From ac776c03b08ae35c03c8e98ce8697502718bc643 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sat, 11 Jul 2026 14:46:32 -0700 Subject: [PATCH 01/56] Fix Jest runner discovery for deeply nested / dynamic-route tests get_test_command's TypeScript runner detector had two defects that left colocated Next.js app-page suites unexecutable through PDD: 1. It walked up only 5 parent directories looking for a jest/vitest/ playwright config, so a page test at e.g. frontend/src/app/hackathon/[eventId]/team/__tests__/ never reached frontend/jest.config.js and fell back to `npx tsx` (no describe/it, wrong cwd). The walk now continues to the JS project root (nearest package.json), with a defensive iteration cap, and never escapes above the project root. 2. Jest was invoked with a trailing bare path that Jest treats as a regex. Next.js dynamic-route segments ([eventId]/[slug]) are regex character classes, so the literal bracketed path matched nothing ("No tests found"). Jest is now invoked with `--runTestsByPath` so the resolved absolute path is matched literally. Verified against promptdriven/pdd_cloud#3251's six migrated app-page suites: all six now resolve to the frontend Jest runner and execute (339/339 tests) through PDD's real command path. Prompt and fingerprint updated to match; adds regression tests for both defects. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +-- pdd/get_test_command.py | 26 ++++++- pdd/prompts/get_test_command_python.prompt | 7 +- tests/test_get_test_command.py | 80 ++++++++++++++++++++++ 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 3bab6eeb9f..8e97d75781 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,16 +2,16 @@ "pdd_version": "0.0.228", "timestamp": "2026-05-06T03:28:21.593685+00:00", "command": "regenerate-public", - "prompt_hash": "a8e7effb43b26e42006d3d2febfc38e0da7c1d53221ac4068d89b6d0c08ecfb5", - "code_hash": "a1e871896a289049c73df4071ec48fc47792ff6cab05332816ba702cd3bc1cb6", + "prompt_hash": "2da30ec79d1f316293d8898f2c1726797f1dbd7e4450e0788ec01e3309bd3e98", + "code_hash": "6a0631cc4364559475b1f71536170871efc486b3f3ccd674c75923a5a3ab1cb0", "example_hash": null, - "test_hash": "5d759cb5c4491420c4ee01d6973f6d0493d02cfe1cdb0bfaf8cb540a868bc365", + "test_hash": "a6f3d48730e93e9888326efb6283503fa0288899c722463a5ecbc6987e2d3da2", "test_files": { - "test_get_test_command.py": "5d759cb5c4491420c4ee01d6973f6d0493d02cfe1cdb0bfaf8cb540a868bc365" + "test_get_test_command.py": "a6f3d48730e93e9888326efb6283503fa0288899c722463a5ecbc6987e2d3da2" }, "include_deps": { "pdd/agentic_langtest.py": "7cb0be38d526d0a630500185427a9f76666d2b79385f7333a7608eb1fb33cfc9", "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" } -} \ No newline at end of file +} diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 4680c62055..7bcab1abf4 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -34,17 +34,39 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: 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 walk continues up to the JS project root (the nearest ``package.json``) + rather than stopping after a fixed number of parents: in Next.js/monorepo + layouts a colocated test can live 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``), so a shallow cap would miss the config and fall + back to a non-test runner. The nearest ancestor config still wins, and the + search never escapes above the project root. + + Jest is invoked with ``--runTestsByPath`` so the resolved absolute path is + matched literally. 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. """ 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 + # Walk up until a config is found or we reach the JS project root (the nearest + # ``package.json``). The runner config lives at or below that root, so we + # never escape into an unrelated parent project. A hard iteration cap guards + # against pathological paths. + for _ in range(40): # 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) + return ("npx jest --no-coverage --runTestsByPath", 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) + # Stop at the JS project root; the config would have been found by now. + if (search_dir / "package.json").exists(): + break parent = search_dir.parent if parent == search_dir: break diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index fb3711ced6..0a9f0eede1 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -8,7 +8,9 @@ You are an expert software engineer implementing the `get_test_command.py` modul - 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). + - For `.ts` and `.tsx` files, search upwards for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). + - Continue the walk up to the JS project root (the nearest `package.json`), not a fixed number of parents: colocated suites in Next.js/monorepo layouts can live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). Never adopt a config above the project root, and cap the walk defensively against pathological paths. + - Invoke Jest with `--runTestsByPath` so the resolved absolute test path is matched literally. 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. - 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. @@ -32,9 +34,10 @@ You are an expert software engineer implementing the `get_test_command.py` modul # Instructions 1. **Implement `_detect_ts_test_runner(test_path: Path)`**: - - Walk up the directory tree (limit: 5 parents). + - Walk up the directory tree until a config is found or the JS project root (nearest `package.json`) is reached; apply a defensive iteration cap. - Check for `playwright.config.[ts|js|mjs]` only if the file is a `.spec` file. - Check for `jest.config` and `vitest.config` variants. + - Use `npx jest --no-coverage --runTestsByPath` for Jest so the absolute test path is matched literally. - 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/`. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 94659b1325..5fffc6f210 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -287,6 +287,86 @@ 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}" + 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 JS project root (package.json). + """ + config_dir = tmp_path / "frontend" + config_dir.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_stops_at_package_json_project_root(self, tmp_path): + """The detector must not escape above the JS project root. + + A jest.config.js in an unrelated ancestor above the nearest package.json + must not be adopted; without an in-project config we fall back to CSV. + """ + # Stray config above the project root — must be ignored. + (tmp_path / "jest.config.js").write_text("module.exports = {};") + project = tmp_path / "some_project" + project.mkdir() + (project / "package.json").write_text("{}") # project root, no jest config + test_file = project / "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-project Jest. + assert result is not None + assert "npx jest" not in result.command, result.command + class TestPlaywrightDetection: """Tests for Playwright detection for .spec.ts files.""" From cb1e2d94488d55b53689deb3076385262406a34f Mon Sep 17 00:00:00 2001 From: "prompt-driven-github-staging[bot]" Date: Sat, 11 Jul 2026 21:53:42 +0000 Subject: [PATCH 02/56] chore: auto-heal prompt/example drift for get_test_command PDD-Auto-Heal-Checkpoint: success --- .pdd/meta/get_test_command_python.json | 15 +-- tests/test_get_test_command.py | 122 ++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 8e97d75781..86efae31c8 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,17 +1,18 @@ { - "pdd_version": "0.0.228", - "timestamp": "2026-05-06T03:28:21.593685+00:00", - "command": "regenerate-public", + "pdd_version": "0.0.302.dev0", + "timestamp": "2026-07-11T21:53:40.586846+00:00", + "command": "test", "prompt_hash": "2da30ec79d1f316293d8898f2c1726797f1dbd7e4450e0788ec01e3309bd3e98", "code_hash": "6a0631cc4364559475b1f71536170871efc486b3f3ccd674c75923a5a3ab1cb0", - "example_hash": null, - "test_hash": "a6f3d48730e93e9888326efb6283503fa0288899c722463a5ecbc6987e2d3da2", + "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", + "test_hash": "2f27e6db01997ada524882f796211dc7fad1227ddfd9ded7a7f34f4547bc9fd3", "test_files": { - "test_get_test_command.py": "a6f3d48730e93e9888326efb6283503fa0288899c722463a5ecbc6987e2d3da2" + "test_get_test_command.py": "2f27e6db01997ada524882f796211dc7fad1227ddfd9ded7a7f34f4547bc9fd3" }, "include_deps": { + "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", "pdd/agentic_langtest.py": "7cb0be38d526d0a630500185427a9f76666d2b79385f7333a7608eb1fb33cfc9", "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" } -} +} \ No newline at end of file diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 5fffc6f210..be55edec0f 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -804,4 +804,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 \ No newline at end of file From ae6c491694b38f8f2659ef35800d3d1a9217669b Mon Sep 17 00:00:00 2001 From: Serhan Date: Sat, 11 Jul 2026 15:49:41 -0700 Subject: [PATCH 03/56] Harden runner detection: repo-root boundary, quoting, Playwright regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review fixes for the TS test-runner detector: - Boundary regression (major): stopping the upward walk at the nearest package.json broke workspace monorepos, where a leaf package has its own manifest but inherits the Jest/Vitest/Playwright config from the workspace root. Walk instead to the repository root (nearest ancestor holding .git) — through intermediate package.json files — so the workspace-root config is still found; never escape above the repo root. - Playwright bracket handling (major): Playwright treats its positional argument as a regex, so a literal `.spec` path under a dynamic route ([slug]) never matched. Regex-escape the path for Playwright (Jest --runTestsByPath / Vitest keep it literal). - Shell safety (major): the resolved path was concatenated unquoted into a command string that callers run with shell=True, so spaces / bracket globs / $() could be re-split or reinterpreted. Shell-quote the path for every runner (also makes POSIX shlex.split round-trip cleanly). Prompt and fingerprint updated to match; adds regressions for workspace-root inheritance, the repo-root no-escape boundary, Playwright bracket escaping, and shell-quoted paths. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +-- pdd/get_test_command.py | 56 +++++++++----- pdd/prompts/get_test_command_python.prompt | 7 +- tests/test_get_test_command.py | 87 +++++++++++++++++++--- 4 files changed, 123 insertions(+), 37 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 86efae31c8..99e1517d1a 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-11T21:53:40.586846+00:00", "command": "test", - "prompt_hash": "2da30ec79d1f316293d8898f2c1726797f1dbd7e4450e0788ec01e3309bd3e98", - "code_hash": "6a0631cc4364559475b1f71536170871efc486b3f3ccd674c75923a5a3ab1cb0", + "prompt_hash": "ece79c645ff581dfa0762d910bb58d9cd391f3c121555a2b500703e2dd4edbe8", + "code_hash": "41d45689ce41252616d05bb771301620bd335d909710c6c06cbde7b907c1cea4", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "2f27e6db01997ada524882f796211dc7fad1227ddfd9ded7a7f34f4547bc9fd3", + "test_hash": "282cba43f4fb75e996b62da48e074c6d6f4df7d5f4c9edcc97736d2732418dc1", "test_files": { - "test_get_test_command.py": "2f27e6db01997ada524882f796211dc7fad1227ddfd9ded7a7f34f4547bc9fd3" + "test_get_test_command.py": "282cba43f4fb75e996b62da48e074c6d6f4df7d5f4c9edcc97736d2732418dc1" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", @@ -15,4 +15,4 @@ "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" } -} \ No newline at end of file +} diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 7bcab1abf4..2cca95a082 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -11,6 +11,8 @@ from pathlib import Path from typing import Optional, Tuple import csv +import re +import shlex from .agentic_langtest import default_verify_cmd_for from .get_language import get_language @@ -35,28 +37,33 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: 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 walk continues up to the JS project root (the nearest ``package.json``) - rather than stopping after a fixed number of parents: in Next.js/monorepo - layouts a colocated test can live many directories below its runner config - (e.g. a page test under + The walk continues up to the repository root (the nearest ancestor containing + ``.git``) rather than stopping after a fixed number of parents: in + Next.js/monorepo layouts a colocated test can live 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``), so a shallow cap would miss the config and fall - back to a non-test runner. The nearest ancestor config still wins, and the - search never escapes above the project root. + back to a non-test runner. The nearest ancestor config wins. The repository + root — not the nearest ``package.json`` — is the boundary: a workspace leaf + package has its own ``package.json`` yet inherits Jest/Vitest/Playwright + configuration from the workspace root, so stopping at the leaf manifest would + miss it. The search never escapes above the repository root, and a hard + iteration cap guards against pathological paths. Jest is invoked with ``--runTestsByPath`` so the resolved absolute path is - matched literally. 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. + 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. """ is_spec = test_path.name.endswith(('.spec.ts', '.spec.tsx')) search_dir = test_path.resolve().parent - # Walk up until a config is found or we reach the JS project root (the nearest - # ``package.json``). The runner config lives at or below that root, so we - # never escape into an unrelated parent project. A hard iteration cap guards - # against pathological paths. - for _ in range(40): + # Walk up until a config is found or we reach the repository root (a directory + # holding ``.git``). Continue *through* intermediate ``package.json`` files so + # a config that lives at the workspace root is still discovered. A hard + # iteration cap guards against pathological paths. + for _ in range(80): # 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) @@ -64,8 +71,9 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: return ("npx jest --no-coverage --runTestsByPath", 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) - # Stop at the JS project root; the config would have been found by now. - if (search_dir / "package.json").exists(): + # Stop at the repository root; the config would have been found by now. + # ``.git`` is a directory in a normal clone and a file in a worktree. + if (search_dir / ".git").exists(): break parent = search_dir.parent if parent == search_dir: @@ -126,7 +134,19 @@ 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. + 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() diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 0a9f0eede1..0a2499fd3c 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -9,8 +9,9 @@ You are an expert software engineer implementing the `get_test_command.py` modul - Fallback to `None` to trigger agentic logic. 2. **TypeScript Smart Detection**: - For `.ts` and `.tsx` files, search upwards for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). - - Continue the walk up to the JS project root (the nearest `package.json`), not a fixed number of parents: colocated suites in Next.js/monorepo layouts can live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). Never adopt a config above the project root, and cap the walk defensively against pathological paths. + - Continue the walk up to the repository root (the nearest ancestor containing `.git`, which is a directory in a clone and a file in a worktree), not a fixed number of parents: colocated suites in Next.js/monorepo layouts can live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). Walk *through* intermediate `package.json` files: a workspace leaf package has its own manifest but inherits its runner config from the workspace root, so the boundary must be the repository root, not the nearest `package.json`. Never adopt a config above the repository root, and cap the walk defensively against pathological paths. - Invoke Jest with `--runTestsByPath` so the resolved absolute test path is matched literally. 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. + - When constructing the runner command string, append the resolved absolute test path with per-runner escaping: Playwright treats its positional argument as a regular expression, so regex-escape the path; Jest (`--runTestsByPath`) and Vitest take it literally. In all cases shell-quote the argument, because callers execute the command string with `shell=True` and an unquoted path with spaces or shell metacharacters would be re-split or reinterpreted. - 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. @@ -34,7 +35,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul # Instructions 1. **Implement `_detect_ts_test_runner(test_path: Path)`**: - - Walk up the directory tree until a config is found or the JS project root (nearest `package.json`) is reached; apply a defensive iteration cap. + - Walk up the directory tree until a config is found or the repository root (a directory holding `.git`) is reached, walking through intermediate `package.json` files; apply a defensive iteration cap. - Check for `playwright.config.[ts|js|mjs]` only if the file is a `.spec` file. - Check for `jest.config` and `vitest.config` variants. - Use `npx jest --no-coverage --runTestsByPath` for Jest so the absolute test path is matched literally. @@ -46,7 +47,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul - 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. + - Ensure the command returned for TS runners uses the absolute path (`resolve()`) of the test file, regex-escaped for Playwright and shell-quoted for every runner (see the TypeScript Smart Detection requirement). - For CSV commands, replace the `{file}` placeholder with the input file string. # Deliverables diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index be55edec0f..aaf8a140cb 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -3,6 +3,7 @@ from unittest.mock import patch, mock_open, MagicMock import csv import io +import shlex import sys import os @@ -293,10 +294,11 @@ def test_jest_config_found_more_than_five_parents_up(self, tmp_path): 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 JS project root (package.json). + 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). @@ -346,27 +348,90 @@ def test_dynamic_route_bracket_path_is_targeted_literally(self, tmp_path): # The resolved absolute path (brackets intact) must appear literally. assert str(test_file.resolve()) in result.command, result.command - def test_walk_stops_at_package_json_project_root(self, tmp_path): - """The detector must not escape above the JS project root. + def test_walk_finds_workspace_root_config_past_leaf_package_json(self, tmp_path): + """A workspace leaf must inherit the workspace-root runner config. - A jest.config.js in an unrelated ancestor above the nearest package.json - must not be adopted; without an in-project config we fall back to CSV. + 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. """ - # Stray config above the project root — must be ignored. + 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 = {};") - project = tmp_path / "some_project" - project.mkdir() - (project / "package.json").write_text("{}") # project root, no jest config - test_file = project / "src" / "test_calculator.ts" + 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-project Jest. + # 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_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 TestPlaywrightDetection: """Tests for Playwright detection for .spec.ts files.""" From fc8c7faa8ef954c69e6d403d4143d3010dbb37b3 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sat, 11 Jul 2026 18:48:30 -0700 Subject: [PATCH 04/56] Make runner-config boundary workspace-aware (round-2 review) Round-1 fixed a boundary that stopped at the nearest package.json (which missed workspace-root configs), but switching to a plain .git boundary over-corrected: an independent package inside a git repo would wrongly adopt the repository-root config, and a non-git tree could walk to the filesystem root. Boundary is now the JS project root (nearest package.json), crossed only when the package is a member of an ancestor workspace (a `workspaces` field, `pnpm-workspace.yaml`, or `lerna.json`), and never above the repository root (.git). Adds `_belongs_to_ancestor_workspace` plus regressions for an independent leaf package and a non-git stray-ancestor config; prompt + fingerprint updated. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_test_command.py | 69 ++++++++++++++++------ pdd/prompts/get_test_command_python.prompt | 5 +- tests/test_get_test_command.py | 43 ++++++++++++++ 4 files changed, 101 insertions(+), 24 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 99e1517d1a..5bca1619f5 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-11T21:53:40.586846+00:00", "command": "test", - "prompt_hash": "ece79c645ff581dfa0762d910bb58d9cd391f3c121555a2b500703e2dd4edbe8", - "code_hash": "41d45689ce41252616d05bb771301620bd335d909710c6c06cbde7b907c1cea4", + "prompt_hash": "ea9519d39967e31f42976aff272c75e988c800705904ec04c3ce4bc32f381af9", + "code_hash": "815a6df53d9188790624e2302d7312f614b0acc572acda564f64d8d276cc5763", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "282cba43f4fb75e996b62da48e074c6d6f4df7d5f4c9edcc97736d2732418dc1", + "test_hash": "40d35b61e2774356f57afbfd9cee1f0805d46cb04d5033075fd741db040967c7", "test_files": { - "test_get_test_command.py": "282cba43f4fb75e996b62da48e074c6d6f4df7d5f4c9edcc97736d2732418dc1" + "test_get_test_command.py": "40d35b61e2774356f57afbfd9cee1f0805d46cb04d5033075fd741db040967c7" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 2cca95a082..0f8bd88e30 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Optional, Tuple import csv +import json import re import shlex @@ -30,6 +31,36 @@ class TestCommand: cwd: Optional[Path] = None +def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: + """Return True if ``package_dir`` (which holds a ``package.json``) is a member + of an ancestor JS workspace, so its runner config may live at the workspace + root rather than in the leaf package. + + Detects the common workspace declarations — a ``workspaces`` field in an + ancestor ``package.json``, or a ``pnpm-workspace.yaml`` / ``lerna.json`` — and + never looks above the repository root (``.git``). + """ + ancestor = package_dir.parent + for _ in range(80): + if (ancestor / "pnpm-workspace.yaml").exists() or (ancestor / "lerna.json").exists(): + return True + ancestor_manifest = ancestor / "package.json" + if ancestor_manifest.exists(): + try: + manifest = json.loads(ancestor_manifest.read_text(encoding="utf-8")) + except (ValueError, OSError): + manifest = {} + if manifest.get("workspaces"): + return True + if (ancestor / ".git").exists(): + break + parent = ancestor.parent + if parent == ancestor: + break + ancestor = parent + return False + + 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. @@ -37,18 +68,20 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: 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 walk continues up to the repository root (the nearest ancestor containing - ``.git``) rather than stopping after a fixed number of parents: in - Next.js/monorepo layouts a colocated test can live 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``), so a shallow cap would miss the config and fall - back to a non-test runner. The nearest ancestor config wins. The repository - root — not the nearest ``package.json`` — is the boundary: a workspace leaf - package has its own ``package.json`` yet inherits Jest/Vitest/Playwright - configuration from the workspace root, so stopping at the leaf manifest would - miss it. The search never escapes above the repository root, and a hard - iteration cap guards against pathological paths. + 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. Jest is invoked with ``--runTestsByPath`` so the resolved absolute path is matched literally (see ``get_test_command_for_file`` for how the path is @@ -59,10 +92,6 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: """ is_spec = test_path.name.endswith(('.spec.ts', '.spec.tsx')) search_dir = test_path.resolve().parent - # Walk up until a config is found or we reach the repository root (a directory - # holding ``.git``). Continue *through* intermediate ``package.json`` files so - # a config that lives at the workspace root is still discovered. A hard - # iteration cap guards against pathological paths. for _ in range(80): # 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')): @@ -71,8 +100,12 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: return ("npx jest --no-coverage --runTestsByPath", 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) - # Stop at the repository root; the config would have been found by now. - # ``.git`` is a directory in a normal clone and a file in a worktree. + # Stop at the JS project boundary (nearest package.json), but cross it when + # this package is a member of an ancestor workspace whose config lives at + # the workspace root. + if (search_dir / "package.json").exists() and not _belongs_to_ancestor_workspace(search_dir): + break + # Never escape the repository, even absent an in-project config. if (search_dir / ".git").exists(): break parent = search_dir.parent diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 0a2499fd3c..bc3181d613 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -9,7 +9,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul - Fallback to `None` to trigger agentic logic. 2. **TypeScript Smart Detection**: - For `.ts` and `.tsx` files, search upwards for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). - - Continue the walk up to the repository root (the nearest ancestor containing `.git`, which is a directory in a clone and a file in a worktree), not a fixed number of parents: colocated suites in Next.js/monorepo layouts can live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). Walk *through* intermediate `package.json` files: a workspace leaf package has its own manifest but inherits its runner config from the workspace root, so the boundary must be the repository root, not the nearest `package.json`. Never adopt a config above the repository root, and cap the walk defensively against pathological paths. + - Continue the walk beyond a fixed number of parents so colocated suites in Next.js/monorepo layouts are found even when they live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). The nearest ancestor config wins. Stop the walk at the JS project boundary — the nearest `package.json` — with two refinements: (a) when that package is a member of an ancestor workspace (a `workspaces` field in an ancestor `package.json`, or `pnpm-workspace.yaml` / `lerna.json`), continue *through* the leaf to the workspace root, whose config it inherits; (b) an independent package must not adopt an unrelated ancestor's config, and the walk must never cross the repository root (`.git`, a directory in a clone and a file in a worktree). Cap the walk defensively against pathological paths. - Invoke Jest with `--runTestsByPath` so the resolved absolute test path is matched literally. 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. - When constructing the runner command string, append the resolved absolute test path with per-runner escaping: Playwright treats its positional argument as a regular expression, so regex-escape the path; Jest (`--runTestsByPath`) and Vitest take it literally. In all cases shell-quote the argument, because callers execute the command string with `shell=True` and an unquoted path with spaces or shell metacharacters would be re-split or reinterpreted. - If found, return the command with the specific directory containing the config as the `cwd`. @@ -35,7 +35,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul # Instructions 1. **Implement `_detect_ts_test_runner(test_path: Path)`**: - - Walk up the directory tree until a config is found or the repository root (a directory holding `.git`) is reached, walking through intermediate `package.json` files; apply a defensive iteration cap. + - Walk up the directory tree until a config is found or the JS project boundary (nearest `package.json`) is reached; cross that boundary only when the package belongs to an ancestor workspace, and never cross the repository root (`.git`). Add a helper that detects ancestor-workspace membership. Apply a defensive iteration cap. - Check for `playwright.config.[ts|js|mjs]` only if the file is a `.spec` file. - Check for `jest.config` and `vitest.config` variants. - Use `npx jest --no-coverage --runTestsByPath` for Jest so the absolute test path is matched literally. @@ -53,6 +53,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul # Deliverables A single file `pdd/get_test_command.py` containing: - `TestCommand` dataclass. +- `_belongs_to_ancestor_workspace` helper function. - `_detect_ts_test_runner` helper function. - `_load_language_format` helper function. - `get_test_command_for_file` public function. \ No newline at end of file diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index aaf8a140cb..7512effb65 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -393,6 +393,49 @@ def test_walk_stops_at_repository_root_and_does_not_escape(self, tmp_path): 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_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. From 1400c4f26a76fe072f234da827bbab7e32031058 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sat, 11 Jul 2026 18:53:41 -0700 Subject: [PATCH 05/56] Document POSIX-shell scope of runner-command quoting (round-2 F2) Clarify that TestCommand.command is a POSIX-shell string (matching how all pdd callers execute verify commands) so shlex.quote is the correct quoting here; making runner execution safe under Windows cmd.exe would require moving all callers to argv + shell=False, a pre-existing cross-cutting change out of scope for runner detection. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 2 +- pdd/get_test_command.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 5bca1619f5..20b53e102a 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -3,7 +3,7 @@ "timestamp": "2026-07-11T21:53:40.586846+00:00", "command": "test", "prompt_hash": "ea9519d39967e31f42976aff272c75e988c800705904ec04c3ce4bc32f381af9", - "code_hash": "815a6df53d9188790624e2302d7312f614b0acc572acda564f64d8d276cc5763", + "code_hash": "e0dcea1e7c92b4954cc2a1badebe31100f5a016857e7776aebb538ac31547718", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", "test_hash": "40d35b61e2774356f57afbfd9cee1f0805d46cb04d5033075fd741db040967c7", "test_files": { diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 0f8bd88e30..f67e83e279 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -175,6 +175,14 @@ def get_test_command_for_file(test_file: str, language: Optional[str] = None) -> # 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: From f18d2e9af2ef4349ab24467c4f85587c1d1c46cd Mon Sep 17 00:00:00 2001 From: Serhan Date: Sat, 11 Jul 2026 19:10:14 -0700 Subject: [PATCH 06/56] Prove workspace membership by glob, not mere declaration (round-3 review) _belongs_to_ancestor_workspace treated any ancestor `workspaces` (or pnpm/lerna) declaration as membership, so an unrelated package (e.g. `vendor/tool`) beneath a workspace root would wrongly adopt the root config. Now read the ancestor's declared package globs and require the leaf package's path (relative to the declaring ancestor) to actually match one, with segment-wise `*`/`**` semantics; unparseable pnpm YAML is treated conservatively as non-member. Adds a negative test for an unrelated package under a workspace root; prompt + fingerprint updated. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_test_command.py | 95 +++++++++++++++++++--- pdd/prompts/get_test_command_python.prompt | 5 +- tests/test_get_test_command.py | 25 ++++++ 4 files changed, 116 insertions(+), 17 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 20b53e102a..3e70df6576 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-11T21:53:40.586846+00:00", "command": "test", - "prompt_hash": "ea9519d39967e31f42976aff272c75e988c800705904ec04c3ce4bc32f381af9", - "code_hash": "e0dcea1e7c92b4954cc2a1badebe31100f5a016857e7776aebb538ac31547718", + "prompt_hash": "b0541f21d64d0fa99afc3f37b3bf6f0ebf4f042f649bd49dd409e7d86ba89871", + "code_hash": "858e0f31ef578a3771b26230e833b9e68f836a4fb6c0450759ee50389fe6c824", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "40d35b61e2774356f57afbfd9cee1f0805d46cb04d5033075fd741db040967c7", + "test_hash": "5c7fc886dbe38c92133f11763ab2e4bf7da993ed01a9b62d1762862b5c2667d2", "test_files": { - "test_get_test_command.py": "40d35b61e2774356f57afbfd9cee1f0805d46cb04d5033075fd741db040967c7" + "test_get_test_command.py": "5c7fc886dbe38c92133f11763ab2e4bf7da993ed01a9b62d1762862b5c2667d2" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index f67e83e279..266e9b0b3e 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Optional, Tuple import csv +import fnmatch import json import re import shlex @@ -31,26 +32,98 @@ class TestCommand: cwd: Optional[Path] = None +def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str) -> 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. + """ + pat_parts = [p for p in pattern.strip("/").split("/") if p not in ("", ".")] + return _match_segments(list(rel_parts), pat_parts) + + +def _match_segments(rel: list, pat: list) -> bool: + if not pat: + return not rel + head, rest = pat[0], pat[1:] + if head == "**": + # Zero or more segments. + for i in range(len(rel) + 1): + if _match_segments(rel[i:], rest): + return True + return False + if not rel: + return False + return fnmatch.fnmatch(rel[0], head) and _match_segments(rel[1:], rest) + + +def _workspace_globs_for(ancestor: Path) -> 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 requires a YAML + parser; if unavailable we conservatively return no globs so membership stays + unproven rather than falsely asserted. + """ + globs: list = [] + manifest_path = ancestor / "package.json" + if manifest_path.exists(): + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (ValueError, OSError): + manifest = {} + ws = manifest.get("workspaces") + if isinstance(ws, dict): + ws = ws.get("packages") + if isinstance(ws, list): + globs.extend(str(p) for p in ws) + lerna_path = ancestor / "lerna.json" + if lerna_path.exists(): + try: + lerna = json.loads(lerna_path.read_text(encoding="utf-8")) + pkgs = lerna.get("packages") + if isinstance(pkgs, list): + globs.extend(str(p) for p in pkgs) + elif pkgs is None: + globs.append("packages/*") # lerna default + except (ValueError, OSError): + pass + pnpm_path = ancestor / "pnpm-workspace.yaml" + if pnpm_path.exists(): + try: + import yaml # optional dependency + data = yaml.safe_load(pnpm_path.read_text(encoding="utf-8")) or {} + pkgs = data.get("packages") + if isinstance(pkgs, list): + globs.extend(str(p) for p in pkgs) + except Exception: + pass # conservative: unparseable pnpm workspace → no membership claim + return globs + + def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: """Return True if ``package_dir`` (which holds a ``package.json``) is a member of an ancestor JS workspace, so its runner config may live at the workspace root rather than in the leaf package. - Detects the common workspace declarations — a ``workspaces`` field in an - ancestor ``package.json``, or a ``pnpm-workspace.yaml`` / ``lerna.json`` — and - never looks above the repository root (``.git``). + 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. + An unrelated package (e.g. a vendored ``vendor/tool``) beneath a workspace + root is therefore not treated as a member. The search never looks above the + repository root (``.git``). """ ancestor = package_dir.parent for _ in range(80): - if (ancestor / "pnpm-workspace.yaml").exists() or (ancestor / "lerna.json").exists(): - return True - ancestor_manifest = ancestor / "package.json" - if ancestor_manifest.exists(): + globs = _workspace_globs_for(ancestor) + if globs: try: - manifest = json.loads(ancestor_manifest.read_text(encoding="utf-8")) - except (ValueError, OSError): - manifest = {} - if manifest.get("workspaces"): + rel_parts = tuple(package_dir.resolve().relative_to(ancestor.resolve()).parts) + except ValueError: + rel_parts = () + if rel_parts and any(_relative_matches_workspace_glob(rel_parts, g) for g in globs): return True if (ancestor / ".git").exists(): break diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index bc3181d613..5feefd6a7a 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -9,7 +9,8 @@ You are an expert software engineer implementing the `get_test_command.py` modul - Fallback to `None` to trigger agentic logic. 2. **TypeScript Smart Detection**: - For `.ts` and `.tsx` files, search upwards for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). - - Continue the walk beyond a fixed number of parents so colocated suites in Next.js/monorepo layouts are found even when they live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). The nearest ancestor config wins. Stop the walk at the JS project boundary — the nearest `package.json` — with two refinements: (a) when that package is a member of an ancestor workspace (a `workspaces` field in an ancestor `package.json`, or `pnpm-workspace.yaml` / `lerna.json`), continue *through* the leaf to the workspace root, whose config it inherits; (b) an independent package must not adopt an unrelated ancestor's config, and the walk must never cross the repository root (`.git`, a directory in a clone and a file in a worktree). Cap the walk defensively against pathological paths. + - Continue the walk beyond a fixed number of parents so colocated suites in Next.js/monorepo layouts are found even when they live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). The nearest ancestor config wins. Stop the walk at the JS project boundary — the nearest `package.json` — with two refinements: (a) when that package is a member of an ancestor workspace, continue *through* the leaf to the workspace root, whose config it inherits; (b) an independent package must not adopt an unrelated ancestor's config, and the walk must never cross the repository root (`.git`, a directory in a clone and a file in a worktree). Cap the walk defensively against pathological paths. + - Prove workspace membership rather than assuming it: read the ancestor's declared package globs (npm/yarn `workspaces` as a list or `{packages: [...]}`, `lerna.json` `packages`, `pnpm-workspace.yaml` `packages`) and require that the leaf package's path — relative to the declaring ancestor — actually matches one of those globs (segment-wise, where `*` matches one path segment and `**` matches any depth). A merely-present `workspaces` declaration does not make an unrelated sibling (e.g. `vendor/tool`) a member. If pnpm's YAML cannot be parsed, conservatively treat membership as unproven. - Invoke Jest with `--runTestsByPath` so the resolved absolute test path is matched literally. 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. - When constructing the runner command string, append the resolved absolute test path with per-runner escaping: Playwright treats its positional argument as a regular expression, so regex-escape the path; Jest (`--runTestsByPath`) and Vitest take it literally. In all cases shell-quote the argument, because callers execute the command string with `shell=True` and an unquoted path with spaces or shell metacharacters would be re-split or reinterpreted. - If found, return the command with the specific directory containing the config as the `cwd`. @@ -53,7 +54,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul # Deliverables A single file `pdd/get_test_command.py` containing: - `TestCommand` dataclass. -- `_belongs_to_ancestor_workspace` helper function. +- Workspace-membership helpers (glob matching + declared-glob extraction) and `_belongs_to_ancestor_workspace`. - `_detect_ts_test_runner` helper function. - `_load_language_format` helper function. - `get_test_command_for_file` public function. \ No newline at end of file diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 7512effb65..9587885ccd 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -417,6 +417,31 @@ def test_independent_leaf_package_does_not_adopt_repo_root_config(self, tmp_path 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_non_git_project_stops_at_package_json_boundary(self, tmp_path): """Without a .git ancestor, stop at the nearest package.json. From 121318c3752b04cf53eaac9a48199578b34e0e02 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sat, 11 Jul 2026 22:05:09 -0700 Subject: [PATCH 07/56] test(get_test_command): reproduce workspace exclusion + brace-glob gaps Failing tests for gltanaka's review finding: _belongs_to_ancestor_workspace ignores pnpm `!` exclusions and treats brace expansion literally. A package under packages/app/test/fixture (excluded by `!**/test/**`) is wrongly reported a member, and a packages/{app,lib} member is wrongly missed. Co-Authored-By: Claude Opus 4.8 --- tests/test_get_test_command.py | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 9587885ccd..0a94609dd1 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -442,6 +442,55 @@ def test_unrelated_package_under_workspace_root_is_not_a_member(self, tmp_path): 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_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. From fab42e985dc5d966d2e11d5f39a62da47e561ae9 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sat, 11 Jul 2026 22:07:42 -0700 Subject: [PATCH 08/56] fix(get_test_command): honor workspace include/exclude + brace globs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _belongs_to_ancestor_workspace treated any ancestor `workspaces`/pnpm/ lerna declaration as membership and matched globs literally, so a package explicitly excluded by pnpm's supported `!**/test/**` (or missed by a `{a,b}` brace glob) was mis-classified — an excluded/independent package could cross its own package.json boundary and adopt the repo-root runner. Membership now requires a positive glob match AND no `!` exclusion match, with brace alternations expanded and segment-wise `*`/`**` semantics. Adds `_expand_braces`, `_split_top_level_commas`, `_package_matches_workspace`; pnpm YAML parsing narrowed to specific exceptions. Prompt + fingerprint updated. Closes gltanaka's changes-requested review. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_test_command.py | 95 +++++++++++++++++++--- pdd/prompts/get_test_command_python.prompt | 2 +- 3 files changed, 89 insertions(+), 16 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 3e70df6576..8c78f9f380 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-11T21:53:40.586846+00:00", "command": "test", - "prompt_hash": "b0541f21d64d0fa99afc3f37b3bf6f0ebf4f042f649bd49dd409e7d86ba89871", - "code_hash": "858e0f31ef578a3771b26230e833b9e68f836a4fb6c0450759ee50389fe6c824", + "prompt_hash": "ffbd013640abdd5550a89004a44c34d50736730984a66b576787980431963d74", + "code_hash": "c693823a7e0361dcf79de41a7909e3ab809fbd9aa328a93ee3c6ad9d8de0b604", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "5c7fc886dbe38c92133f11763ab2e4bf7da993ed01a9b62d1762862b5c2667d2", + "test_hash": "16e3fee5882653a9fc5d54bf853d9e9326eeb61c970e6472c23ea0332626f8b4", "test_files": { - "test_get_test_command.py": "5c7fc886dbe38c92133f11763ab2e4bf7da993ed01a9b62d1762862b5c2667d2" + "test_get_test_command.py": "16e3fee5882653a9fc5d54bf853d9e9326eeb61c970e6472c23ea0332626f8b4" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 266e9b0b3e..bc9b418c4f 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -94,15 +94,87 @@ def _workspace_globs_for(ancestor: Path) -> list: if pnpm_path.exists(): try: import yaml # optional dependency - data = yaml.safe_load(pnpm_path.read_text(encoding="utf-8")) or {} - pkgs = data.get("packages") - if isinstance(pkgs, list): - globs.extend(str(p) for p in pkgs) - except Exception: - pass # conservative: unparseable pnpm workspace → no membership claim + except ImportError: + yaml = None + if yaml is not None: + try: + data = yaml.safe_load(pnpm_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError): + data = None # conservative: unparseable pnpm workspace + if isinstance(data, dict): + pkgs = data.get("packages") + if isinstance(pkgs, list): + globs.extend(str(p) for p in pkgs) return globs +def _split_top_level_commas(body: str) -> list: + """Split a brace body on commas that are not inside a nested brace group.""" + 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 = [] + else: + current.append(char) + parts.append("".join(current)) + return parts + + +def _expand_braces(pattern: str) -> list: + """Expand ``{a,b}`` brace alternations (as npm/Yarn workspace globs use) into + concrete patterns. Unbalanced or single-option braces are left literal.""" + start = pattern.find("{") + if start == -1: + return [pattern] + depth, end = 0, -1 + for i in range(start, len(pattern)): + if pattern[i] == "{": + depth += 1 + elif pattern[i] == "}": + depth -= 1 + if depth == 0: + end = i + break + if end == -1: + return [pattern] # unbalanced → treat literally + options = _split_top_level_commas(pattern[start + 1:end]) + if len(options) < 2: + return [pattern] # not a real alternation → literal + prefix, suffix = pattern[:start], pattern[end + 1:] + expanded: list = [] + for option in options: + expanded.extend(_expand_braces(prefix + option + suffix)) + return expanded + + +def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: + """Return True when ``rel_parts`` matches the workspace globs' include/exclude + semantics: at least one positive pattern matches and no ``!`` exclusion does. + + Exclusions (a leading ``!``, e.g. pnpm's ``!**/test/**``) are honored, and + brace alternations are expanded before matching. + """ + positives, negatives = [], [] + for raw in globs: + raw = str(raw).strip() + if not raw: + continue + if raw.startswith("!"): + negatives.extend(_expand_braces(raw[1:])) + else: + positives.extend(_expand_braces(raw)) + if not any(_relative_matches_workspace_glob(rel_parts, p) for p in positives): + return False + return not any(_relative_matches_workspace_glob(rel_parts, n) for n in negatives) + + def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: """Return True if ``package_dir`` (which holds a ``package.json``) is a member of an ancestor JS workspace, so its runner config may live at the workspace @@ -110,10 +182,11 @@ def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: 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. - An unrelated package (e.g. a vendored ``vendor/tool``) beneath a workspace - root is therefore not treated as a member. The search never looks above the - repository root (``.git``). + ``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``). """ ancestor = package_dir.parent for _ in range(80): @@ -123,7 +196,7 @@ def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: rel_parts = tuple(package_dir.resolve().relative_to(ancestor.resolve()).parts) except ValueError: rel_parts = () - if rel_parts and any(_relative_matches_workspace_glob(rel_parts, g) for g in globs): + if rel_parts and _package_matches_workspace(rel_parts, globs): return True if (ancestor / ".git").exists(): break diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 5feefd6a7a..aa46169744 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -10,7 +10,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul 2. **TypeScript Smart Detection**: - For `.ts` and `.tsx` files, search upwards for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). - Continue the walk beyond a fixed number of parents so colocated suites in Next.js/monorepo layouts are found even when they live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). The nearest ancestor config wins. Stop the walk at the JS project boundary — the nearest `package.json` — with two refinements: (a) when that package is a member of an ancestor workspace, continue *through* the leaf to the workspace root, whose config it inherits; (b) an independent package must not adopt an unrelated ancestor's config, and the walk must never cross the repository root (`.git`, a directory in a clone and a file in a worktree). Cap the walk defensively against pathological paths. - - Prove workspace membership rather than assuming it: read the ancestor's declared package globs (npm/yarn `workspaces` as a list or `{packages: [...]}`, `lerna.json` `packages`, `pnpm-workspace.yaml` `packages`) and require that the leaf package's path — relative to the declaring ancestor — actually matches one of those globs (segment-wise, where `*` matches one path segment and `**` matches any depth). A merely-present `workspaces` declaration does not make an unrelated sibling (e.g. `vendor/tool`) a member. If pnpm's YAML cannot be parsed, conservatively treat membership as unproven. + - Prove workspace membership rather than assuming it: read the ancestor's declared package globs (npm/yarn `workspaces` as a list or `{packages: [...]}`, `lerna.json` `packages`, `pnpm-workspace.yaml` `packages`) and require that the leaf package's path — relative to the declaring ancestor — actually matches them. Implement include/exclude semantics faithfully: a package is a member only when it matches at least one positive glob AND no `!`-prefixed exclusion glob (e.g. pnpm's documented `!**/test/**`). Expand brace alternations (`{a,b}`) before matching, and match segment-wise where `*` matches one path segment and `**` matches any depth. A merely-present `workspaces` declaration does not make an unrelated sibling (e.g. `vendor/tool`) or an explicitly excluded package a member. If pnpm's YAML cannot be parsed, conservatively treat membership as unproven. - Invoke Jest with `--runTestsByPath` so the resolved absolute test path is matched literally. 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. - When constructing the runner command string, append the resolved absolute test path with per-runner escaping: Playwright treats its positional argument as a regular expression, so regex-escape the path; Jest (`--runTestsByPath`) and Vitest take it literally. In all cases shell-quote the argument, because callers execute the command string with `shell=True` and an unquoted path with spaces or shell metacharacters would be re-split or reinterpreted. - If found, return the command with the specific directory containing the config as the `cwd`. From 4c7d68e377108c9970ef62a0ce9c1bfb41f0b40d Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 11:29:53 -0700 Subject: [PATCH 09/56] fix(get_test_command): harden workspace resolution (round-4 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent Codex gpt-5.6-sol xhigh review found six issues; five fixed (one rejected as pre-existing/out-of-scope, see below). - F2 nested workspace root: _workspace_root_for now returns the declaring workspace root, used as a traversal ceiling so an independent intermediate package.json between a member and its workspace root no longer stops the walk (e.g. member vendor/container/packages/app under root glob vendor/container/packages/*). Previously returned None instead of Jest. - F3 source precedence: pnpm-workspace.yaml is authoritative — pnpm ignores the package.json `workspaces` field, so a stale/attacker-controlled list no longer unions in and over-authorizes membership. Missing/unparseable pnpm YAML fails closed. - F4 malformed manifests: a package.json/lerna.json whose parsed top level is not an object ([] or a bare string) contributes no globs instead of raising AttributeError during discovery. - F5 symlink containment: the repo root is anchored lexically (nearest .git without following symlinks); a test dir symlinked outside the repo can no longer smuggle the walk into an out-of-repo config. In-repo symlinks still resolve normally. - F6 brace-bomb budget: brace expansion is bounded by _MAX_BRACE_EXPANSION; an untrusted {a,b}-style brace bomb fails membership closed instead of materializing an exponential list. F1 (Windows shell=True quoting) rejected: pdd's verify boundary is POSIX-only (callers use subprocess start_new_session=True; no Windows classifier; base already returned unquoted shell=True strings). The argv/shell=False migration is the cross-cutting change the module docstring explicitly scopes out. Prompt raised to doctrine altitude for the new behaviors; fingerprint meta re-synced (prompt/code/test hashes). +11 regression/negative-control tests. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +- pdd/get_test_command.py | 215 ++++++++++++++++----- pdd/prompts/get_test_command_python.prompt | 10 +- tests/test_get_test_command.py | 178 ++++++++++++++++- 4 files changed, 356 insertions(+), 57 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 8c78f9f380..8876549fef 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-11T21:53:40.586846+00:00", + "timestamp": "2026-07-12T18:28:34.134404+00:00", "command": "test", - "prompt_hash": "ffbd013640abdd5550a89004a44c34d50736730984a66b576787980431963d74", - "code_hash": "c693823a7e0361dcf79de41a7909e3ab809fbd9aa328a93ee3c6ad9d8de0b604", + "prompt_hash": "318939812b47766abebcc18fab723cfc628b3ec2b2d068edd4500726d05a50dd", + "code_hash": "61430a1185b02ce52f742168f842a8884b5032014318918f37127467ba117b17", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "16e3fee5882653a9fc5d54bf853d9e9326eeb61c970e6472c23ea0332626f8b4", + "test_hash": "c88cf8773b1d6482c45cd8678786b62a76f74023f9b9486cf63f0fc8c0b1c7b2", "test_files": { - "test_get_test_command.py": "16e3fee5882653a9fc5d54bf853d9e9326eeb61c970e6472c23ea0332626f8b4" + "test_get_test_command.py": "c88cf8773b1d6482c45cd8678786b62a76f74023f9b9486cf63f0fc8c0b1c7b2" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index bc9b418c4f..fbeec42272 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -13,6 +13,7 @@ import csv import fnmatch import json +import os import re import shlex @@ -20,6 +21,18 @@ from .get_language import get_language +# 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 + + +class _BraceBudgetError(Exception): + """Raised when a brace pattern would expand past ``_MAX_BRACE_EXPANSION``.""" + + @dataclass class TestCommand: """Bundles a test command string with its required working directory. @@ -63,10 +76,35 @@ def _workspace_globs_for(ancestor: Path) -> 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 requires a YAML - parser; if unavailable we conservatively return no globs so membership stays + ``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. """ + pnpm_path = ancestor / "pnpm-workspace.yaml" + if pnpm_path.exists(): + try: + import yaml # optional dependency + except ImportError: + return [] # cannot parse pnpm config → membership unproven (fail closed) + try: + data = yaml.safe_load(pnpm_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError): + return [] # conservative: unparseable pnpm workspace + if isinstance(data, dict): + pkgs = data.get("packages") + if isinstance(pkgs, list): + return [str(p) for p in pkgs] + return [] + globs: list = [] manifest_path = ancestor / "package.json" if manifest_path.exists(): @@ -74,37 +112,24 @@ def _workspace_globs_for(ancestor: Path) -> list: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except (ValueError, OSError): manifest = {} - ws = manifest.get("workspaces") - if isinstance(ws, dict): - ws = ws.get("packages") - if isinstance(ws, list): - globs.extend(str(p) for p in ws) + if isinstance(manifest, dict): + ws = manifest.get("workspaces") + if isinstance(ws, dict): + ws = ws.get("packages") + if isinstance(ws, list): + globs.extend(str(p) for p in ws) lerna_path = ancestor / "lerna.json" if lerna_path.exists(): try: lerna = json.loads(lerna_path.read_text(encoding="utf-8")) + except (ValueError, OSError): + lerna = {} + if isinstance(lerna, dict): pkgs = lerna.get("packages") if isinstance(pkgs, list): globs.extend(str(p) for p in pkgs) elif pkgs is None: globs.append("packages/*") # lerna default - except (ValueError, OSError): - pass - pnpm_path = ancestor / "pnpm-workspace.yaml" - if pnpm_path.exists(): - try: - import yaml # optional dependency - except ImportError: - yaml = None - if yaml is not None: - try: - data = yaml.safe_load(pnpm_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError): - data = None # conservative: unparseable pnpm workspace - if isinstance(data, dict): - pkgs = data.get("packages") - if isinstance(pkgs, list): - globs.extend(str(p) for p in pkgs) return globs @@ -127,12 +152,29 @@ def _split_top_level_commas(body: str) -> list: return parts -def _expand_braces(pattern: str) -> list: +def _expand_braces(pattern: str, _count: 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.""" + concrete patterns. Unbalanced or single-option braces are left literal. + + Expansion is bounded: if the pattern would produce more than + ``_MAX_BRACE_EXPANSION`` concrete patterns, ``_BraceBudgetError`` is raised so + the caller can fail membership closed instead of materializing an exponential + list from an untrusted manifest (a ``{a,b}`` brace bomb). The depth-first walk + increments the shared counter at each terminal pattern, so it stops early + rather than after building the full Cartesian product. + """ + if _count is None: + _count = [0] + + def _emit(value: str) -> list: + _count[0] += 1 + if _count[0] > _MAX_BRACE_EXPANSION: + raise _BraceBudgetError + return [value] + start = pattern.find("{") if start == -1: - return [pattern] + return _emit(pattern) depth, end = 0, -1 for i in range(start, len(pattern)): if pattern[i] == "{": @@ -143,14 +185,14 @@ def _expand_braces(pattern: str) -> list: end = i break if end == -1: - return [pattern] # unbalanced → treat literally + return _emit(pattern) # unbalanced → treat literally options = _split_top_level_commas(pattern[start + 1:end]) if len(options) < 2: - return [pattern] # not a real alternation → literal + return _emit(pattern) # not a real alternation → literal prefix, suffix = pattern[:start], pattern[end + 1:] expanded: list = [] for option in options: - expanded.extend(_expand_braces(prefix + option + suffix)) + expanded.extend(_expand_braces(prefix + option + suffix, _count)) return expanded @@ -162,23 +204,27 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: brace alternations are expanded before matching. """ positives, negatives = [], [] - for raw in globs: - raw = str(raw).strip() - if not raw: - continue - if raw.startswith("!"): - negatives.extend(_expand_braces(raw[1:])) - else: - positives.extend(_expand_braces(raw)) + try: + for raw in globs: + raw = str(raw).strip() + if not raw: + continue + if raw.startswith("!"): + negatives.extend(_expand_braces(raw[1:])) + else: + positives.extend(_expand_braces(raw)) + except _BraceBudgetError: + # Pathological brace pattern from an untrusted manifest → cannot be + # evaluated safely, so membership is unproven (fail closed). + return False if not any(_relative_matches_workspace_glob(rel_parts, p) for p in positives): return False return not any(_relative_matches_workspace_glob(rel_parts, n) for n in negatives) -def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: - """Return True if ``package_dir`` (which holds a ``package.json``) is a member - of an ancestor JS workspace, so its runner config may live at the workspace - root rather than in the leaf package. +def _workspace_root_for(package_dir: Path) -> 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`` @@ -187,6 +233,13 @@ def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: 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): @@ -197,14 +250,57 @@ def _belongs_to_ancestor_workspace(package_dir: Path) -> bool: except ValueError: rel_parts = () if rel_parts and _package_matches_workspace(rel_parts, globs): - return True + return ancestor if (ancestor / ".git").exists(): break parent = ancestor.parent if parent == ancestor: break ancestor = parent - return False + 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: + 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: + return False + return len(rel.parts) > 0 + + +def _lexical_repo_root(test_path: Path) -> Optional[Path]: + """Find the nearest ``.git`` ancestor of ``test_path`` *without* following + symlinks (lexical path), or ``None`` if there is no ``.git`` above it. + + This anchors repository containment before ``resolve()`` is applied, so a + symlinked test directory that points outside the repository cannot smuggle + the walk into an out-of-repo runner config. + """ + directory = Path(os.path.abspath(str(test_path))).parent + for _ in range(200): + if (directory / ".git").exists(): + return directory + parent = directory.parent + if parent == directory: + break + directory = parent + return None def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: @@ -229,6 +325,13 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: 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 @@ -238,7 +341,17 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: """ is_spec = test_path.name.endswith(('.spec.ts', '.spec.tsx')) search_dir = test_path.resolve().parent + # 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) + # 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 # 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) @@ -248,9 +361,17 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: return ("npx vitest run", search_dir) # Stop at the JS project boundary (nearest package.json), but cross it when # this package is a member of an ancestor workspace whose config lives at - # the workspace root. - if (search_dir / "package.json").exists() and not _belongs_to_ancestor_workspace(search_dir): - break + # the workspace root — and keep crossing intermediate independent manifests + # until that workspace root is reached. + if (search_dir / "package.json").exists(): + root = _workspace_root_for(search_dir) + if root is not None and (ceiling is None or _is_strict_ancestor(root, ceiling)): + # Extend the ceiling to the shallowest (highest) workspace root, + # so nested workspaces chain correctly. + ceiling = root + below_ceiling = ceiling is not None and _is_strict_ancestor(ceiling, search_dir) + if root is None and not below_ceiling: + break # Never escape the repository, even absent an in-project config. if (search_dir / ".git").exists(): break diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index aa46169744..5282ca6940 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -9,8 +9,10 @@ You are an expert software engineer implementing the `get_test_command.py` modul - Fallback to `None` to trigger agentic logic. 2. **TypeScript Smart Detection**: - For `.ts` and `.tsx` files, search upwards for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). - - Continue the walk beyond a fixed number of parents so colocated suites in Next.js/monorepo layouts are found even when they live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). The nearest ancestor config wins. Stop the walk at the JS project boundary — the nearest `package.json` — with two refinements: (a) when that package is a member of an ancestor workspace, continue *through* the leaf to the workspace root, whose config it inherits; (b) an independent package must not adopt an unrelated ancestor's config, and the walk must never cross the repository root (`.git`, a directory in a clone and a file in a worktree). Cap the walk defensively against pathological paths. - - Prove workspace membership rather than assuming it: read the ancestor's declared package globs (npm/yarn `workspaces` as a list or `{packages: [...]}`, `lerna.json` `packages`, `pnpm-workspace.yaml` `packages`) and require that the leaf package's path — relative to the declaring ancestor — actually matches them. Implement include/exclude semantics faithfully: a package is a member only when it matches at least one positive glob AND no `!`-prefixed exclusion glob (e.g. pnpm's documented `!**/test/**`). Expand brace alternations (`{a,b}`) before matching, and match segment-wise where `*` matches one path segment and `**` matches any depth. A merely-present `workspaces` declaration does not make an unrelated sibling (e.g. `vendor/tool`) or an explicitly excluded package a member. If pnpm's YAML cannot be parsed, conservatively treat membership as unproven. + - Continue the walk beyond a fixed number of parents so colocated suites in Next.js/monorepo layouts are found even when they live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). The nearest ancestor config wins. Stop the walk at the JS project boundary — the nearest `package.json` — with these refinements: (a) when that package is a proven member of an ancestor workspace, continue *through* the leaf to the workspace root, whose config it inherits; (b) the declaring workspace *root* becomes a traversal ceiling, so intermediate *independent* `package.json` manifests that sit between the member and its workspace root (and that do not themselves match the workspace globs) do not prematurely stop the walk; (c) an independent package must not adopt an unrelated ancestor's config, and the walk must never cross the repository root (`.git`, a directory in a clone and a file in a worktree). Cap the walk defensively against pathological paths. + - Enforce repository containment: anchor the repository root *lexically* (nearest `.git` without following symlinks) before resolving the test path, and refuse to adopt any runner config once the resolved path escapes that root. A test directory symlinked outside the repository must not smuggle the walk into an out-of-repo config; a symlink that stays inside the repository is fine. + - Prove workspace membership rather than assuming it: read the ancestor's declared package globs (npm/yarn `workspaces` as a list or `{packages: [...]}`, `lerna.json` `packages`, `pnpm-workspace.yaml` `packages`) and require that the leaf package's path — relative to the declaring ancestor — actually matches them. Implement include/exclude semantics faithfully: a package is a member only when it matches at least one positive glob AND no `!`-prefixed exclusion glob (e.g. pnpm's documented `!**/test/**`). Expand brace alternations (`{a,b}`) before matching, and match segment-wise where `*` matches one path segment and `**` matches any depth. A merely-present `workspaces` declaration does not make an unrelated sibling (e.g. `vendor/tool`) or an explicitly excluded package a member. + - Read workspace declarations at doctrine altitude for a hostile, possibly-stale manifest: (i) `pnpm-workspace.yaml`, when present, is authoritative — pnpm ignores the `package.json` `workspaces` field, so a stale or attacker-controlled `workspaces` list must not union in and broaden membership; if pnpm's YAML is missing a parser or cannot be parsed, treat membership as unproven (fail closed). (ii) A manifest whose parsed top level is not an object (e.g. `[]` or a bare string) contributes no globs instead of raising. (iii) Bound brace expansion with an explicit budget so a `{a,b}`-style brace bomb in an untrusted manifest cannot exponentially expand; on exceeding the budget, fail membership closed rather than materializing the product. - Invoke Jest with `--runTestsByPath` so the resolved absolute test path is matched literally. 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. - When constructing the runner command string, append the resolved absolute test path with per-runner escaping: Playwright treats its positional argument as a regular expression, so regex-escape the path; Jest (`--runTestsByPath`) and Vitest take it literally. In all cases shell-quote the argument, because callers execute the command string with `shell=True` and an unquoted path with spaces or shell metacharacters would be re-split or reinterpreted. - If found, return the command with the specific directory containing the config as the `cwd`. @@ -36,7 +38,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul # Instructions 1. **Implement `_detect_ts_test_runner(test_path: Path)`**: - - Walk up the directory tree until a config is found or the JS project boundary (nearest `package.json`) is reached; cross that boundary only when the package belongs to an ancestor workspace, and never cross the repository root (`.git`). Add a helper that detects ancestor-workspace membership. Apply a defensive iteration cap. + - Walk up the directory tree until a config is found or the JS project boundary (nearest `package.json`) is reached; cross that boundary only when the package is a proven member of an ancestor workspace, using that workspace root as a traversal ceiling so intermediate independent manifests do not stop the walk, and never cross the repository root (`.git`). Add a helper that resolves the ancestor-workspace *root* for a package (returning the declaring root, not just a boolean, so it can serve as the ceiling), and enforce lexical repository containment against symlink escapes. Apply a defensive iteration cap. - Check for `playwright.config.[ts|js|mjs]` only if the file is a `.spec` file. - Check for `jest.config` and `vitest.config` variants. - Use `npx jest --no-coverage --runTestsByPath` for Jest so the absolute test path is matched literally. @@ -54,7 +56,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul # Deliverables A single file `pdd/get_test_command.py` containing: - `TestCommand` dataclass. -- Workspace-membership helpers (glob matching + declared-glob extraction) and `_belongs_to_ancestor_workspace`. +- Workspace-membership helpers: segment-wise glob matching, budgeted brace expansion, source-precedence-aware declared-glob extraction, `_workspace_root_for` (returns the declaring workspace root) and its boolean wrapper `_belongs_to_ancestor_workspace`, plus repository-containment helpers. - `_detect_ts_test_runner` helper function. - `_load_language_format` helper function. - `get_test_command_for_file` public function. \ No newline at end of file diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 0a94609dd1..9fe84c399d 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -8,7 +8,16 @@ 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, + _expand_braces, + _BraceBudgetError, +) class TestGetTestCommandForFilePython: @@ -550,6 +559,173 @@ def test_resolved_path_is_shell_quoted(self, tmp_path): 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_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_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_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() + + class TestPlaywrightDetection: """Tests for Playwright detection for .spec.ts files.""" From 3f5834c0be3427f940a3aee77353c976661d05a3 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 11:48:44 -0700 Subject: [PATCH 10/56] fix(get_test_command): DoS-harden untrusted workspace patterns (round-5 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second independent Codex gpt-5.6-sol xhigh review (fresh, at the new head) found five medium issues, all in the untrusted-manifest trust-boundary class; all five fixed with regression coverage. - R2-1 symlink to foreign checkout: _lexical_repo_root only anchors at a non-symlinked directory, so a symlinked component whose `.git` probe would follow the link out of the tree (repo/link -> outside, both with .git) no longer mis-anchors containment; the out-of-repo config is refused. - R2-2 invalid-UTF-8 pnpm YAML: read now also catches UnicodeError → membership unproven instead of crashing discovery with UnicodeDecodeError. - R2-3 `**` exponential match / RecursionError: glob matching is now an iterative O(n*m) dynamic program (no recursion, no slicing) with a segment budget; a wall of `**` fails closed instead of backtracking/recursing. - R2-4 brace-bomb RecursionError: _expand_braces is iterative (worklist, not recursion) with worklist+output budgets, so deep nesting fails closed via _BraceBudgetError rather than escaping as RecursionError. _package_matches_ workspace now catches _PatternBudgetError/RecursionError defensively. - R2-5 non-string glob entries: `_string_globs` requires every declared entry to be a str; a JSON/YAML `true`/number makes the declaration malformed (no globs) instead of coercing to a glob like "True". Prompt raised to doctrine altitude for bounded/no-recursion pattern evaluation, string-only declarations, invalid-encoding fail-closed, and foreign-checkout symlink containment. Fingerprint meta re-synced. +7 regression tests (85 total). Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +- pdd/get_test_command.py | 201 +++++++++++++-------- pdd/prompts/get_test_command_python.prompt | 4 +- tests/test_get_test_command.py | 86 +++++++++ 4 files changed, 215 insertions(+), 86 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 8876549fef..64f982ac0f 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T18:28:34.134404+00:00", + "timestamp": "2026-07-12T18:48:21.355744+00:00", "command": "test", - "prompt_hash": "318939812b47766abebcc18fab723cfc628b3ec2b2d068edd4500726d05a50dd", - "code_hash": "61430a1185b02ce52f742168f842a8884b5032014318918f37127467ba117b17", + "prompt_hash": "69ca74045303517b22b438ed14c1927d9fcb6aeaf04d9403444eee6c4c99df9d", + "code_hash": "d95ced2536e35fef24d4820bd073ef8f7f96efbd49ce0f4685d4b54c819d5881", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "c88cf8773b1d6482c45cd8678786b62a76f74023f9b9486cf63f0fc8c0b1c7b2", + "test_hash": "464d55e06ec9a472e0c6ad7f5dd386181df4d35f397dfc0a51e820ae6c1b7db9", "test_files": { - "test_get_test_command.py": "c88cf8773b1d6482c45cd8678786b62a76f74023f9b9486cf63f0fc8c0b1c7b2" + "test_get_test_command.py": "464d55e06ec9a472e0c6ad7f5dd386181df4d35f397dfc0a51e820ae6c1b7db9" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index fbeec42272..49a4edef4b 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -28,8 +28,20 @@ # 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 -class _BraceBudgetError(Exception): + +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``.""" @@ -52,24 +64,34 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str) - 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``. + A pattern longer than ``_MAX_GLOB_SEGMENTS`` raises ``_PatternBudgetError`` so + membership fails closed. """ pat_parts = [p for p in pattern.strip("/").split("/") if p not in ("", ".")] - return _match_segments(list(rel_parts), pat_parts) - - -def _match_segments(rel: list, pat: list) -> bool: - if not pat: - return not rel - head, rest = pat[0], pat[1:] - if head == "**": - # Zero or more segments. - for i in range(len(rel) + 1): - if _match_segments(rel[i:], rest): - return True - return False - if not rel: - return False - return fnmatch.fnmatch(rel[0], head) and _match_segments(rel[1:], rest) + if len(pat_parts) > _MAX_GLOB_SEGMENTS: + raise _PatternBudgetError + rel = list(rel_parts) + n, m = len(rel), len(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): + head = pat_parts[j] + if head == "**": + # ``**`` matches zero segments (advance pattern) or one-or-more + # (consume rel[i], stay on the same ``**``). + dp[i][j] = dp[i][j + 1] or dp[i + 1][j] + else: + dp[i][j] = fnmatch.fnmatch(rel[i], head) and dp[i + 1][j + 1] + return dp[0][0] def _workspace_globs_for(ancestor: Path) -> list: @@ -87,7 +109,10 @@ def _workspace_globs_for(ancestor: Path) -> list: 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. + 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). """ pnpm_path = ancestor / "pnpm-workspace.yaml" if pnpm_path.exists(): @@ -97,12 +122,10 @@ def _workspace_globs_for(ancestor: Path) -> list: return [] # cannot parse pnpm config → membership unproven (fail closed) try: data = yaml.safe_load(pnpm_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError): - return [] # conservative: unparseable pnpm workspace + except (yaml.YAMLError, OSError, UnicodeError): + return [] # conservative: unparseable/invalid-encoding pnpm workspace if isinstance(data, dict): - pkgs = data.get("packages") - if isinstance(pkgs, list): - return [str(p) for p in pkgs] + return _string_globs(data.get("packages")) return [] globs: list = [] @@ -116,8 +139,7 @@ def _workspace_globs_for(ancestor: Path) -> list: ws = manifest.get("workspaces") if isinstance(ws, dict): ws = ws.get("packages") - if isinstance(ws, list): - globs.extend(str(p) for p in ws) + globs.extend(_string_globs(ws)) lerna_path = ancestor / "lerna.json" if lerna_path.exists(): try: @@ -126,13 +148,24 @@ def _workspace_globs_for(ancestor: Path) -> list: lerna = {} if isinstance(lerna, dict): pkgs = lerna.get("packages") - if isinstance(pkgs, list): - globs.extend(str(p) for p in pkgs) - elif pkgs is None: + if pkgs is None: globs.append("packages/*") # lerna default + else: + globs.extend(_string_globs(pkgs)) return globs +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.""" + if not isinstance(value, list): + return [] + if not all(isinstance(item, str) for item in value): + return [] + return list(value) + + def _split_top_level_commas(body: str) -> list: """Split a brace body on commas that are not inside a nested brace group.""" parts, depth, current = [], 0, [] @@ -152,48 +185,53 @@ def _split_top_level_commas(body: str) -> list: return parts -def _expand_braces(pattern: str, _count: Optional[list] = None) -> list: +def _expand_braces(pattern: str) -> list: """Expand ``{a,b}`` brace alternations (as npm/Yarn workspace globs use) into concrete patterns. Unbalanced or single-option braces are left literal. - Expansion is bounded: if the pattern would produce more than - ``_MAX_BRACE_EXPANSION`` concrete patterns, ``_BraceBudgetError`` is raised so - the caller can fail membership closed instead of materializing an exponential - list from an untrusted manifest (a ``{a,b}`` brace bomb). The depth-first walk - increments the shared counter at each terminal pattern, so it stops early - rather than after building the full Cartesian product. + Expansion is *iterative* (a worklist, not recursion) and bounded on both the + number of finished patterns and the transient worklist size. Either 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). """ - if _count is None: - _count = [0] - - def _emit(value: str) -> list: - _count[0] += 1 - if _count[0] > _MAX_BRACE_EXPANSION: + out: list = [] + worklist = [pattern] + while worklist: + if len(worklist) > _MAX_BRACE_EXPANSION: raise _BraceBudgetError - return [value] - - start = pattern.find("{") - if start == -1: - return _emit(pattern) - depth, end = 0, -1 - for i in range(start, len(pattern)): - if pattern[i] == "{": - depth += 1 - elif pattern[i] == "}": - depth -= 1 - if depth == 0: - end = i - break - if end == -1: - return _emit(pattern) # unbalanced → treat literally - options = _split_top_level_commas(pattern[start + 1:end]) - if len(options) < 2: - return _emit(pattern) # not a real alternation → literal - prefix, suffix = pattern[:start], pattern[end + 1:] - expanded: list = [] - for option in options: - expanded.extend(_expand_braces(prefix + option + suffix, _count)) - return expanded + pat = worklist.pop() + start = pat.find("{") + if start == -1: + out.append(pat) + if len(out) > _MAX_BRACE_EXPANSION: + raise _BraceBudgetError + continue + depth, end = 0, -1 + for i in range(start, len(pat)): + if pat[i] == "{": + depth += 1 + elif pat[i] == "}": + depth -= 1 + if depth == 0: + end = i + break + if end == -1: + out.append(pat) # unbalanced → treat literally + if len(out) > _MAX_BRACE_EXPANSION: + raise _BraceBudgetError + continue + options = _split_top_level_commas(pat[start + 1:end]) + if len(options) < 2: + out.append(pat) # not a real alternation → literal + if len(out) > _MAX_BRACE_EXPANSION: + raise _BraceBudgetError + continue + prefix, suffix = pat[:start], pat[end + 1:] + for option in options: + worklist.append(prefix + option + suffix) + return out def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: @@ -203,8 +241,8 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: Exclusions (a leading ``!``, e.g. pnpm's ``!**/test/**``) are honored, and brace alternations are expanded before matching. """ - positives, negatives = [], [] try: + positives, negatives = [], [] for raw in globs: raw = str(raw).strip() if not raw: @@ -213,13 +251,14 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: negatives.extend(_expand_braces(raw[1:])) else: positives.extend(_expand_braces(raw)) - except _BraceBudgetError: - # Pathological brace pattern from an untrusted manifest → cannot be - # evaluated safely, so membership is unproven (fail closed). + if not any(_relative_matches_workspace_glob(rel_parts, p) for p in positives): + return False + return not any(_relative_matches_workspace_glob(rel_parts, n) for n in negatives) + 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 - if not any(_relative_matches_workspace_glob(rel_parts, p) for p in positives): - return False - return not any(_relative_matches_workspace_glob(rel_parts, n) for n in negatives) def _workspace_root_for(package_dir: Path) -> Optional[Path]: @@ -285,16 +324,20 @@ def _is_strict_ancestor(ancestor: Path, descendant: Path) -> bool: def _lexical_repo_root(test_path: Path) -> Optional[Path]: - """Find the nearest ``.git`` ancestor of ``test_path`` *without* following - symlinks (lexical path), or ``None`` if there is no ``.git`` above it. - - This anchors repository containment before ``resolve()`` is applied, so a - symlinked test directory that points outside the repository cannot smuggle - the walk into an out-of-repo runner config. + """Find the nearest ``.git`` ancestor of ``test_path`` on its *lexical* + (un-resolved, ``abspath``) parent chain, or ``None`` if there is none. + + Only a *non-symlinked* directory may anchor the root: if ``directory`` is + itself a symlink, its ``.git`` probe would follow the link out of the tree + (e.g. ``repo/link -> /outside`` where ``/outside/.git`` exists), so such a + directory is skipped. Combined with the resolved-path containment check in + :func:`_detect_ts_test_runner`, this refuses both a symlinked test directory + that escapes a repository and one whose target is itself a foreign checkout — + without rejecting a symlink that stays inside the repository. """ directory = Path(os.path.abspath(str(test_path))).parent for _ in range(200): - if (directory / ".git").exists(): + if not os.path.islink(str(directory)) and (directory / ".git").exists(): return directory parent = directory.parent if parent == directory: diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 5282ca6940..35eca82c53 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -10,9 +10,9 @@ You are an expert software engineer implementing the `get_test_command.py` modul 2. **TypeScript Smart Detection**: - For `.ts` and `.tsx` files, search upwards for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). - Continue the walk beyond a fixed number of parents so colocated suites in Next.js/monorepo layouts are found even when they live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). The nearest ancestor config wins. Stop the walk at the JS project boundary — the nearest `package.json` — with these refinements: (a) when that package is a proven member of an ancestor workspace, continue *through* the leaf to the workspace root, whose config it inherits; (b) the declaring workspace *root* becomes a traversal ceiling, so intermediate *independent* `package.json` manifests that sit between the member and its workspace root (and that do not themselves match the workspace globs) do not prematurely stop the walk; (c) an independent package must not adopt an unrelated ancestor's config, and the walk must never cross the repository root (`.git`, a directory in a clone and a file in a worktree). Cap the walk defensively against pathological paths. - - Enforce repository containment: anchor the repository root *lexically* (nearest `.git` without following symlinks) before resolving the test path, and refuse to adopt any runner config once the resolved path escapes that root. A test directory symlinked outside the repository must not smuggle the walk into an out-of-repo config; a symlink that stays inside the repository is fine. + - Enforce repository containment: anchor the repository root *lexically* (nearest `.git` on the un-resolved parent chain, and only at a non-symlinked directory so a symlinked component's `.git` cannot be followed out of the tree) before resolving the test path, then refuse to adopt any runner config once the 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` — must not smuggle the walk into an out-of-repo config; a symlink that stays inside the repository is fine. - Prove workspace membership rather than assuming it: read the ancestor's declared package globs (npm/yarn `workspaces` as a list or `{packages: [...]}`, `lerna.json` `packages`, `pnpm-workspace.yaml` `packages`) and require that the leaf package's path — relative to the declaring ancestor — actually matches them. Implement include/exclude semantics faithfully: a package is a member only when it matches at least one positive glob AND no `!`-prefixed exclusion glob (e.g. pnpm's documented `!**/test/**`). Expand brace alternations (`{a,b}`) before matching, and match segment-wise where `*` matches one path segment and `**` matches any depth. A merely-present `workspaces` declaration does not make an unrelated sibling (e.g. `vendor/tool`) or an explicitly excluded package a member. - - Read workspace declarations at doctrine altitude for a hostile, possibly-stale manifest: (i) `pnpm-workspace.yaml`, when present, is authoritative — pnpm ignores the `package.json` `workspaces` field, so a stale or attacker-controlled `workspaces` list must not union in and broaden membership; if pnpm's YAML is missing a parser or cannot be parsed, treat membership as unproven (fail closed). (ii) A manifest whose parsed top level is not an object (e.g. `[]` or a bare string) contributes no globs instead of raising. (iii) Bound brace expansion with an explicit budget so a `{a,b}`-style brace bomb in an untrusted manifest cannot exponentially expand; on exceeding the budget, fail membership closed rather than materializing the product. + - Read and evaluate workspace declarations at doctrine altitude for a hostile, possibly-stale manifest, failing membership closed on anything unsafe: (i) `pnpm-workspace.yaml`, when present, is authoritative — pnpm ignores the `package.json` `workspaces` field, so a stale or attacker-controlled `workspaces` list must not union in and broaden membership; if pnpm's YAML is missing a parser, cannot be parsed, or is not valid UTF-8, treat membership as unproven. (ii) A manifest whose parsed top level is not an object (e.g. `[]` or a bare string) contributes no globs; a declared package list must contain only string entries — a non-string entry (e.g. JSON `true`/a number) makes the whole declaration malformed and contributes no globs, never a coerced glob like `"True"`. (iii) All untrusted-pattern evaluation must run in bounded time and space with no unbounded recursion: expand brace alternations iteratively under an explicit budget (a `{a,b}` brace bomb, including thousands of nested groups, must fail closed, not raise `RecursionError`), and match globs segment-wise with an iterative dynamic program (`*` = one segment via fnmatch, `**` = any depth) so a wall of `**` segments cannot backtrack exponentially; bound the pattern's segment count and fail closed beyond it. - Invoke Jest with `--runTestsByPath` so the resolved absolute test path is matched literally. 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. - When constructing the runner command string, append the resolved absolute test path with per-runner escaping: Playwright treats its positional argument as a regular expression, so regex-escape the path; Jest (`--runTestsByPath`) and Vitest take it literally. In all cases shell-quote the argument, because callers execute the command string with `shell=True` and an unquoted path with spaces or shell metacharacters would be re-split or reinterpreted. - If found, return the command with the specific directory containing the config as the `cwd`. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 9fe84c399d..d70aeb1bcb 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -17,6 +17,8 @@ _package_matches_workspace, _expand_braces, _BraceBudgetError, + _PatternBudgetError, + _relative_matches_workspace_glob, ) @@ -725,6 +727,90 @@ def test_symlink_within_repo_still_detected(self, tmp_path): assert "npx jest" in cmd assert returned_dir == repo.resolve() + def test_symlink_to_foreign_checkout_is_refused(self, tmp_path): + """A symlink whose target is itself a git checkout must not be adopted. + + `repo/link -> 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) == [] + class TestPlaywrightDetection: """Tests for Playwright detection for .spec.ts files.""" From edd3b9b8e3254013b61c77ee2b0393750f1a97d5 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 12:15:29 -0700 Subject: [PATCH 11/56] fix(get_test_command): close symlink/dot/budget gaps; prompt to doctrine altitude (round-6 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found six findings; all addressed. - R3-1 symlink to nested foreign checkout: repository containment now anchors at the deepest-symlink boundary (component-aware), so a `.git` probe cannot follow a symlinked path component into a foreign checkout below it. Anchors at the true repo; the foreign config is refused. - R3-2 lexical-root depth cap: the lexical repo-root search now walks to the filesystem root (no artificial 200 cap), so a deep path ending in an escaping symlink still anchors containment and refuses the out-of-repo config. - R3-3 aggregate resource budget: brace expansion now shares one budget across the whole membership check; raw-glob count is capped; comma-splitting and segment-splitting are bounded before allocation. A many-glob or comma/slash-wall manifest fails closed instead of exhausting memory. - R3-4 pnpm YAML recursion bomb: YAML parsing also catches RecursionError → fail closed instead of crashing discovery. - R3-5 dotfile matching: glob matching applies minimatch `dot:false` — a wildcard segment no longer matches a leading-dot segment (so `packages/*` excludes `packages/.shadow`; `packages/.*` still includes it). - R3-6 prompt altitude: rewrote the prompt to stable numbered R1–R13 MUST/MUST NOT behavioral contracts with a Vocabulary and pinned Interface, removing transcribed private-helper names and algorithm recipes (DP/fnmatch/iterative), per docs/prompting_guide.md. Fingerprint meta re-synced. +11 regression tests (95 total). Suite green, E2E green, pylint E 10.00. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +- pdd/get_test_command.py | 177 ++++++++++++++++----- pdd/prompts/get_test_command_python.prompt | 88 +++++----- tests/test_get_test_command.py | 108 +++++++++++++ 4 files changed, 290 insertions(+), 93 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 64f982ac0f..b48ac3dd0f 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T18:48:21.355744+00:00", + "timestamp": "2026-07-12T19:15:03.908388+00:00", "command": "test", - "prompt_hash": "69ca74045303517b22b438ed14c1927d9fcb6aeaf04d9403444eee6c4c99df9d", - "code_hash": "d95ced2536e35fef24d4820bd073ef8f7f96efbd49ce0f4685d4b54c819d5881", + "prompt_hash": "17064d0d4efeaae3ba512d77821e70198be8669e54f45658bf33550366b62ed6", + "code_hash": "99054385e722e6ebd22e4398e2b9e5726690a5748186788463cd07502bdc5940", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "464d55e06ec9a472e0c6ad7f5dd386181df4d35f397dfc0a51e820ae6c1b7db9", + "test_hash": "16afafe767ca4e7b03bdee3ad99460381d0620bbbfed54a7f8a9bd1dafe5d1cc", "test_files": { - "test_get_test_command.py": "464d55e06ec9a472e0c6ad7f5dd386181df4d35f397dfc0a51e820ae6c1b7db9" + "test_get_test_command.py": "16afafe767ca4e7b03bdee3ad99460381d0620bbbfed54a7f8a9bd1dafe5d1cc" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 49a4edef4b..eeb8f58f5f 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -35,6 +35,13 @@ # ``_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 + class _PatternBudgetError(Exception): """Raised when an untrusted workspace pattern exceeds a safety budget @@ -68,9 +75,20 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str) - 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``. - A pattern longer than ``_MAX_GLOB_SEGMENTS`` raises ``_PatternBudgetError`` so - membership fails closed. + 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 pat_parts = [p for p in pattern.strip("/").split("/") if p not in ("", ".")] if len(pat_parts) > _MAX_GLOB_SEGMENTS: raise _PatternBudgetError @@ -83,17 +101,28 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str) - 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): + seg_is_dot = rel[i].startswith(".") for j in range(m - 1, -1, -1): head = pat_parts[j] if head == "**": # ``**`` matches zero segments (advance pattern) or one-or-more - # (consume rel[i], stay on the same ``**``). - dp[i][j] = dp[i][j + 1] or dp[i + 1][j] + # (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 (not seg_is_dot and dp[i + 1][j]) else: - dp[i][j] = fnmatch.fnmatch(rel[i], head) and dp[i + 1][j + 1] + dp[i][j] = _segment_matches(rel[i], head) and dp[i + 1][j + 1] return dp[0][0] +def _segment_matches(name: str, pattern_segment: str) -> bool: + """fnmatch a single path segment with minimatch ``dot:false`` semantics: a + wildcard pattern segment does not match a ``name`` that begins with ``.`` + unless the pattern segment itself begins with ``.``.""" + if name.startswith(".") and not pattern_segment.startswith("."): + return False + return fnmatch.fnmatch(name, pattern_segment) + + def _workspace_globs_for(ancestor: Path) -> list: """Return the workspace package globs declared by ``ancestor`` (empty if none). @@ -122,8 +151,10 @@ def _workspace_globs_for(ancestor: Path) -> list: return [] # cannot parse pnpm config → membership unproven (fail closed) try: data = yaml.safe_load(pnpm_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError, UnicodeError): - return [] # conservative: unparseable/invalid-encoding pnpm workspace + except (yaml.YAMLError, OSError, UnicodeError, RecursionError): + # Unparseable, invalid-encoding, or deeply-nested (recursion-bomb) + # pnpm workspace → membership unproven (fail closed). + return [] if isinstance(data, dict): return _string_globs(data.get("packages")) return [] @@ -166,8 +197,13 @@ def _string_globs(value) -> list: return list(value) -def _split_top_level_commas(body: str) -> list: - """Split a brace body on commas that are not inside a nested brace group.""" +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 == "{": @@ -179,34 +215,48 @@ def _split_top_level_commas(body: str) -> list: 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 _expand_braces(pattern: str) -> list: +def _expand_braces(pattern: str, budget: 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. - Expansion is *iterative* (a worklist, not recursion) and bounded on both the - number of finished patterns and the transient worklist size. Either 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). + 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. 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] + + 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) > _MAX_BRACE_EXPANSION: + if len(worklist) > budget[0] + 1: raise _BraceBudgetError pat = worklist.pop() start = pat.find("{") if start == -1: - out.append(pat) - if len(out) > _MAX_BRACE_EXPANSION: - raise _BraceBudgetError + _emit(pat) continue depth, end = 0, -1 for i in range(start, len(pat)): @@ -218,15 +268,11 @@ def _expand_braces(pattern: str) -> list: end = i break if end == -1: - out.append(pat) # unbalanced → treat literally - if len(out) > _MAX_BRACE_EXPANSION: - raise _BraceBudgetError + _emit(pat) # unbalanced → treat literally continue - options = _split_top_level_commas(pat[start + 1:end]) + options = _split_top_level_commas(pat[start + 1:end], budget[0] + 1) if len(options) < 2: - out.append(pat) # not a real alternation → literal - if len(out) > _MAX_BRACE_EXPANSION: - raise _BraceBudgetError + _emit(pat) # not a real alternation → literal continue prefix, suffix = pat[:start], pat[end + 1:] for option in options: @@ -239,18 +285,23 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: semantics: at least one positive pattern matches and no ``!`` exclusion does. Exclusions (a leading ``!``, e.g. pnpm's ``!**/test/**``) are honored, and - brace alternations are expanded before matching. + brace alternations are expanded before matching. All expansion for one + membership check shares a single aggregate budget, so a declaration with many + globs cannot multiply past the budget. """ + if len(globs) > _MAX_RAW_GLOBS: + return False # hostile declaration size → membership unproven (fail closed) try: + budget = [_MAX_BRACE_EXPANSION] # shared across every glob below positives, negatives = [], [] for raw in globs: raw = str(raw).strip() if not raw: continue if raw.startswith("!"): - negatives.extend(_expand_braces(raw[1:])) + negatives.extend(_expand_braces(raw[1:], budget)) else: - positives.extend(_expand_braces(raw)) + positives.extend(_expand_braces(raw, budget)) if not any(_relative_matches_workspace_glob(rel_parts, p) for p in positives): return False return not any(_relative_matches_workspace_glob(rel_parts, n) for n in negatives) @@ -323,21 +374,61 @@ def _is_strict_ancestor(ancestor: Path, descendant: Path) -> bool: 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 nearest ``.git`` ancestor of ``test_path`` on its *lexical* - (un-resolved, ``abspath``) parent chain, or ``None`` if there is none. - - Only a *non-symlinked* directory may anchor the root: if ``directory`` is - itself a symlink, its ``.git`` probe would follow the link out of the tree - (e.g. ``repo/link -> /outside`` where ``/outside/.git`` exists), so such a - directory is skipped. Combined with the resolved-path containment check in - :func:`_detect_ts_test_runner`, this refuses both a symlinked test directory - that escapes a repository and one whose target is itself a foreign checkout — - without rejecting a symlink that stays inside the repository. + """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. """ - directory = Path(os.path.abspath(str(test_path))).parent - for _ in range(200): - if not os.path.islink(str(directory)) and (directory / ".git").exists(): + 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: diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 35eca82c53..4e7aaf378f 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -1,26 +1,46 @@ # 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 for `playwright.config`, `jest.config`, or `vitest.config` (supporting `.js`, `.ts`, and `.mjs` extensions). - - Continue the walk beyond a fixed number of parents so colocated suites in Next.js/monorepo layouts are found even when they live many directories below their runner config (e.g. a page test under `frontend/src/app/hackathon/[eventId]/team/__tests__/` and the config at `frontend/jest.config.js`). The nearest ancestor config wins. Stop the walk at the JS project boundary — the nearest `package.json` — with these refinements: (a) when that package is a proven member of an ancestor workspace, continue *through* the leaf to the workspace root, whose config it inherits; (b) the declaring workspace *root* becomes a traversal ceiling, so intermediate *independent* `package.json` manifests that sit between the member and its workspace root (and that do not themselves match the workspace globs) do not prematurely stop the walk; (c) an independent package must not adopt an unrelated ancestor's config, and the walk must never cross the repository root (`.git`, a directory in a clone and a file in a worktree). Cap the walk defensively against pathological paths. - - Enforce repository containment: anchor the repository root *lexically* (nearest `.git` on the un-resolved parent chain, and only at a non-symlinked directory so a symlinked component's `.git` cannot be followed out of the tree) before resolving the test path, then refuse to adopt any runner config once the 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` — must not smuggle the walk into an out-of-repo config; a symlink that stays inside the repository is fine. - - Prove workspace membership rather than assuming it: read the ancestor's declared package globs (npm/yarn `workspaces` as a list or `{packages: [...]}`, `lerna.json` `packages`, `pnpm-workspace.yaml` `packages`) and require that the leaf package's path — relative to the declaring ancestor — actually matches them. Implement include/exclude semantics faithfully: a package is a member only when it matches at least one positive glob AND no `!`-prefixed exclusion glob (e.g. pnpm's documented `!**/test/**`). Expand brace alternations (`{a,b}`) before matching, and match segment-wise where `*` matches one path segment and `**` matches any depth. A merely-present `workspaces` declaration does not make an unrelated sibling (e.g. `vendor/tool`) or an explicitly excluded package a member. - - Read and evaluate workspace declarations at doctrine altitude for a hostile, possibly-stale manifest, failing membership closed on anything unsafe: (i) `pnpm-workspace.yaml`, when present, is authoritative — pnpm ignores the `package.json` `workspaces` field, so a stale or attacker-controlled `workspaces` list must not union in and broaden membership; if pnpm's YAML is missing a parser, cannot be parsed, or is not valid UTF-8, treat membership as unproven. (ii) A manifest whose parsed top level is not an object (e.g. `[]` or a bare string) contributes no globs; a declared package list must contain only string entries — a non-string entry (e.g. JSON `true`/a number) makes the whole declaration malformed and contributes no globs, never a coerced glob like `"True"`. (iii) All untrusted-pattern evaluation must run in bounded time and space with no unbounded recursion: expand brace alternations iteratively under an explicit budget (a `{a,b}` brace bomb, including thousands of nested groups, must fail closed, not raise `RecursionError`), and match globs segment-wise with an iterative dynamic program (`*` = one segment via fnmatch, `**` = any depth) so a wall of `**` segments cannot backtrack exponentially; bound the pattern's segment count and fail closed beyond it. - - Invoke Jest with `--runTestsByPath` so the resolved absolute test path is matched literally. 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. - - When constructing the runner command string, append the resolved absolute test path with per-runner escaping: Playwright treats its positional argument as a regular expression, so regex-escape the path; Jest (`--runTestsByPath`) and Vitest take it literally. In all cases shell-quote the argument, because callers execute the command string with `shell=True` and an unquoted path with spaces or shell metacharacters would be re-split or reinterpreted. - - 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. +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. + +# Vocabulary +- **Runner config**: a `playwright.config`, `jest.config`, or `vitest.config` file (`.js`, `.ts`, or `.mjs`). +- **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. + +# Contract +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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). + +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** on every malformed or unsafe declaration: a manifest whose parsed top level is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a `pnpm-workspace.yaml` with no available parser, a parse error, invalid encoding, or pathological nesting. + +R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, and glob matching (including many `**` segments) MUST be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Budgets are aggregate across one membership check, not merely per glob. + +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` — MUST be refused; a symlink that stays inside the repository MUST still resolve normally. + +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. (This is a POSIX-shell command string, matching how all pdd callers run verify commands.) + +R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder with the input file string and return `cwd=None`. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. # Dependencies @@ -36,27 +56,5 @@ 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 until a config is found or the JS project boundary (nearest `package.json`) is reached; cross that boundary only when the package is a proven member of an ancestor workspace, using that workspace root as a traversal ceiling so intermediate independent manifests do not stop the walk, and never cross the repository root (`.git`). Add a helper that resolves the ancestor-workspace *root* for a package (returning the declaring root, not just a boolean, so it can serve as the ceiling), and enforce lexical repository containment against symlink escapes. Apply a defensive iteration cap. - - Check for `playwright.config.[ts|js|mjs]` only if the file is a `.spec` file. - - Check for `jest.config` and `vitest.config` variants. - - Use `npx jest --no-coverage --runTestsByPath` for Jest so the absolute test path is matched literally. - - 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, regex-escaped for Playwright and shell-quoted for every runner (see the TypeScript Smart Detection requirement). - - For CSV commands, replace the `{file}` placeholder with the input file string. - # Deliverables -A single file `pdd/get_test_command.py` containing: -- `TestCommand` dataclass. -- Workspace-membership helpers: segment-wise glob matching, budgeted brace expansion, source-precedence-aware declared-glob extraction, `_workspace_root_for` (returns the declaring workspace root) and its boolean wrapper `_belongs_to_ancestor_workspace`, plus repository-containment helpers. -- `_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/test_get_test_command.py b/tests/test_get_test_command.py index d70aeb1bcb..80498ed416 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -19,6 +19,7 @@ _BraceBudgetError, _PatternBudgetError, _relative_matches_workspace_glob, + _lexical_repo_root, ) @@ -811,6 +812,113 @@ def test_pnpm_yaml_invalid_utf8_fails_closed(self, tmp_path): (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_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_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 + class TestPlaywrightDetection: """Tests for Playwright detection for .spec.ts files.""" From 264d3f4a2c754b05f31e347e63918c685c616fb8 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 12:39:55 -0700 Subject: [PATCH 12/56] fix(get_test_command): close JSON-recursion, config-symlink, ceiling & injection gaps (round-7 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found five findings; all fixed. - R4-1 (high) `..`+symlink mis-anchor: os.path.abspath collapses `..` textually, which is unsound across symlinks and could mis-anchor containment into a foreign checkout. Detection now fails closed when the naive-collapsed path and the true resolution disagree (a `..` traversed a symlink); a `..` with no symlink is unaffected. - R4-2 (high) placeholder re-injection: pdd/fix_error_loop.py re-ran {file}/{test} substitution on the already-complete, shell-quoted command, so a maliciously named path (containing `{test};touch …`) broke the quoting → command injection. get_test_command_for_file already substitutes CSV paths and embeds the quoted runner path, so the redundant re-substitution is removed and documented (prompt R14). - R4-3 config-file symlink escape: a runner config that is itself a symlink (or broken symlink) resolving outside the repository is now refused, anchored on the canonical repo root (reliable even where the lexical anchor is unset by a harmless system symlink); an in-repo config symlink still works. - R4-4 workspace root without package.json: the declaring workspace root now caps the walk even when it has no package.json of its own (pnpm/lerna root), so an unrelated ancestor config above it is not adopted; a config AT the root is still inherited. - R4-5 JSON recursion/oversized manifest: package.json/lerna.json parsing now catches RecursionError, and every declaration file is size-bounded before it is read/parsed. A parse-failing lerna.json no longer falls through to the `packages/*` default (fail closed). Prompt R8/R10 broadened and R14 added (all behavioral). Fingerprint meta re-synced. +11 regression tests (105 total). Suite green, E2E green, pylint E 10.00. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +- pdd/fix_error_loop.py | 10 +- pdd/get_test_command.py | 161 +++++++++++++++---- pdd/prompts/get_test_command_python.prompt | 6 +- tests/test_get_test_command.py | 171 +++++++++++++++++++++ 5 files changed, 321 insertions(+), 37 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index b48ac3dd0f..4609d48e5c 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T19:15:03.908388+00:00", + "timestamp": "2026-07-12T19:39:31.344132+00:00", "command": "test", - "prompt_hash": "17064d0d4efeaae3ba512d77821e70198be8669e54f45658bf33550366b62ed6", - "code_hash": "99054385e722e6ebd22e4398e2b9e5726690a5748186788463cd07502bdc5940", + "prompt_hash": "63b0952af4e344a99f521f541fe92c197b65dc9d584bb541873768d6a5da2c2b", + "code_hash": "5fdc3c62e07d446f02474ad95d1bf29ecaf1ce2ab5483c858d40579c3c2dae43", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "16afafe767ca4e7b03bdee3ad99460381d0620bbbfed54a7f8a9bd1dafe5d1cc", + "test_hash": "18fa17f3a01938469fb3fe1fb674dd001be132e1cf8bc6d22276c324de766712", "test_files": { - "test_get_test_command.py": "16afafe767ca4e7b03bdee3ad99460381d0620bbbfed54a7f8a9bd1dafe5d1cc" + "test_get_test_command.py": "18fa17f3a01938469fb3fe1fb674dd001be132e1cf8bc6d22276c324de766712" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", 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_test_command.py b/pdd/get_test_command.py index eeb8f58f5f..d4c616c922 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -42,6 +42,23 @@ # 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, so a modest +# git blob cannot force hundreds of megabytes of parsing/allocation before the +# other budgets apply. +_MAX_MANIFEST_BYTES = 5 * 1024 * 1024 + + +def _read_manifest_text(path: Path) -> Optional[str]: + """Read ``path`` as UTF-8, or return ``None`` if it is missing, too large, + unreadable, or not valid UTF-8 (so callers fail closed).""" + try: + if path.stat().st_size > _MAX_MANIFEST_BYTES: + return None + return path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return None + class _PatternBudgetError(Exception): """Raised when an untrusted workspace pattern exceeds a safety budget @@ -149,11 +166,14 @@ def _workspace_globs_for(ancestor: Path) -> list: import yaml # optional dependency except ImportError: return [] # cannot parse pnpm config → membership unproven (fail closed) + text = _read_manifest_text(pnpm_path) # None if missing/oversized/bad-utf8 + if text is None: + return [] try: - data = yaml.safe_load(pnpm_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError, UnicodeError, RecursionError): - # Unparseable, invalid-encoding, or deeply-nested (recursion-bomb) - # pnpm workspace → membership unproven (fail closed). + data = yaml.safe_load(text) + except (yaml.YAMLError, RecursionError): + # Unparseable or deeply-nested (recursion-bomb) pnpm workspace → + # membership unproven (fail closed). return [] if isinstance(data, dict): return _string_globs(data.get("packages")) @@ -162,9 +182,12 @@ def _workspace_globs_for(ancestor: Path) -> list: globs: list = [] manifest_path = ancestor / "package.json" if manifest_path.exists(): + text = _read_manifest_text(manifest_path) try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except (ValueError, OSError): + manifest = json.loads(text) 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") @@ -173,14 +196,19 @@ def _workspace_globs_for(ancestor: Path) -> list: globs.extend(_string_globs(ws)) lerna_path = ancestor / "lerna.json" if lerna_path.exists(): + text = _read_manifest_text(lerna_path) try: - lerna = json.loads(lerna_path.read_text(encoding="utf-8")) - except (ValueError, OSError): - lerna = {} + lerna = json.loads(text) if text is not None else None + except (ValueError, RecursionError): + lerna = None # parse failed → contribute nothing (fail closed) if isinstance(lerna, dict): pkgs = lerna.get("packages") if pkgs is None: - globs.append("packages/*") # lerna default + # Successfully parsed lerna.json with no ``packages`` key → the + # documented lerna default. (A *parse failure* above sets + # ``lerna=None`` and skips this, so a malformed/bomb lerna.json + # does NOT silently grant the default glob.) + globs.append("packages/*") else: globs.extend(_string_globs(pkgs)) return globs @@ -437,6 +465,66 @@ def _lexical_repo_root(test_path: Path) -> Optional[Path]: return None +_RUNNER_CONFIGS = ( + # (command prefix, (config filenames...), spec_only) + ("npx playwright test", ("playwright.config.ts", "playwright.config.js", "playwright.config.mjs"), True), + ("npx jest --no-coverage --runTestsByPath", ("jest.config.js", "jest.config.ts", "jest.config.mjs"), False), + ("npx vitest run", ("vitest.config.ts", "vitest.config.js", "vitest.config.mjs"), 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: + 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) + + +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.""" + 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 None + + 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. @@ -473,12 +561,26 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: 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 # 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) # Deepest ancestor-workspace root the original leaf must still reach; walk # *through* intermediate independent package.json manifests until then. ceiling: Optional[Path] = None @@ -486,25 +588,28 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: # 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 - # 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 --runTestsByPath", 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) - # Stop at the JS project boundary (nearest package.json), but cross it when - # this package is a member of an ancestor workspace whose config lives at - # the workspace root — and keep crossing intermediate independent manifests - # until that workspace root is reached. - if (search_dir / "package.json").exists(): - root = _workspace_root_for(search_dir) - if root is not None and (ceiling is None or _is_strict_ancestor(root, ceiling)): - # Extend the ceiling to the shallowest (highest) workspace root, - # so nested workspaces chain correctly. - ceiling = root + found = _find_runner_here(search_dir, is_spec, repo_root) + if found is not None: + return found + pkg_here = (search_dir / "package.json").exists() + # 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) + 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 root is None and not below_ceiling: + 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(): diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 4e7aaf378f..faedf85f7c 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -30,11 +30,13 @@ R6 (MUST): Determine **workspace member** status by matching the package's relat 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** on every malformed or unsafe declaration: a manifest whose parsed top level is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a `pnpm-workspace.yaml` with no available parser, a parse error, invalid encoding, or pathological nesting. +R8 (MUST): **Fail closed** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, and glob matching (including many `**` segments) MUST be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Budgets are aggregate across one membership check, not merely per glob. -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` — MUST be refused; a symlink that stays inside the repository MUST still resolve normally. +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. + +R14 (MUST NOT): Re-run placeholder (`{file}`/`{test}`) substitution on a command returned by `get_test_command_for_file`. The returned command is already complete — CSV placeholders resolved and the runner path embedded and shell-quoted — so a caller that blindly re-substitutes would splice an unbalanced quoted string into an already-quoted argument when the resolved path itself contains that literal token, breaking shell quoting (a command-injection vector). Callers MUST treat the command as final. (Enforced at the `fix_error_loop` boundary.) 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 80498ed416..1f56e29d2b 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -919,6 +919,177 @@ def test_slash_wall_glob_fails_closed(self): _relative_matches_workspace_glob(("a",), "/".join(["x"] * 1000)) assert _package_matches_workspace(("a",), ["/".join(["x"] * 1000)]) is False + 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' + " " * (6 * 1024 * 1024) + ) + assert _workspace_globs_for(anc) == [] + + 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 + + +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 + class TestPlaywrightDetection: """Tests for Playwright detection for .spec.ts files.""" From 4d29b8b942a3ccfe28ed857f661e5d86292a978e Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 12:55:03 -0700 Subject: [PATCH 13/56] fix(get_test_command): device-symlink & brace-byte DoS; fix-loop prompt contract (round-8 review) Fifth independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found four findings; three fixed, one (low/optional) rejected with evidence. - R5-1 (high) manifest symlinked to a device: a pnpm-workspace.yaml/package.json symlinked to /dev/zero reports st_size 0 then streams forever. _read_manifest_text now requires the resolved target to be a regular file (S_ISREG) and reads at most _MAX_MANIFEST_BYTES+1 from a byte-capped handle; a symlink to a genuine regular file still works. - R5-2 (high) brace-expansion byte blowup: the count budget allowed a near-5MB prefix glob with a few brace groups to materialize ~5GB of strings. Added a per-raw-glob length cap (_MAX_GLOB_LENGTH) that bounds expansion in bytes, not just result count. Real globs are tiny; an over-long one fails membership closed. - R5-3 (medium) fix-loop prompt provenance: the {file}/{test} no-re-substitution rule lived only in get_test_command's prompt, so regenerating fix_error_loop.py could reopen the injection. Added the MUST NOT rule to fix_error_loop_python.prompt and a direct _run_non_python_initial_verification regression test. - R5-4 (low, optional) REJECTED: nested `{a{b,c}}` bash-brace parity. The impl emits it literally and fails membership CLOSED (never falsely includes); it is not a real workspace-glob pattern (would require a package literally named `{ab}`), and full bash-brace parity is out of proportion/scope. The reviewer independently re-verified fingerprint consistency (prompt/code/ example/test + include_deps all match). Meta re-synced. +5 regression tests (109 total). Suite green, E2E green, pylint E 10.00. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_test_command.py | 34 +++++++++-- pdd/prompts/fix_error_loop_python.prompt | 2 +- tests/test_get_test_command.py | 73 ++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 4609d48e5c..545ca8dadf 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T19:39:31.344132+00:00", + "timestamp": "2026-07-12T19:54:31.437684+00:00", "command": "test", "prompt_hash": "63b0952af4e344a99f521f541fe92c197b65dc9d584bb541873768d6a5da2c2b", - "code_hash": "5fdc3c62e07d446f02474ad95d1bf29ecaf1ce2ab5483c858d40579c3c2dae43", + "code_hash": "6410d5de48a083164ac4ca2ce5db8d8abf90061a40df9578405c9729f043ea38", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "18fa17f3a01938469fb3fe1fb674dd001be132e1cf8bc6d22276c324de766712", + "test_hash": "6c2e39d32061099442386678c6476edca8544d7628ad7b3cbc095134242d3972", "test_files": { - "test_get_test_command.py": "18fa17f3a01938469fb3fe1fb674dd001be132e1cf8bc6d22276c324de766712" + "test_get_test_command.py": "6c2e39d32061099442386678c6476edca8544d7628ad7b3cbc095134242d3972" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index d4c616c922..d9bfb87731 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -16,6 +16,7 @@ import os import re import shlex +import stat from .agentic_langtest import default_verify_cmd_for from .get_language import get_language @@ -48,14 +49,35 @@ # other budgets apply. _MAX_MANIFEST_BYTES = 5 * 1024 * 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 + def _read_manifest_text(path: Path) -> Optional[str]: - """Read ``path`` as UTF-8, or return ``None`` if it is missing, too large, - unreadable, or not valid UTF-8 (so callers fail closed).""" + """Read ``path`` as UTF-8, or return ``None`` (so callers fail closed) if it is + missing, not a regular file, too large, 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: - if path.stat().st_size > _MAX_MANIFEST_BYTES: + 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_MANIFEST_BYTES: return None - return path.read_text(encoding="utf-8") + with open(path, "rb") as handle: + data = handle.read(_MAX_MANIFEST_BYTES + 1) + if len(data) > _MAX_MANIFEST_BYTES: + return None # grew past the cap between stat and read + return data.decode("utf-8") except (OSError, UnicodeError): return None @@ -326,6 +348,10 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: raw = str(raw).strip() if not raw: continue + if len(raw) > _MAX_GLOB_LENGTH: + # An over-long glob would blow up expansion by bytes even under + # the count budget → membership unproven (fail closed). + raise _PatternBudgetError if raw.startswith("!"): negatives.extend(_expand_braces(raw[1:], budget)) else: diff --git a/pdd/prompts/fix_error_loop_python.prompt b/pdd/prompts/fix_error_loop_python.prompt index 86da0d5bf3..fcd166a04d 100644 --- a/pdd/prompts/fix_error_loop_python.prompt +++ b/pdd/prompts/fix_error_loop_python.prompt @@ -37,7 +37,7 @@ 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)`. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 1f56e29d2b..42fd047d42 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -905,6 +905,15 @@ def test_comma_bomb_brace_fails_closed(self): 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") @@ -1048,6 +1057,28 @@ def test_oversized_manifest_fails_closed(self, tmp_path): ) assert _workspace_globs_for(anc) == [] + @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_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" @@ -1090,6 +1121,48 @@ def test_no_reinjection_via_maliciously_named_path(self, tmp_path): 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.""" From aa63a2b03044332f0e15508d80b5c936c23f262b Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 13:08:44 -0700 Subject: [PATCH 14/56] fix(get_test_command): dangling-pnpm, symlink-loop, O(n^2) manifest re-parse (round-9 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found three medium findings; all fixed. - R6-1 dangling pnpm symlink: `Path.exists()` is False for a dangling symlink, so an authoritative pnpm-workspace.yaml was ignored and membership fell through to a stale package.json `workspaces`. pnpm presence is now detected lexically (exists() or is_symlink()); a present-but-unreadable/dangling pnpm config yields no globs (fail closed) and never falls through. - R6-2 symlink-loop crash: a self-referential/looping symlink path makes Path.resolve() raise RuntimeError on 3.12, which propagated out of get_test_command_for_file and crashed sync/fix orchestration. Resolution is now guarded (OSError, RuntimeError) in _detect_ts_test_runner and the containment helpers → refuse discovery (None) instead of crashing. - R6-3 O(n^2) manifest re-parsing: _workspace_root_for was called at each walk step and re-read every ancestor manifest, so deeply nested packages with padded manifests could re-parse gigabytes. Added a per-discovery cache keyed by canonical ancestor path so each manifest is parsed at most once. Meta re-synced. +5 regression tests (114 total; incl. a read-at-most-once assertion). Suite green, E2E green, pylint E 10.00. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_test_command.py | 54 +++++++++++++---- tests/test_get_test_command.py | 82 ++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 545ca8dadf..40d197eb9d 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T19:54:31.437684+00:00", + "timestamp": "2026-07-12T20:08:28.228030+00:00", "command": "test", "prompt_hash": "63b0952af4e344a99f521f541fe92c197b65dc9d584bb541873768d6a5da2c2b", - "code_hash": "6410d5de48a083164ac4ca2ce5db8d8abf90061a40df9578405c9729f043ea38", + "code_hash": "847a2b6ee88b931ac2e4c82bc13a6667a07c127f95000d193e5dd8798ccbc338", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "6c2e39d32061099442386678c6476edca8544d7628ad7b3cbc095134242d3972", + "test_hash": "dcc93bf73346ba123af7497db39e2940ca02edef7aaec445d264f49225364ee7", "test_files": { - "test_get_test_command.py": "6c2e39d32061099442386678c6476edca8544d7628ad7b3cbc095134242d3972" + "test_get_test_command.py": "dcc93bf73346ba123af7497db39e2940ca02edef7aaec445d264f49225364ee7" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index d9bfb87731..3e52950788 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -162,7 +162,7 @@ def _segment_matches(name: str, pattern_segment: str) -> bool: return fnmatch.fnmatch(name, pattern_segment) -def _workspace_globs_for(ancestor: Path) -> list: +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`` @@ -181,14 +181,38 @@ def _workspace_globs_for(ancestor: Path) -> list: ``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) + + +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(): + 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) # None if missing/oversized/bad-utf8 + text = _read_manifest_text(pnpm_path) # None if missing/dangling/oversized/bad-utf8 if text is None: return [] try: @@ -366,7 +390,7 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: return False -def _workspace_root_for(package_dir: Path) -> Optional[Path]: +def _workspace_root_for(package_dir: Path, cache: Optional[dict] = 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. @@ -387,11 +411,11 @@ def _workspace_root_for(package_dir: Path) -> Optional[Path]: """ ancestor = package_dir.parent for _ in range(80): - globs = _workspace_globs_for(ancestor) + globs = _workspace_globs_for(ancestor, cache) if globs: try: rel_parts = tuple(package_dir.resolve().relative_to(ancestor.resolve()).parts) - except ValueError: + except (ValueError, OSError, RuntimeError): rel_parts = () if rel_parts and _package_matches_workspace(rel_parts, globs): return ancestor @@ -415,7 +439,7 @@ def _is_within(child: Path, parent: Path) -> bool: try: child.resolve().relative_to(parent.resolve()) return True - except ValueError: + except (ValueError, OSError, RuntimeError): return False @@ -423,7 +447,7 @@ 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: + except (ValueError, OSError, RuntimeError): return False return len(rel.parts) > 0 @@ -508,7 +532,7 @@ def _resolved_repo_root(test_path: Path) -> Optional[Path]: symlink escaping the repository.""" try: directory = test_path.resolve().parent - except OSError: + except (OSError, RuntimeError): return None for _ in range(4096): if (directory / ".git").exists(): @@ -598,7 +622,12 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: return None is_spec = test_path.name.endswith(('.spec.ts', '.spec.tsx')) - search_dir = test_path.resolve().parent + 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. @@ -607,6 +636,9 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: # 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 = {} # Deepest ancestor-workspace root the original leaf must still reach; walk # *through* intermediate independent package.json manifests until then. ceiling: Optional[Path] = None @@ -623,7 +655,7 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: # 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) + member_root = _workspace_root_for(search_dir, ws_cache) 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 diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 42fd047d42..6ef9da311b 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1079,6 +1079,88 @@ def test_manifest_symlinked_to_regular_file_is_read(self, tmp_path): 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_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" From 9cf77d317cb0cd17a140198dee6a532cdfa95ca6 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 13:26:11 -0700 Subject: [PATCH 15/56] fix(get_test_command): shell-quote CSV/smart fallback paths; dangling package.json boundary (round-10 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found two findings; both fixed, plus a same-class injection in the delegated smart-detection path. - R7-1 (high) CSV-fallback command injection: `get_test_command_for_file` step 2 substituted the raw test path into the CSV command without shell-quoting, so a path like `/repo/$(touch PWN)/a.py` injected under the callers' `shell=True`. The substitution now uses `shlex.quote`. - Same-class injection in step 3 (smart detection): `default_verify_cmd_for` (pdd/agentic_langtest.py), whose command `get_test_command_for_file` returns as-is, substituted the path unquoted — and its Python fallback used bare double quotes, which do NOT stop `$()` command substitution. Both now use `shlex.quote`. - R7-2 (medium) dangling package.json boundary: the JS-project boundary used `Path.exists()`, which is False for a dangling/looping `package.json` symlink, so the walk slipped past an independent package and adopted an unrelated ancestor config. Boundary detection now uses `os.path.lexists` (present-but-dangling still stops the walk); a proven workspace member still inherits correctly. Prompt R13 updated to require shell-quoting the CSV path. Fingerprint meta re-synced (incl. the agentic_langtest include-dep hash). +6 regression tests (get_test_command 117 + agentic_langtest 15 = shell-injection and dangling/looping symlink coverage). Suites green, E2E green, pylint E 10.00. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 12 ++--- pdd/agentic_langtest.py | 11 +++- pdd/get_test_command.py | 12 ++++- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_agentic_langtest.py | 15 ++++++ tests/test_get_test_command.py | 60 ++++++++++++++++++++-- 6 files changed, 98 insertions(+), 14 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 40d197eb9d..956ec8ffee 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,17 +1,17 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T20:08:28.228030+00:00", + "timestamp": "2026-07-12T20:25:23.527157+00:00", "command": "test", - "prompt_hash": "63b0952af4e344a99f521f541fe92c197b65dc9d584bb541873768d6a5da2c2b", - "code_hash": "847a2b6ee88b931ac2e4c82bc13a6667a07c127f95000d193e5dd8798ccbc338", + "prompt_hash": "4c878c9afa1778f54bbf41ec101b10215c7b7a1f8e33b8f443bd6a2e423a6206", + "code_hash": "de296f5ac932cfb81196fe0f7fdfe7641efcb9681301af5601c261eca0ce26b1", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "dcc93bf73346ba123af7497db39e2940ca02edef7aaec445d264f49225364ee7", + "test_hash": "f7cbd358e86c19125ddefb25a020171ad555cd3bea56fd6cb06c75b6899418e4", "test_files": { - "test_get_test_command.py": "dcc93bf73346ba123af7497db39e2940ca02edef7aaec445d264f49225364ee7" + "test_get_test_command.py": "f7cbd358e86c19125ddefb25a020171ad555cd3bea56fd6cb06c75b6899418e4" }, "include_deps": { "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", - "pdd/agentic_langtest.py": "7cb0be38d526d0a630500185427a9f76666d2b79385f7333a7608eb1fb33cfc9", + "pdd/agentic_langtest.py": "5b8927fbf48bb73b11aa6654d1cb0080f0f4ea292ce2f3699300a58abce09ec2", "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" } diff --git a/pdd/agentic_langtest.py b/pdd/agentic_langtest.py index 35a2936412..1a590b8559 100644 --- a/pdd/agentic_langtest.py +++ b/pdd/agentic_langtest.py @@ -11,6 +11,7 @@ import csv import os +import shlex from pathlib import Path @@ -54,11 +55,17 @@ 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: this command is executed with + # ``shell=True`` by pdd callers, so an unquoted path with spaces or + # shell metacharacters (e.g. ``$()``/``;``) would be re-split or run + # via command substitution — a command-injection vector. + return csv_cmd.replace('{file}', shlex.quote(unit_test_file)) # 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. + return f'{os.sys.executable} -m pytest {shlex.quote(unit_test_file)} -q' # 3. No command available — triggers agentic fallback return None diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 3e52950788..5ad1dd3790 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -649,7 +649,11 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: found = _find_runner_here(search_dir, is_spec, repo_root) if found is not None: return found - pkg_here = (search_dir / "package.json").exists() + # 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. @@ -758,7 +762,11 @@ def get_test_command_for_file(test_file: str, language: Optional[str] = None) -> 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: callers run this command string with + # ``shell=True``, so an unquoted path with spaces or shell metacharacters + # (e.g. ``/repo/$(touch PWN)/a.test.ts``) would be re-split or executed + # via command substitution — a command-injection vector. + return TestCommand(command=csv_cmd.replace('{file}', shlex.quote(str(test_file)))) # 3. Smart detection if resolved_language: diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index faedf85f7c..ff110c6da3 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -42,7 +42,7 @@ R11 (MUST): Target the test path literally. Invoke Jest with `--runTestsByPath` 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. (This is a POSIX-shell command string, matching how all pdd callers run verify commands.) -R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder with the input file string and return `cwd=None`. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. +R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder with the **shell-quoted** input file string (callers execute with `shell=True`, so an unquoted path with spaces or shell metacharacters such as `$()`/`;` would be re-split or injected) and return `cwd=None`. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. # Dependencies 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_get_test_command.py b/tests/test_get_test_command.py index 6ef9da311b..8ea73e18ad 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -198,15 +198,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') @@ -1106,6 +1126,40 @@ def test_dangling_pnpm_symlink_leaf_not_a_member(self, tmp_path): 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" From db417d1e3535b6fd9026f6c209df38ef6ae3b5a6 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 13:50:37 -0700 Subject: [PATCH 16/56] fix(get_test_command): agentic_fix injection, discovery-wide match budget, grounding provenance (round-11 review) Eighth independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found three findings; all fixed. - R8-1 (high) agentic_fix.py command injection: `_verify_and_log` and the preflight path re-substituted `{test}`/`{cwd}` into a command that may already be a finalized `default_verify_cmd_for` output, so a resolved path containing a literal `{test}` + shell metacharacters broke its quoting and injected under `bash -lc`. Now the PDD_AGENTIC_VERIFY_CMD *template* is substituted with `shlex.quote`d values, while a *finalized* command (env unset) runs as-is with no re-substitution. No caller passes a template via the param, so the env var is the authoritative template source. - R8-2 (medium) agentic_langtest provenance: the module's own prompt still claimed "all non-Python languages return None" and an unsafe double-quoted Python path, and the grounding example (`context/agentic_langtest_example.py`, an include-dep of the get_test_command prompt) demonstrated the same unsafe double-quoting. Back-propagated the real CSV-then-pytest resolution and the POSIX shell-quoting contract into the prompt, and rewrote the grounding example to `shlex.quote` every shell-substituted path (JS require target JSON-encoded then shell-quoted). - R8-3 (medium) aggregate matching DoS: the per-check DP-cell budget did not bound work across the whole discovery walk, so a heavy manifest re-evaluated at each of many nested package boundaries could stall for tens of seconds. The DP-cell budget is now shared across the entire `_detect_ts_test_runner` call; a legit deep chain still resolves. Fingerprint meta re-synced (incl. the changed grounding-example include-dep hash). +4 regression tests (get_test_command 119 + agentic_langtest 15 + agentic_fix TestVerifyAndLog 9). Suites green, E2E green, pylint E clean. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 12 ++--- context/agentic_langtest_example.py | 31 +++++++++---- pdd/agentic_fix.py | 25 ++++++++-- pdd/get_test_command.py | 40 +++++++++++++--- pdd/prompts/agentic_langtest_python.prompt | 32 +++++++++---- tests/test_agentic_fix.py | 35 ++++++++++++++ tests/test_get_test_command.py | 53 ++++++++++++++++++++++ 7 files changed, 195 insertions(+), 33 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 956ec8ffee..0984e59776 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,16 +1,16 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T20:25:23.527157+00:00", + "timestamp": "2026-07-12T20:50:10.839837+00:00", "command": "test", - "prompt_hash": "4c878c9afa1778f54bbf41ec101b10215c7b7a1f8e33b8f443bd6a2e423a6206", - "code_hash": "de296f5ac932cfb81196fe0f7fdfe7641efcb9681301af5601c261eca0ce26b1", + "prompt_hash": "44da432d4b26beee68d087e1fc65aaee728f5fa1606e2637bce1f9c0229738eb", + "code_hash": "9945ac614480455804d724169a7096a6aeb0dca2706b1a96aa59e638beb80ec9", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "f7cbd358e86c19125ddefb25a020171ad555cd3bea56fd6cb06c75b6899418e4", + "test_hash": "5ef543541a53ed3848d78849c4c5f9e1fa6526bf80346cc0d50d1156cfb9a034", "test_files": { - "test_get_test_command.py": "f7cbd358e86c19125ddefb25a020171ad555cd3bea56fd6cb06c75b6899418e4" + "test_get_test_command.py": "5ef543541a53ed3848d78849c4c5f9e1fa6526bf80346cc0d50d1156cfb9a034" }, "include_deps": { - "context/agentic_langtest_example.py": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f", + "context/agentic_langtest_example.py": "6f4471b0ffb4aab7114d292cf6dbd72f7fe7e597f3d302698e50e19d1b6e4238", "pdd/agentic_langtest.py": "5b8927fbf48bb73b11aa6654d1cb0080f0f4ea292ce2f3699300a58abce09ec2", "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" diff --git a/context/agentic_langtest_example.py b/context/agentic_langtest_example.py index 942fa0424f..a97e84a684 100644 --- a/context/agentic_langtest_example.py +++ b/context/agentic_langtest_example.py @@ -1,6 +1,8 @@ # pdd/agentic_langtest.py from __future__ import annotations +import json import os +import shlex import shutil import sys from pathlib import Path @@ -59,38 +61,49 @@ def default_verify_cmd_for(lang: str, unit_test_file: str) -> str | None: A shell command string to execute the tests, or None if the language is not supported or a build system cannot be detected. """ - test_rel = unit_test_file + # Every path spliced into a returned command MUST be shell-quoted: these + # commands are executed with ``bash -lc`` / ``shell=True``, so an unquoted + # path with spaces or shell metacharacters (``$()``, ``;``, …) would be + # re-split or run as a command-injection vector. Bare double quotes are NOT + # enough — ``"$(...)"`` is still expanded inside double quotes — so use + # ``shlex.quote``. lang = lang.lower() if lang == "python": - return f'{sys.executable} -m pytest "{test_rel}" -q' + return f'{sys.executable} -m pytest {shlex.quote(unit_test_file)} -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) + # The JS require target is a JavaScript string literal, so JSON-encode it + # (safe JS quoting) and then shell-quote the whole ``node -e`` program. + node_program = ( + "try { require(" + json.dumps("./" + rel_test_path) + + "); } catch (e) { console.error(e); process.exit(1); }" + ) return ( "set -e\n" - f'cd "{example_dir}" && ' + f"cd {shlex.quote(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); }}"; ' + f" echo {shlex.quote('No package.json in ' + example_dir + '; running test file directly')}; " + f" node -e {shlex.quote(node_program)}; " "fi" ) if lang == "java": root_dir = str(_find_project_root(unit_test_file)) + q_root = shlex.quote(root_dir) # Prefer Maven if pom.xml is present if "pom.xml" in os.listdir(root_dir): - return f"cd '{root_dir}' && mvn test" + return f"cd {q_root} && 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" + return f"cd {q_root} && ./gradlew test" else: - # Fixed incorrect quoting/backtick usage: use cd and run gradle - return f"cd '{root_dir}' && gradle test" + return f"cd {q_root} && gradle test" else: return None diff --git a/pdd/agentic_fix.py b/pdd/agentic_fix.py index 81b9f801d9..3f50e6f78b 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 @@ -127,7 +128,18 @@ def _verify_and_log(unit_test_file: str, cwd: Path, *, verify_cmd: Optional[str] if not enabled: return True if verify_cmd: - cmd = verify_cmd.replace("{test}", str(Path(unit_test_file).resolve())).replace("{cwd}", str(cwd)) + # A user template (PDD_AGENTIC_VERIFY_CMD) has intended `{test}`/`{cwd}` + # placeholders → substitute them with SHELL-QUOTED values (executed via + # `bash -lc`). A command from `default_verify_cmd_for` is already finalized + # and shell-safe → run it as-is; re-substituting would splice into its + # existing quoting and inject when the resolved path contains a literal + # `{test}`/`{cwd}` plus shell metacharacters. + if os.getenv("PDD_AGENTIC_VERIFY_CMD"): + cmd = verify_cmd.replace( + "{test}", shlex.quote(str(Path(unit_test_file).resolve())) + ).replace("{cwd}", shlex.quote(str(cwd))) + 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 +302,16 @@ 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") + pre_cmd = env_tpl or 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)) + # Only a user template's `{test}`/`{cwd}` placeholders are + # substituted (shell-quoted); a finalized default command runs + # as-is so its quoting is not corrupted (injection). + if env_tpl: + pre_cmd = env_tpl.replace( + "{test}", shlex.quote(str(Path(unit_test_file).resolve())) + ).replace("{cwd}", shlex.quote(str(working_dir))) pre = subprocess.run( ["bash", "-lc", pre_cmd], capture_output=True, text=True, check=False, diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 5ad1dd3790..950403cbc5 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -58,6 +58,15 @@ # 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 + def _read_manifest_text(path: Path) -> Optional[str]: """Read ``path`` as UTF-8, or return ``None`` (so callers fail closed) if it is @@ -103,7 +112,8 @@ class TestCommand: cwd: Optional[Path] = None -def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str) -> bool: +def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, + cell_budget: Optional[list] = None) -> bool: """Match a package's path segments against a single workspace glob pattern. Supports the segment semantics workspace tools use: ``*`` matches exactly one @@ -133,6 +143,12 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str) - raise _PatternBudgetError rel = list(rel_parts) n, m = len(rel), len(pat_parts) + # 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 # 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 @@ -354,7 +370,8 @@ def _emit(value: str) -> None: return out -def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: +def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, + cell_budget: Optional[list] = None) -> bool: """Return True when ``rel_parts`` matches the workspace globs' include/exclude semantics: at least one positive pattern matches and no ``!`` exclusion does. @@ -367,6 +384,11 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: return False # hostile declaration size → membership unproven (fail closed) try: budget = [_MAX_BRACE_EXPANSION] # shared across every glob below + # 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] positives, negatives = [], [] for raw in globs: raw = str(raw).strip() @@ -380,9 +402,9 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: negatives.extend(_expand_braces(raw[1:], budget)) else: positives.extend(_expand_braces(raw, budget)) - if not any(_relative_matches_workspace_glob(rel_parts, p) for p in positives): + if not any(_relative_matches_workspace_glob(rel_parts, p, cells) for p in positives): return False - return not any(_relative_matches_workspace_glob(rel_parts, n) for n in negatives) + return not any(_relative_matches_workspace_glob(rel_parts, n, cells) for n in negatives) except (_PatternBudgetError, RecursionError): # Pathological pattern from an untrusted manifest (brace bomb, ``**`` # wall, or deep nesting) → cannot be evaluated safely, so membership is @@ -390,7 +412,8 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list) -> bool: return False -def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None) -> Optional[Path]: +def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None, + cell_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. @@ -417,7 +440,7 @@ def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None) -> Opti 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): + if rel_parts and _package_matches_workspace(rel_parts, globs, cell_budget): return ancestor if (ancestor / ".git").exists(): break @@ -639,6 +662,9 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, 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] # Deepest ancestor-workspace root the original leaf must still reach; walk # *through* intermediate independent package.json manifests until then. ceiling: Optional[Path] = None @@ -659,7 +685,7 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: # 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) + member_root = _workspace_root_for(search_dir, ws_cache, match_cells) 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 diff --git a/pdd/prompts/agentic_langtest_python.prompt b/pdd/prompts/agentic_langtest_python.prompt index 33e93f267e..6f581d91d7 100644 --- a/pdd/prompts/agentic_langtest_python.prompt +++ b/pdd/prompts/agentic_langtest_python.prompt @@ -5,12 +5,18 @@ 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. - -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. +`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 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,8 +29,18 @@ 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. diff --git a/tests/test_agentic_fix.py b/tests/test_agentic_fix.py index 353fb1182a..b74d83826c 100644 --- a/tests/test_agentic_fix.py +++ b/tests/test_agentic_fix.py @@ -225,6 +225,41 @@ def test_verify_and_log_with_verify_cmd(self, tmp_path): ) assert result is True + def test_verify_and_log_env_template_shell_quotes_path(self, tmp_path, monkeypatch): + """A PDD_AGENTIC_VERIFY_CMD template's {test}/{cwd} substitution is shell- + quoted, so a maliciously named test path cannot inject under `bash -lc`.""" + import shlex + monkeypatch.setenv("PDD_AGENTIC_VERIFY_CMD", "pytest {test}") + 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) + argv = shlex.split(captured["cmd"]) + assert str(test_file.resolve()) in argv, (captured["cmd"], argv) + assert "touch" not in argv, argv + + def test_verify_and_log_finalized_command_is_not_resubstituted(self, tmp_path, monkeypatch): + """Without the env template, a finalized command (e.g. from + default_verify_cmd_for) is executed as-is — no {test}/{cwd} re-substitution + that could corrupt its quoting when the path contains a literal {test}.""" + import shlex + monkeypatch.delenv("PDD_AGENTIC_VERIFY_CMD", raising=False) + 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_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_get_test_command.py b/tests/test_get_test_command.py index 8ea73e18ad..9684d1dda9 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -948,6 +948,59 @@ def test_slash_wall_glob_fails_closed(self): _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. From e504487ad813df8fe536c575eb4f3d79a77156d3 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 14:13:54 -0700 Subject: [PATCH 17/56] fix: quote get_run_command path, preserve workspace glob whitespace, explicit fix-loop provenance, faithful grounding (round-12 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found five findings; all fixed. - R9-1 (high) get_run_command injection: `get_run_command_for_file` substituted the path into the run template unquoted, and `agentic_fix` preflight/`_verify_and_log` execute it via `bash -lc` — so a Java/other test at `/repo/$(touch PWN)/x` injected. The `{file}` substitution now uses `shlex.quote`. - R9-2 (medium) whitespace-normalized glob: `_package_matches_workspace` stripped surrounding whitespace, turning `" packages/* "` (literal-whitespace, a non-match in workspace tools) into a broader `packages/*` and falsely proving membership. Whitespace is now preserved; only an exactly-empty entry is skipped. - R9-3 (medium) fix-loop provenance heuristic: agentic_fix `_verify_and_log` inferred template-vs-finalized from ambient `PDD_AGENTIC_VERIFY_CMD` state, which mishandles an explicit `verify_cmd=` template arg. Provenance is now tracked explicitly (`verify_cmd_is_template`): templates get shell-quoted `{test}`/`{cwd}` substitution, finalized commands run as-is. - R9-4 (medium) grounding example contradicted the contract: the get_test_command prompt's grounding (`context/agentic_langtest_example.py`) hardcoded JS npm / Java Maven-Gradle logic instead of the real CSV-first→Python-fallback→None resolution. Rewrote `default_verify_cmd_for` to mirror the real contract with `shlex.quote`. - R9-5 (low) interpreter path unquoted: the Python fallback now shell-quotes `sys.executable` too (a Python installed under a path with spaces no longer re-splits), in both the code and the grounding example. Fingerprint meta re-synced (incl. the changed grounding-example + agentic_langtest include-dep hashes). +5 regression tests across get_test_command / get_run_command / agentic_fix. Suites green, E2E green, pylint E clean. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 14 ++-- context/agentic_langtest_example.py | 92 +++++++++++--------------- pdd/agentic_fix.py | 31 ++++++--- pdd/agentic_langtest.py | 4 +- pdd/get_run_command.py | 7 +- pdd/get_test_command.py | 6 +- tests/test_agentic_fix.py | 20 +++--- tests/test_get_run_command.py | 16 ++++- tests/test_get_test_command.py | 26 ++++++++ 9 files changed, 129 insertions(+), 87 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 0984e59776..bfee1fb3cd 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,17 +1,17 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T20:50:10.839837+00:00", + "timestamp": "2026-07-12T21:13:22.844361+00:00", "command": "test", - "prompt_hash": "44da432d4b26beee68d087e1fc65aaee728f5fa1606e2637bce1f9c0229738eb", - "code_hash": "9945ac614480455804d724169a7096a6aeb0dca2706b1a96aa59e638beb80ec9", + "prompt_hash": "81dfaf5f1e69cc08cd109cdb7821dede47933a550bdd9234550d75a0575bf2cc", + "code_hash": "f8b5c342e98cbb162c50b6ac10f76459bbc9137cdc243a2073b538ace2c59da6", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "5ef543541a53ed3848d78849c4c5f9e1fa6526bf80346cc0d50d1156cfb9a034", + "test_hash": "1c6a0757f7e7bf4ef2719921e0932d1683b94ffb705556f9243ba3bfb7545575", "test_files": { - "test_get_test_command.py": "5ef543541a53ed3848d78849c4c5f9e1fa6526bf80346cc0d50d1156cfb9a034" + "test_get_test_command.py": "1c6a0757f7e7bf4ef2719921e0932d1683b94ffb705556f9243ba3bfb7545575" }, "include_deps": { - "context/agentic_langtest_example.py": "6f4471b0ffb4aab7114d292cf6dbd72f7fe7e597f3d302698e50e19d1b6e4238", - "pdd/agentic_langtest.py": "5b8927fbf48bb73b11aa6654d1cb0080f0f4ea292ce2f3699300a58abce09ec2", + "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", + "pdd/agentic_langtest.py": "5257c999b30c44063f0ed76c0f680a591391d7e1674887bdf9fe15cef7cce02b", "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" } diff --git a/context/agentic_langtest_example.py b/context/agentic_langtest_example.py index a97e84a684..3081a16c95 100644 --- a/context/agentic_langtest_example.py +++ b/context/agentic_langtest_example.py @@ -1,7 +1,6 @@ # pdd/agentic_langtest.py from __future__ import annotations -import json -import os +import csv import shlex import shutil import sys @@ -47,66 +46,49 @@ 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 every + path spliced into it MUST be shell-quoted (`shlex.quote`). Bare double quotes + are NOT enough — `"$(...)"` is still expanded inside double quotes — so an + unquoted path with spaces or shell metacharacters (`$()`, `;`, …) would be + re-split or run as a command-injection vector. """ - # Every path spliced into a returned command MUST be shell-quoted: these - # commands are executed with ``bash -lc`` / ``shell=True``, so an unquoted - # path with spaces or shell metacharacters (``$()``, ``;``, …) would be - # re-split or run as a command-injection vector. Bare double quotes are NOT - # enough — ``"$(...)"`` is still expanded inside double quotes — so use - # ``shlex.quote``. lang = lang.lower() - if lang == "python": - return f'{sys.executable} -m pytest {shlex.quote(unit_test_file)} -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) - # The JS require target is a JavaScript string literal, so JSON-encode it - # (safe JS quoting) and then shell-quote the whole ``node -e`` program. - node_program = ( - "try { require(" + json.dumps("./" + rel_test_path) - + "); } catch (e) { console.error(e); process.exit(1); }" - ) - return ( - "set -e\n" - f"cd {shlex.quote(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 {shlex.quote('No package.json in ' + example_dir + '; running test file directly')}; " - f" node -e {shlex.quote(node_program)}; " - "fi" - ) - if lang == "java": - root_dir = str(_find_project_root(unit_test_file)) - q_root = shlex.quote(root_dir) - # Prefer Maven if pom.xml is present - if "pom.xml" in os.listdir(root_dir): - return f"cd {q_root} && 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 {q_root} && ./gradlew test" - else: - return f"cd {q_root} && gradle test" - else: - return None + # 1. CSV lookup by language name. + csv_cmd = _run_test_command_for_language(lang) + if csv_cmd: + return csv_cmd.replace("{file}", shlex.quote(unit_test_file)) + + # 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/pdd/agentic_fix.py b/pdd/agentic_fix.py index 3f50e6f78b..63fa4d7cce 100644 --- a/pdd/agentic_fix.py +++ b/pdd/agentic_fix.py @@ -117,24 +117,27 @@ 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 _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: - # A user template (PDD_AGENTIC_VERIFY_CMD) has intended `{test}`/`{cwd}` - # placeholders → substitute them with SHELL-QUOTED values (executed via - # `bash -lc`). A command from `default_verify_cmd_for` is already finalized - # and shell-safe → run it as-is; re-substituting would splice into its - # existing quoting and inject when the resolved path contains a literal - # `{test}`/`{cwd}` plus shell metacharacters. - if os.getenv("PDD_AGENTIC_VERIFY_CMD"): + if verify_cmd_is_template: cmd = verify_cmd.replace( "{test}", shlex.quote(str(Path(unit_test_file).resolve())) ).replace("{cwd}", shlex.quote(str(cwd))) @@ -347,10 +350,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) @@ -439,7 +449,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 1a590b8559..5f3088d511 100644 --- a/pdd/agentic_langtest.py +++ b/pdd/agentic_langtest.py @@ -65,7 +65,9 @@ def default_verify_cmd_for(lang: str, unit_test_file: str) -> str | None: if lang == "python": # ``shlex.quote`` (not bare double quotes): ``"$(...)"`` is still expanded # by the shell inside double quotes, so double quotes do not stop injection. - return f'{os.sys.executable} -m pytest {shlex.quote(unit_test_file)} -q' + # 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/get_run_command.py b/pdd/get_run_command.py index 47bbf1b6a1..40fc7e36d9 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -2,6 +2,7 @@ import os import csv +import shlex from pdd.path_resolution import get_default_resolver @@ -72,4 +73,8 @@ 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 — a command-injection vector. + return run_command_template.replace('{file}', shlex.quote(file_path)) diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 950403cbc5..0147cb4c36 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -391,7 +391,11 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, cells = cell_budget if cell_budget is not None else [_MAX_MATCH_CELLS] positives, negatives = [], [] for raw in globs: - raw = str(raw).strip() + # 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/*`. Normalizing it + # would falsely prove membership. Skip only an exactly-empty entry. + raw = str(raw) if not raw: continue if len(raw) > _MAX_GLOB_LENGTH: diff --git a/tests/test_agentic_fix.py b/tests/test_agentic_fix.py index b74d83826c..5a4e15acd9 100644 --- a/tests/test_agentic_fix.py +++ b/tests/test_agentic_fix.py @@ -225,11 +225,11 @@ def test_verify_and_log_with_verify_cmd(self, tmp_path): ) assert result is True - def test_verify_and_log_env_template_shell_quotes_path(self, tmp_path, monkeypatch): - """A PDD_AGENTIC_VERIFY_CMD template's {test}/{cwd} substitution is shell- - quoted, so a maliciously named test path cannot inject under `bash -lc`.""" + 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 - monkeypatch.setenv("PDD_AGENTIC_VERIFY_CMD", "pytest {test}") evil = tmp_path / "{test}';touch PWN;echo '" evil.mkdir() test_file = evil / "a.py" @@ -237,17 +237,17 @@ def test_verify_and_log_env_template_shell_quotes_path(self, tmp_path, monkeypat 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_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_and_log_finalized_command_is_not_resubstituted(self, tmp_path, monkeypatch): - """Without the env template, a finalized command (e.g. from - default_verify_cmd_for) is executed as-is — no {test}/{cwd} re-substitution - that could corrupt its quoting when the path contains a literal {test}.""" + 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 - monkeypatch.delenv("PDD_AGENTIC_VERIFY_CMD", raising=False) evil = tmp_path / "{test}';touch PWN;echo '" evil.mkdir() test_file = evil / "a.py" diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index 3221bdac5e..33f8710ecb 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -96,8 +96,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.""" diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 9684d1dda9..0dcfad0a61 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -704,6 +704,32 @@ def test_brace_bomb_raises_budget_error(self): 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 From 5f74e8ef1c5be954deb466a1d3de60127bc5cffa Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 14:24:28 -0700 Subject: [PATCH 18/56] fix(get_test_command): fail closed on non-YAMLError pnpm parse crashes (round-13 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenth independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found one finding; fixed. - R10-1 (medium) pnpm YAML construction crash: a `pnpm-workspace.yaml` value such as `packages: [2020-99-99]` makes PyYAML's timestamp constructor raise a bare `ValueError` ("month must be in 1..12") — which is NOT a `yaml.YAMLError`, so it escaped the handler and crashed runner discovery. The parse now also catches `ValueError`/`TypeError`/`OverflowError` (any construction failure on untrusted YAML) and fails membership closed. Fingerprint meta re-synced. +2 regression tests (malformed-timestamp scalar, end-to-end). Suite 123 passed, E2E green. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +++---- pdd/get_test_command.py | 10 ++++++--- tests/test_get_test_command.py | 30 ++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index bfee1fb3cd..bb6473da36 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T21:13:22.844361+00:00", + "timestamp": "2026-07-12T21:24:25.434089+00:00", "command": "test", "prompt_hash": "81dfaf5f1e69cc08cd109cdb7821dede47933a550bdd9234550d75a0575bf2cc", - "code_hash": "f8b5c342e98cbb162c50b6ac10f76459bbc9137cdc243a2073b538ace2c59da6", + "code_hash": "b37e368edc748ec3c90498a08b4115e63e5fbb575c5f8113fa107389b0cbd5f6", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "1c6a0757f7e7bf4ef2719921e0932d1683b94ffb705556f9243ba3bfb7545575", + "test_hash": "5346c3ba18e0957fadc44c8d1c81b10787e5d47604475975e75ff29e47d0d84a", "test_files": { - "test_get_test_command.py": "1c6a0757f7e7bf4ef2719921e0932d1683b94ffb705556f9243ba3bfb7545575" + "test_get_test_command.py": "5346c3ba18e0957fadc44c8d1c81b10787e5d47604475975e75ff29e47d0d84a" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 0147cb4c36..9d2cd6e8a9 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -233,9 +233,13 @@ def _workspace_globs_uncached(ancestor: Path) -> list: return [] try: data = yaml.safe_load(text) - except (yaml.YAMLError, RecursionError): - # Unparseable or deeply-nested (recursion-bomb) pnpm workspace → - # membership unproven (fail closed). + 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")) diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 0dcfad0a61..2f619dfbe7 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -968,6 +968,36 @@ def test_pnpm_yaml_recursion_bomb_fails_closed(self, tmp_path): (anc / "pnpm-workspace.yaml").write_text("packages: " + "[" * 3000 + "]" * 3000 + "\n") assert _workspace_globs_for(anc) == [] + def test_pnpm_yaml_malformed_timestamp_fails_closed(self, tmp_path): + """A malformed YAML timestamp scalar raises a bare ValueError from PyYAML's + constructor — not a yaml.YAMLError — and must fail closed, not crash.""" + pytest.importorskip("yaml") + for body in ("packages: [2020-99-99]\n", "packages: 2020-13-45\n"): + anc = tmp_path / body[:12].replace(":", "_").replace(" ", "") + anc.mkdir() + (anc / "pnpm-workspace.yaml").write_text(body) + assert _workspace_globs_for(anc) == [] + (anc / "pnpm-workspace.yaml").unlink() + + 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): From 5661d6a7116b96b05ea3561b8560648b8a87d8e6 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 14:39:43 -0700 Subject: [PATCH 19/56] fix(get_test_command): bound manifest parse memory to prevent OOM (round-14 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleventh independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found one finding; fixed. - R11-1 (high) manifest parse OOM: a valid under-5MB workspace manifest containing millions of short entries materialized hundreds of MB of Python objects during json.loads/yaml.safe_load — before the `_MAX_RAW_GLOBS` count guard could run — and could OOM a worker. Real manifests are tiny, so the byte caps are now small: 1 MiB for package.json/lerna.json and 256 KiB for pnpm-workspace.yaml (YAML amplifies more per byte and a real pnpm workspace file is a few KB). Peak parse memory is now bounded to ~100 MB with generous headroom for legit manifests. `_string_globs` also rejects an over-`_MAX_RAW_GLOBS`-cardinality list up front, before validating/copying it, so no second full-list traversal occurs. Fingerprint meta re-synced. +1 regression test (under-byte-cap over-cardinality + over-byte-cap). Suite 124 passed, E2E green, pylint E clean. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +++--- pdd/get_test_command.py | 35 ++++++++++++++++++-------- tests/test_get_test_command.py | 25 +++++++++++++++++- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index bb6473da36..3a373efb34 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T21:24:25.434089+00:00", + "timestamp": "2026-07-12T21:39:24.768289+00:00", "command": "test", "prompt_hash": "81dfaf5f1e69cc08cd109cdb7821dede47933a550bdd9234550d75a0575bf2cc", - "code_hash": "b37e368edc748ec3c90498a08b4115e63e5fbb575c5f8113fa107389b0cbd5f6", + "code_hash": "8de21c3f8e9c408c6d8b258fa468bf250c95bbeff66765da4c2acb033b16ac6c", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "5346c3ba18e0957fadc44c8d1c81b10787e5d47604475975e75ff29e47d0d84a", + "test_hash": "7989eb8f89d0557b79870049ec66f79179f71f827e6773d9532a7b2e3e12950f", "test_files": { - "test_get_test_command.py": "5346c3ba18e0957fadc44c8d1c81b10787e5d47604475975e75ff29e47d0d84a" + "test_get_test_command.py": "7989eb8f89d0557b79870049ec66f79179f71f827e6773d9532a7b2e3e12950f" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 9d2cd6e8a9..5ea1a41eb3 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -44,10 +44,16 @@ _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, so a modest -# git blob cannot force hundreds of megabytes of parsing/allocation before the -# other budgets apply. -_MAX_MANIFEST_BYTES = 5 * 1024 * 1024 +# 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 @@ -68,9 +74,10 @@ _MAX_MATCH_CELLS = 2_000_000 -def _read_manifest_text(path: Path) -> Optional[str]: +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, too large, unreadable, or not valid UTF-8. + 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 @@ -80,11 +87,11 @@ def _read_manifest_text(path: Path) -> Optional[str]: 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_MANIFEST_BYTES: + if st.st_size > max_bytes: return None with open(path, "rb") as handle: - data = handle.read(_MAX_MANIFEST_BYTES + 1) - if len(data) > _MAX_MANIFEST_BYTES: + 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): @@ -228,7 +235,7 @@ def _workspace_globs_uncached(ancestor: Path) -> list: import yaml # optional dependency except ImportError: return [] # cannot parse pnpm config → membership unproven (fail closed) - text = _read_manifest_text(pnpm_path) # None if missing/dangling/oversized/bad-utf8 + text = _read_manifest_text(pnpm_path, _MAX_PNPM_YAML_BYTES) # None if missing/dangling/oversized/bad-utf8 if text is None: return [] try: @@ -283,9 +290,15 @@ def _workspace_globs_uncached(ancestor: Path) -> list: 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.""" + (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) diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 2f619dfbe7..e3b117745f 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1182,10 +1182,33 @@ def test_oversized_manifest_fails_closed(self, tmp_path): anc = tmp_path / "a" anc.mkdir() (anc / "package.json").write_text( - '{"workspaces":["packages/*"]}\n' + " " * (6 * 1024 * 1024) + '{"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) From bcd0dce8df01bbf60acb254319e0c1d503139962 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 14:48:47 -0700 Subject: [PATCH 20/56] fix(get_test_command): lerna explicit-null packages is not the default (round-15 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelfth independent Codex gpt-5.6-sol xhigh review (fresh, at new head) found one finding; fixed. - R12-1 (medium) lerna explicit-null default: `lerna.json` `{"packages": null}` was treated the same as an omitted key and granted the documented `packages/*` default, falsely proving membership and adopting the root Jest config — which contradicts the prompt contract that only an *omitted* key gets the default. The code now distinguishes absence (`"packages" not in lerna` → default) from an explicit value (`null` → `_string_globs(None)` → no globs, fail closed). Fingerprint meta re-synced. +1 regression test (omitted vs explicit-null, direct + end-to-end). Suite 125 passed, E2E green, pylint E clean. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +++---- pdd/get_test_command.py | 14 +++++------ tests/test_get_test_command.py | 32 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 3a373efb34..2e76758102 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T21:39:24.768289+00:00", + "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", "prompt_hash": "81dfaf5f1e69cc08cd109cdb7821dede47933a550bdd9234550d75a0575bf2cc", - "code_hash": "8de21c3f8e9c408c6d8b258fa468bf250c95bbeff66765da4c2acb033b16ac6c", + "code_hash": "726744ba760359270fbe0a39c7b4527052831f7947ac1369c639944a74aafc5b", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "7989eb8f89d0557b79870049ec66f79179f71f827e6773d9532a7b2e3e12950f", + "test_hash": "22f4ef16391bf97733581a1fedda52808ca3bda087d2ebe46c903172becaeb11", "test_files": { - "test_get_test_command.py": "7989eb8f89d0557b79870049ec66f79179f71f827e6773d9532a7b2e3e12950f" + "test_get_test_command.py": "22f4ef16391bf97733581a1fedda52808ca3bda087d2ebe46c903172becaeb11" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 5ea1a41eb3..95e015cf1a 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -275,15 +275,15 @@ def _workspace_globs_uncached(ancestor: Path) -> list: except (ValueError, RecursionError): lerna = None # parse failed → contribute nothing (fail closed) if isinstance(lerna, dict): - pkgs = lerna.get("packages") - if pkgs is None: - # Successfully parsed lerna.json with no ``packages`` key → the - # documented lerna default. (A *parse failure* above sets - # ``lerna=None`` and skips this, so a malformed/bomb lerna.json - # does NOT silently grant the default glob.) + 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(pkgs)) + globs.extend(_string_globs(lerna["packages"])) return globs diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index e3b117745f..a7278b36cf 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1365,6 +1365,38 @@ def test_malformed_lerna_does_not_grant_default_glob(self, tmp_path): 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.""" From 2f860e3145b04cfeda15bbf0395ae5aec1f2ec70 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 15:06:41 -0700 Subject: [PATCH 21/56] fix(get_test_command): expand brace alternations after/inside single-option groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-13 review (Codex gpt-5.6-sol xhigh) found _expand_braces stopped at the first single-option brace and emitted the whole pattern literally, so a real alternation appearing later in the pattern was never expanded. With workspace globs ["packages/**", "!packages/{foo}/{a,b}"] the `{a,b}` in the exclusion was left unexpanded, the exclusion never matched leaf `packages/{foo}/a`, and the excluded package was falsely treated as a workspace member — inheriting an ancestor's Jest config. Add _find_expandable_brace: scan left-to-right for the first two-or-more-option brace, descending into single-option groups (so `{a{b,c}}` expands its inner `{b,c}`, bash parity) and skipping past fully-literal groups (so `{foo}/{a,b}` still finds `{a,b}`). Only a pattern with no real alternation anywhere is emitted whole. All existing budgets (brace/comma/segment/cell) are unchanged and still fail closed on bombs. Regression: excluded leaf now non-member, sibling the exclusion does not name is still a member (negative control), nested-in-singleton expands. Prompt R6 updated to state the contract; meta re-synced; E2E green. Also back-propagate the shell-quoting security contract into the get_run_command and agentic_fix prompts (no meta; provenance completeness). Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_test_command.py | 61 +++++++++++++++------- pdd/prompts/agentic_fix_python.prompt | 2 +- pdd/prompts/get_run_command_python.prompt | 6 ++- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 26 +++++++++ 6 files changed, 80 insertions(+), 25 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 2e76758102..14a73dd0d6 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "81dfaf5f1e69cc08cd109cdb7821dede47933a550bdd9234550d75a0575bf2cc", - "code_hash": "726744ba760359270fbe0a39c7b4527052831f7947ac1369c639944a74aafc5b", + "prompt_hash": "d96802ab91c1ed0e8d35f7b7221354fe7b6d6a80d3d0ce453d2cde1de6f6e6d7", + "code_hash": "f64b8da66b4662858d8e55136fa81477a0717302244cd14542bd94623124ac95", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "22f4ef16391bf97733581a1fedda52808ca3bda087d2ebe46c903172becaeb11", + "test_hash": "c85dc0427f345d0b408a40a9db83510707baadaf65fdf2d95459fb8ae86b9b61", "test_files": { - "test_get_test_command.py": "22f4ef16391bf97733581a1fedda52808ca3bda087d2ebe46c903172becaeb11" + "test_get_test_command.py": "c85dc0427f345d0b408a40a9db83510707baadaf65fdf2d95459fb8ae86b9b61" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 95e015cf1a..df86088348 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -332,6 +332,45 @@ def _split_top_level_commas(body: str, limit: int) -> list: return parts +def _find_expandable_brace(pattern: str, limit: int) -> 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 ``}``. + ``_split_top_level_commas`` may raise ``_BraceBudgetError`` for a comma bomb. + """ + i, n = 0, len(pattern) + while i < n: + if pattern[i] != "{": + i += 1 + continue + depth, end = 0, -1 + for j in range(i, n): + if pattern[j] == "{": + depth += 1 + elif pattern[j] == "}": + depth -= 1 + if depth == 0: + end = j + break + if end == -1: + return None # unbalanced from here on → nothing expandable + body = pattern[i + 1:end] + if len(_split_top_level_commas(body, limit)) >= 2: + return (i, end) + inner = _find_expandable_brace(body, limit) # descend into the 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 + + def _expand_braces(pattern: str, budget: 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. @@ -361,26 +400,12 @@ def _emit(value: str) -> None: if len(worklist) > budget[0] + 1: raise _BraceBudgetError pat = worklist.pop() - start = pat.find("{") - if start == -1: - _emit(pat) - continue - depth, end = 0, -1 - for i in range(start, len(pat)): - if pat[i] == "{": - depth += 1 - elif pat[i] == "}": - depth -= 1 - if depth == 0: - end = i - break - if end == -1: - _emit(pat) # unbalanced → treat literally + span = _find_expandable_brace(pat, budget[0] + 1) + 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) - if len(options) < 2: - _emit(pat) # not a real alternation → literal - continue prefix, suffix = pat[:start], pat[end + 1:] for option in options: worklist.append(prefix + option + suffix) diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index febeb591cd..0a0935c89b 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -8,7 +8,7 @@ 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`): 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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`); a *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient 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. diff --git a/pdd/prompts/get_run_command_python.prompt b/pdd/prompts/get_run_command_python.prompt index 340c3bb2ce..e20391c1cc 100644 --- a/pdd/prompts/get_run_command_python.prompt +++ b/pdd/prompts/get_run_command_python.prompt @@ -22,7 +22,11 @@ - 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`. + - Replaces the `{file}` placeholder in the template with the **shell-quoted** + `file_path` (`shlex.quote`). 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. % Data Source diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index ff110c6da3..1b564478f8 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index a7278b36cf..8b3184bd9d 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -743,6 +743,32 @@ def test_normal_brace_within_budget_still_matches(self): 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_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_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" From 3fa8affa3ea19655812b3ce36f1d4814ec255796 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 15:16:45 -0700 Subject: [PATCH 22/56] fix(get_test_command): unmatched brace must not short-circuit later alternation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-14 review (Codex gpt-5.6-sol xhigh) found _find_expandable_brace returned None on the first *unbalanced* `{` (no matching `}`), so a later balanced alternation was never reached — the same short-circuit class as round 13, but for an unmatched brace. With globs ["packages/**", "!packages/{foo/{a,b}"] the `{a,b}` in the exclusion never expanded, the exclusion never matched leaf `packages/{foo/a`, and the excluded package was falsely proven a workspace member (inheriting an ancestor's Jest config). This contradicts prompt R6, which already requires an unbalanced brace to be literal without short-circuiting. Treat an unmatched `{` as a literal single character and keep scanning (`i += 1; continue`) for a later expandable group, matching bash (`echo packages/{foo/{a,b}` -> `packages/{foo/a packages/{foo/b`). Only a pattern with no real alternation anywhere is emitted whole. Regression: excluded leaves `a`/`b` non-members, unnamed sibling `c` still a member (negative control), trailing lone `{` stays literal. Docstring updated; meta re-synced (code+test hashes); E2E green; lint 9.72 (no regression). Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 6 +++--- pdd/get_test_command.py | 9 +++++++-- tests/test_get_test_command.py | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 14a73dd0d6..d9ab357be0 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -3,11 +3,11 @@ "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", "prompt_hash": "d96802ab91c1ed0e8d35f7b7221354fe7b6d6a80d3d0ce453d2cde1de6f6e6d7", - "code_hash": "f64b8da66b4662858d8e55136fa81477a0717302244cd14542bd94623124ac95", + "code_hash": "63c16cf27abd50719bcdd9a32ca3abbd48662ff86fe1ee2885e5c0f38d2bf241", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "c85dc0427f345d0b408a40a9db83510707baadaf65fdf2d95459fb8ae86b9b61", + "test_hash": "175579df3ef8baaabaed74425b54dcf9f0c36d214f1ab99fd8140b561e4cab0a", "test_files": { - "test_get_test_command.py": "c85dc0427f345d0b408a40a9db83510707baadaf65fdf2d95459fb8ae86b9b61" + "test_get_test_command.py": "175579df3ef8baaabaed74425b54dcf9f0c36d214f1ab99fd8140b561e4cab0a" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index df86088348..aeb210af60 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -343,7 +343,11 @@ def _find_expandable_brace(pattern: str, limit: int) -> Optional[Tuple[int, int] 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 ``}``. - ``_split_top_level_commas`` may raise ``_BraceBudgetError`` for a comma bomb. + + 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. """ i, n = 0, len(pattern) while i < n: @@ -360,7 +364,8 @@ def _find_expandable_brace(pattern: str, limit: int) -> Optional[Tuple[int, int] end = j break if end == -1: - return None # unbalanced from here on → nothing expandable + 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) diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 8b3184bd9d..1861e3a75f 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -760,6 +760,23 @@ def test_brace_after_single_option_group_still_expands(self): # ...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 From 7390ccd76ec9c8b6542a0564da92a3c5d5a72a95 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 15:36:16 -0700 Subject: [PATCH 23/56] fix(get_test_command): escape-aware fail-closed + bounded brace-scan work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-15 review (Codex gpt-5.6-sol xhigh) found two medium defects in the brace expander: R15-1 (correctness): _split_top_level_commas is not escape-aware, so a workspace glob like "packages/{foo\,bar,baz}" — where `\,` is a literal comma and thus two options, not three — over-expanded to include "packages/bar", falsely proving an independent leaf a workspace member and letting it adopt an ancestor's Jest config. Skipping an individual escaped glob would be unsafe for exclusions (a misparsed `!` glob could fail to exclude → false member), so any glob set containing a backslash now fails membership closed. Matches prompt R6's new escape clause. R15-2 (DoS): _find_expandable_brace recursively rescans each nested singleton on every worklist entry, so a 861-char glob ("{"*400 + x + "}"*400 + "/{a,b}"*10) — within every byte/count/segment budget and producing exactly 1024 expansions — cost 24.8s of pure scanning. Add an aggregate _MAX_BRACE_SCAN_WORK budget charging every scanned character, shared across the whole discovery walk (like the DP-cell budget), raising _BraceBudgetError so such a pattern fails closed (now ~0.9s). Realistic globs cost <200K scans — ~40x under budget; a 500-option alternation still expands and matches in ~10ms. Regression: escaped-comma positive/exclusion sets non-member; deep-nested singleton time-bounded; large legit brace still resolves members. Prompt R6/R9 updated; meta re-synced (prompt+code+test); E2E green; lint 9.73. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_test_command.py | 73 ++++++++++++++++++---- pdd/prompts/get_test_command_python.prompt | 4 +- tests/test_get_test_command.py | 35 +++++++++++ 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index d9ab357be0..f28cb2da50 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "d96802ab91c1ed0e8d35f7b7221354fe7b6d6a80d3d0ce453d2cde1de6f6e6d7", - "code_hash": "63c16cf27abd50719bcdd9a32ca3abbd48662ff86fe1ee2885e5c0f38d2bf241", + "prompt_hash": "47f115c1db454279e3bb86d59116f7f3ca9ce74c0e6d22966136ec6cb1734cdb", + "code_hash": "4822efcd647a731a979a92a8f464554be2785c5fd3e293d0124527bef75d4024", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "175579df3ef8baaabaed74425b54dcf9f0c36d214f1ab99fd8140b561e4cab0a", + "test_hash": "6b12603c828895d0d085421049327fb46c080083a3f8e299fb69aa516c0bbdfc", "test_files": { - "test_get_test_command.py": "175579df3ef8baaabaed74425b54dcf9f0c36d214f1ab99fd8140b561e4cab0a" + "test_get_test_command.py": "6b12603c828895d0d085421049327fb46c080083a3f8e299fb69aa516c0bbdfc" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index aeb210af60..e8144eb45f 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -73,6 +73,16 @@ # 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 + 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 @@ -332,7 +342,8 @@ def _split_top_level_commas(body: str, limit: int) -> list: return parts -def _find_expandable_brace(pattern: str, limit: int) -> Optional[Tuple[int, int]]: +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. @@ -348,7 +359,14 @@ def _find_expandable_brace(pattern: str, limit: int) -> Optional[Tuple[int, int] 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: if pattern[i] != "{": @@ -356,6 +374,9 @@ def _find_expandable_brace(pattern: str, limit: int) -> Optional[Tuple[int, int] 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] == "}": @@ -369,14 +390,15 @@ def _find_expandable_brace(pattern: str, limit: int) -> Optional[Tuple[int, int] body = pattern[i + 1:end] if len(_split_top_level_commas(body, limit)) >= 2: return (i, end) - inner = _find_expandable_brace(body, limit) # descend into the singleton + 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 -def _expand_braces(pattern: str, budget: Optional[list] = None) -> list: +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. @@ -384,7 +406,10 @@ def _expand_braces(pattern: str, budget: Optional[list] = None) -> list: ``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. Any + 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, @@ -392,6 +417,8 @@ def _expand_braces(pattern: str, budget: Optional[list] = None) -> list: """ if budget is None: budget = [_MAX_BRACE_EXPANSION] + if work is None: + work = [_MAX_BRACE_SCAN_WORK] def _emit(value: str) -> None: budget[0] -= 1 @@ -405,7 +432,7 @@ def _emit(value: str) -> None: if len(worklist) > budget[0] + 1: raise _BraceBudgetError pat = worklist.pop() - span = _find_expandable_brace(pat, budget[0] + 1) + span = _find_expandable_brace(pat, budget[0] + 1, work) if span is None: _emit(pat) # no real alternation remains → terminal (literal braces) continue @@ -418,19 +445,26 @@ def _emit(value: str) -> None: def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, - cell_budget: Optional[list] = None) -> bool: + cell_budget: Optional[list] = None, + work_budget: Optional[list] = None) -> bool: """Return True when ``rel_parts`` matches the workspace globs' include/exclude semantics: at least one positive pattern matches and no ``!`` exclusion does. Exclusions (a leading ``!``, e.g. pnpm's ``!**/test/**``) are honored, and brace alternations are expanded before matching. All expansion for one membership check shares a single aggregate budget, so a declaration with many - globs cannot multiply past the budget. + globs cannot multiply past the budget. ``work_budget`` (the brace-scan budget) + is likewise shared across the whole discovery walk when the caller supplies + one, so pathological globs at several ancestors cannot each spend a full + budget's worth of scanning. """ if len(globs) > _MAX_RAW_GLOBS: return False # hostile declaration size → membership unproven (fail closed) try: budget = [_MAX_BRACE_EXPANSION] # shared across every glob below + # 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); @@ -445,14 +479,22 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, raw = str(raw) if not raw: continue + if "\\" in raw: + # A backslash escapes brace metacharacters in minimatch/brace + # expansion (e.g. `{foo\,bar,baz}` is two options, not three). This + # expander is not escape-aware, so an escaped glob would over-expand + # and could falsely prove membership — or, in an exclusion, fail to + # exclude. Rather than guess, treat the whole set as unparseable so + # membership is unproven (fail closed), never falsely proven. + raise _PatternBudgetError if len(raw) > _MAX_GLOB_LENGTH: # An over-long glob would blow up expansion by bytes even under # the count budget → membership unproven (fail closed). raise _PatternBudgetError if raw.startswith("!"): - negatives.extend(_expand_braces(raw[1:], budget)) + negatives.extend(_expand_braces(raw[1:], budget, work)) else: - positives.extend(_expand_braces(raw, budget)) + positives.extend(_expand_braces(raw, budget, work)) if not any(_relative_matches_workspace_glob(rel_parts, p, cells) for p in positives): return False return not any(_relative_matches_workspace_glob(rel_parts, n, cells) for n in negatives) @@ -464,7 +506,8 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None, - cell_budget: Optional[list] = None) -> Optional[Path]: + cell_budget: Optional[list] = None, + work_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. @@ -491,7 +534,8 @@ def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None, 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): + if rel_parts and _package_matches_workspace( + rel_parts, globs, cell_budget, work_budget): return ancestor if (ancestor / ".git").exists(): break @@ -716,6 +760,10 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: # 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] # Deepest ancestor-workspace root the original leaf must still reach; walk # *through* intermediate independent package.json manifests until then. ceiling: Optional[Path] = None @@ -736,7 +784,8 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: # 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) + member_root = _workspace_root_for( + search_dir, ws_cache, match_cells, brace_work) 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 diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 1b564478f8..a2fd5bd1e8 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,13 +26,13 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. -R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, and glob matching (including many `**` segments) MUST be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Budgets are aggregate across one membership check, not merely per glob. +R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 1861e3a75f..d429c62ee8 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -786,6 +786,41 @@ def test_nested_brace_inside_single_option_group_expands(self): "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_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_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" From bb2f2b72bba3b5fc45938e17a35d9a266db3838f Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 15:51:10 -0700 Subject: [PATCH 24/56] fix(get_test_command): bracket character-class globs fail membership closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-16 review (Codex gpt-5.6-sol xhigh) found the per-segment fnmatch matcher diverges from minimatch on bracket character classes. With workspaces ["packages/[^a]"], minimatch's [^a] negates the class (match any char but `a`), but Python fnmatch treats `^` as a literal member, so the code matched `packages/a` (should be excluded) and rejected `packages/b` (should match) — falsely proving `packages/a` a workspace member and adopting an unrelated ancestor Jest config. POSIX classes like [[:alpha:]] are likewise unsupported by fnmatch. Fail membership closed on any glob containing `[` (the class opener), the same single-boundary treatment already used for backslash escapes. The supported glob language is now exactly literal / `*` / `**` / `?` / `{,}`; anything else is unproven and never adopts a foreign config (the leaf just uses its nearest package.json). A bracket in a *path segment* (a Next.js dynamic-route dir like `[eventId]`) is literal data, not a glob metacharacter, and still matches an ordinary `*` glob — the E2E dynamic-route case is unaffected. Regression: `[^a]` positive/exclusion and `[[:alpha:]]`/`[a-z]` fail closed; `[eventId]` path still matches `packages/*`; `*`/`{,}`/`?`/backslash behavior unchanged. Prompt R6 updated; meta re-synced (prompt+code+test); E2E green; lint 9.73. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 ++++---- pdd/get_test_command.py | 23 +++++++++++++++------- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 21 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index f28cb2da50..2d0d944ce4 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "47f115c1db454279e3bb86d59116f7f3ca9ce74c0e6d22966136ec6cb1734cdb", - "code_hash": "4822efcd647a731a979a92a8f464554be2785c5fd3e293d0124527bef75d4024", + "prompt_hash": "b015b403a834fa0c26f021f1823f36378ce4f55e822dd8f46606a3b1170289e7", + "code_hash": "5d1396d8387ae8ced1dcbbfa76cbf424029085eb34ee4b9fcd1e13c1170b2db7", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "6b12603c828895d0d085421049327fb46c080083a3f8e299fb69aa516c0bbdfc", + "test_hash": "0488adb4382b4082c0bac5bd4fcfe81c0b7dc12c72da44816483487ca2942ef2", "test_files": { - "test_get_test_command.py": "6b12603c828895d0d085421049327fb46c080083a3f8e299fb69aa516c0bbdfc" + "test_get_test_command.py": "0488adb4382b4082c0bac5bd4fcfe81c0b7dc12c72da44816483487ca2942ef2" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index e8144eb45f..c82bcb05f8 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -479,13 +479,22 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, raw = str(raw) if not raw: continue - if "\\" in raw: - # A backslash escapes brace metacharacters in minimatch/brace - # expansion (e.g. `{foo\,bar,baz}` is two options, not three). This - # expander is not escape-aware, so an escaped glob would over-expand - # and could falsely prove membership — or, in an exclusion, fail to - # exclude. Rather than guess, treat the whole set as unparseable so - # membership is unproven (fail closed), never falsely proven. + if "\\" in raw or "[" in raw: + # Two constructs this matcher does not implement with minimatch + # parity, so a glob using either is treated as unparseable and the + # whole set fails membership closed (never falsely proven): + # * Backslash escapes of brace metacharacters (e.g. `{foo\,bar}`, + # where `\,` is a literal comma → two options, not three) — the + # brace expander is not escape-aware and would over-expand. + # * Bracket character classes (e.g. `[^a]`, `[[:alpha:]]`) — the + # per-segment `fnmatch` diverges from minimatch (`^` is literal + # in fnmatch but negates in minimatch; POSIX classes are + # unsupported), so an exclusion could fail to exclude and a + # positive could over-match, either way a false member. + # 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. raise _PatternBudgetError if len(raw) > _MAX_GLOB_LENGTH: # An over-long glob would blow up expansion by bytes even under diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index a2fd5bd1e8..ff3dc13494 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`) MUST NOT silently over-match a positive or under-match an exclusion. Note a bracket appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index d429c62ee8..0183039bff 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -803,6 +803,27 @@ def test_backslash_escaped_glob_fails_membership_closed(self): 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 + 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 From 2e7a18772cd2e7679835b5ea2399666fd9f2a262 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 15:54:22 -0700 Subject: [PATCH 25/56] harden(get_test_command): extglob workspace globs fail membership closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proactively close the last minimatch-parity gap in the same class as rounds 15-16 (backslash escapes, bracket character classes). minimatch expands extglobs — ?(…), *(…), +(…), @(…), !(…) — but the per-segment fnmatch matcher treats them literally, so an extglob *exclusion* under-matches: e.g. with ["packages/*", "!packages/@(foo|bar)"], packages/foo is NOT excluded (fnmatch compares the literal string "@(foo|bar)") and is falsely proven a workspace member, adopting an unrelated ancestor Jest config. Verified by direct probe. Extend the existing single-boundary fail-closed guard in _package_matches_workspace to also reject any glob containing an extglob prefix (_EXTGLOB_MARKERS). The supported glob language is now exactly literal / * / ** / ? / {,}; backslash, bracket classes, and extglobs all fail closed (leaf uses its nearest package.json, never a foreign config). Bare `?`/`*` wildcards and `@`-scoped-style path segments without `(` are unaffected. Regression added; prompt R6 updated; meta re-synced (prompt+code+test); E2E green; get_test_command suite 133 passed; lint 9.73. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 ++++---- pdd/get_test_command.py | 19 +++++++++++++++---- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 17 +++++++++++++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 2d0d944ce4..52942248fb 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "b015b403a834fa0c26f021f1823f36378ce4f55e822dd8f46606a3b1170289e7", - "code_hash": "5d1396d8387ae8ced1dcbbfa76cbf424029085eb34ee4b9fcd1e13c1170b2db7", + "prompt_hash": "1563af03d353b7afdc32fa396f18c8b736404a0a5426b16631ade29d004540b9", + "code_hash": "781b8a97247d0c1277b7394146130877867d1b25d3b9a02fcfecc817edf2a460", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "0488adb4382b4082c0bac5bd4fcfe81c0b7dc12c72da44816483487ca2942ef2", + "test_hash": "a349fcea0ca0c16782b0273830d71ad3deec6e660a58e673e922a81a2412c5a0", "test_files": { - "test_get_test_command.py": "0488adb4382b4082c0bac5bd4fcfe81c0b7dc12c72da44816483487ca2942ef2" + "test_get_test_command.py": "a349fcea0ca0c16782b0273830d71ad3deec6e660a58e673e922a81a2412c5a0" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index c82bcb05f8..7b5ddf74ba 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -83,6 +83,12 @@ # expand to a handful of short patterns — orders of magnitude under this bound. _MAX_BRACE_SCAN_WORK = 8_000_000 +# Extglob prefixes (``?(``/``*(``/``+(``/``@(``/``!(``). minimatch expands these +# but the per-segment ``fnmatch`` matcher treats them literally, so a workspace +# glob using one fails membership closed rather than under-/over-matching. See +# the guard in ``_package_matches_workspace``. +_EXTGLOB_MARKERS = ("?(", "*(", "+(", "@(", "!(") + 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 @@ -479,10 +485,11 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, raw = str(raw) if not raw: continue - if "\\" in raw or "[" in raw: - # Two constructs this matcher does not implement with minimatch - # parity, so a glob using either is treated as unparseable and the - # whole set fails membership closed (never falsely proven): + if "\\" in raw or "[" in raw or any(m in raw for m in _EXTGLOB_MARKERS): + # Three construct families this matcher does not implement with + # minimatch parity, so a glob using any of them is treated as + # unparseable and the whole set fails membership closed (never + # falsely proven): # * Backslash escapes of brace metacharacters (e.g. `{foo\,bar}`, # where `\,` is a literal comma → two options, not three) — the # brace expander is not escape-aware and would over-expand. @@ -491,6 +498,10 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # in fnmatch but negates in minimatch; POSIX classes are # unsupported), so an exclusion could fail to exclude and a # positive could over-match, either way a false member. + # * Extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`) — `fnmatch` + # treats them literally, so an extglob exclusion under-matches + # (e.g. `!packages/@(foo|bar)` fails to exclude `packages/foo`) + # and yields a false member. # 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 diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index ff3dc13494..8e69dc2ca4 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`) MUST NOT silently over-match a positive or under-match an exclusion. Note a bracket appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`) and extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`). Note a bracket appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 0183039bff..93e3e7032a 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -824,6 +824,23 @@ def test_bracket_character_class_glob_fails_membership_closed(self): # glob metacharacter and still matches an ordinary `*` glob. assert _package_matches_workspace(("packages", "[eventId]"), ["packages/*"]) 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 From 86784715f9042ba788cbfa8570e3ef9f68d30ca4 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 16:14:56 -0700 Subject: [PATCH 26/56] fix(get_test_command): close 4 minimatch-parity/DoS edges (round-17 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-17 review (Codex gpt-5.6-sol xhigh) found four medium defects: F1 (DoS): _find_expandable_brace charged the inner brace scan but NOT the non-brace prefix skipped by the outer loop, so a long `*`/literal run before the first alternation was re-walked free for each of up to 1024 worklist entries, at every ancestor of a deep walk. A 4,043-char supported glob across 80 boundaries took 23.65s within all budgets. Now every examined position is charged, and the brace-expansion COUNT budget is threaded discovery-wide (like the scan-work and DP-cell budgets) via _detect_ts_test_runner → _workspace_root_for → _package_matches_workspace. The 80-boundary case now fails closed in ~0.6s. F2 (brace ranges): {1..3}/{a..c}/{01..03}/{1..9..2} contain no comma, so the comma-only expander emitted them literally and an exclusion range failed to exclude → false member. Any glob containing `..` now fails membership closed. F3 (internal dot): the segment split stripped ALL `.` segments, so `packages/./x` collapsed to `packages/x` and falsely matched. Internal `.` is now preserved (only a leading `./` is normalized), so `packages/./*` no longer matches `packages/app`. F4 (astral ?): fnmatch counts a non-BMP char as one code point but minimatch counts UTF-16 code units, so `?` matched an emoji where minimatch needs `??`. When the leaf holds an astral character, any `?` glob now fails closed. `*` and BMP `?` are unaffected. The unsupported-construct checks are consolidated into a single auditable helper (_glob_beyond_supported_subset): backslash, brackets, `..`, extglobs, and astral-`?` all fail closed. Supported language stays literal / * / ** / ? / {,}. Regressions added for all four (incl. deterministic prefix-charge + shared-budget deep-walk). Prompt R6 updated; meta re-synced; E2E green; 137 get_test_command + 52 other tests pass; lint 9.74. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_test_command.py | 106 ++++++++++++++------- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 68 +++++++++++++ 4 files changed, 146 insertions(+), 38 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 52942248fb..0720364610 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "1563af03d353b7afdc32fa396f18c8b736404a0a5426b16631ade29d004540b9", - "code_hash": "781b8a97247d0c1277b7394146130877867d1b25d3b9a02fcfecc817edf2a460", + "prompt_hash": "615dbd3f7237c5cf397b136c1b7069eaa0a950d3e720830bbb8b156601c5f721", + "code_hash": "26669e3c22a36b369476466fa4d8652042cf003e4755d3a9b5e0b7f5e4170645", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "a349fcea0ca0c16782b0273830d71ad3deec6e660a58e673e922a81a2412c5a0", + "test_hash": "9f934f67ed6d015f4fc0237bbd28e8e68ebd24992599a0ed7b853e0d2ea608c7", "test_files": { - "test_get_test_command.py": "a349fcea0ca0c16782b0273830d71ad3deec6e660a58e673e922a81a2412c5a0" + "test_get_test_command.py": "9f934f67ed6d015f4fc0237bbd28e8e68ebd24992599a0ed7b853e0d2ea608c7" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 7b5ddf74ba..45e4034aaa 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -90,6 +90,38 @@ _EXTGLOB_MARKERS = ("?(", "*(", "+(", "@(", "!(") +def _glob_beyond_supported_subset(raw: str, has_astral: bool) -> bool: + """Return True when ``raw`` uses a minimatch construct this matcher does not + implement with faithful parity, so the whole membership check must fail closed. + + The supported glob language is exactly literal characters, ``*`` (one segment), + ``**`` (any depth), ``?`` (one character), and ``{a,b}`` brace alternation. + Everything below is rejected because ``fnmatch``/this expander would diverge — + over-matching a positive or under-matching an exclusion into a false member: + + * ``\\`` — backslash escapes of brace metacharacters (expander is not + escape-aware; ``{foo\\,bar}`` is two options, not three). + * ``[`` — bracket character classes (``[^a]`` negates in minimatch but + ``^`` is literal in ``fnmatch``; POSIX ``[[:alpha:]]`` is unsupported). + * ``..`` — brace ranges (``{1..3}``, ``{a..c}``, ``{01..03}``, ``{1..9..2}``); + no legitimate workspace path holds ``..`` either. + * extglobs (``?(…)``/``*(…)``/``+(…)``/``@(…)``/``!(…)``) — ``fnmatch`` treats + them literally, so an extglob exclusion fails to exclude. + * ``?`` against an astral-character leaf — minimatch counts a non-BMP char as + two UTF-16 code units, so it needs ``??`` where ``fnmatch`` matches one ``?``. + + 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. + """ + if "\\" in raw or "[" in raw or ".." in raw: + return True + if any(marker in raw for marker in _EXTGLOB_MARKERS): + return True + return has_astral and "?" in raw + + 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 @@ -161,7 +193,13 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, # Cheap guard before allocating the split list (a "slash wall" attack). if pattern.count("/") > _MAX_GLOB_SEGMENTS: raise _PatternBudgetError - pat_parts = [p for p in pattern.strip("/").split("/") if p not in ("", ".")] + # Drop empty segments (from ``//`` or a trailing ``/``) and a *leading* ``./`` + # (npm normalizes a leading current-dir). An *internal* ``.`` segment is kept: + # minimatch does not collapse ``packages/./x``, so such a glob must not be + # treated as ``packages/x`` and falsely prove membership. + pat_parts = [p for p in pattern.strip("/").split("/") if p != ""] + while pat_parts and pat_parts[0] == ".": + pat_parts.pop(0) if len(pat_parts) > _MAX_GLOB_SEGMENTS: raise _PatternBudgetError rel = list(rel_parts) @@ -375,6 +413,12 @@ def _find_expandable_brace(pattern: str, limit: int, 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 @@ -452,22 +496,25 @@ def _emit(value: str) -> None: def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, cell_budget: Optional[list] = None, - work_budget: Optional[list] = None) -> bool: + work_budget: Optional[list] = None, + expand_budget: Optional[list] = None) -> bool: """Return True when ``rel_parts`` matches the workspace globs' include/exclude semantics: at least one positive pattern matches and no ``!`` exclusion does. Exclusions (a leading ``!``, e.g. pnpm's ``!**/test/**``) are honored, and - brace alternations are expanded before matching. All expansion for one - membership check shares a single aggregate budget, so a declaration with many - globs cannot multiply past the budget. ``work_budget`` (the brace-scan budget) - is likewise shared across the whole discovery walk when the caller supplies - one, so pathological globs at several ancestors cannot each spend a full - budget's worth of scanning. + brace alternations are expanded before matching. 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: - budget = [_MAX_BRACE_EXPANSION] # shared across every glob below + # 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] @@ -477,6 +524,13 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # otherwise a fresh per-call budget is used (direct/standalone callers). cells = cell_budget if cell_budget is not None else [_MAX_MATCH_CELLS] positives, negatives = [], [] + # minimatch (a JS regex) counts a string in UTF-16 code units, but Python + # `fnmatch` counts Unicode code points, so `?` (one unit) diverges from `?` + # (one code point) whenever a path segment holds a non-BMP / "astral" + # character (e.g. an emoji, two UTF-16 units): mine matches a single `?` + # where minimatch needs `??`. When the leaf has such a character, any `?` + # glob therefore fails membership closed. + has_astral = any(ord(ch) > 0xFFFF for part in rel_parts for ch in part) for raw in globs: # Do NOT strip surrounding whitespace: workspace tools treat it # literally, so `" packages/* "` is a package literally named with @@ -485,27 +539,8 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, raw = str(raw) if not raw: continue - if "\\" in raw or "[" in raw or any(m in raw for m in _EXTGLOB_MARKERS): - # Three construct families this matcher does not implement with - # minimatch parity, so a glob using any of them is treated as - # unparseable and the whole set fails membership closed (never - # falsely proven): - # * Backslash escapes of brace metacharacters (e.g. `{foo\,bar}`, - # where `\,` is a literal comma → two options, not three) — the - # brace expander is not escape-aware and would over-expand. - # * Bracket character classes (e.g. `[^a]`, `[[:alpha:]]`) — the - # per-segment `fnmatch` diverges from minimatch (`^` is literal - # in fnmatch but negates in minimatch; POSIX classes are - # unsupported), so an exclusion could fail to exclude and a - # positive could over-match, either way a false member. - # * Extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`) — `fnmatch` - # treats them literally, so an extglob exclusion under-matches - # (e.g. `!packages/@(foo|bar)` fails to exclude `packages/foo`) - # and yields a false member. - # 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. + if _glob_beyond_supported_subset(raw, has_astral): + # Unsupported minimatch construct → membership unproven (fail closed). raise _PatternBudgetError if len(raw) > _MAX_GLOB_LENGTH: # An over-long glob would blow up expansion by bytes even under @@ -527,7 +562,8 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None, cell_budget: Optional[list] = None, - work_budget: Optional[list] = None) -> Optional[Path]: + 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. @@ -555,7 +591,7 @@ def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None, except (ValueError, OSError, RuntimeError): rel_parts = () if rel_parts and _package_matches_workspace( - rel_parts, globs, cell_budget, work_budget): + rel_parts, globs, cell_budget, work_budget, expand_budget): return ancestor if (ancestor / ".git").exists(): break @@ -784,6 +820,10 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: # 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 @@ -805,7 +845,7 @@ def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: 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) + 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 diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 8e69dc2ca4..b107c27b85 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`) and extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`). Note a bracket appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`) extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally), brace ranges (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}` — comma-only expanders emit these literally), and `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where `fnmatch` matches one `?`) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). Note a bracket appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`), though a *leading* `./` may be normalized away. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 93e3e7032a..3821cbebbc 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -20,6 +20,10 @@ _PatternBudgetError, _relative_matches_workspace_glob, _lexical_repo_root, + _find_expandable_brace, + _MAX_BRACE_SCAN_WORK, + _MAX_MATCH_CELLS, + _MAX_BRACE_EXPANSION, ) @@ -859,6 +863,70 @@ def test_scan_budget_does_not_reject_large_legitimate_brace(self): 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_against_astral_character_fails_membership_closed(self): + """``fnmatch`` counts a non-BMP (astral) character as one code point, but + minimatch (a JS regex) counts it as two UTF-16 code units, so a single ``?`` + matches an emoji here where minimatch needs ``??``. When the leaf holds an + astral character, any ``?`` glob fails membership closed rather than falsely + match; ``*`` (which spans the whole segment) and BMP ``?`` are unaffected.""" + emoji = "\U0001F600" + assert _package_matches_workspace(("packages", emoji), ["packages/?"]) is False + assert _package_matches_workspace(("packages", emoji), ["packages/??"]) is False + assert _package_matches_workspace( + ("packages", emoji), ["packages/*", "!packages/?"]) is False + # `*` 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_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" From c62688b8e430cb9684c58ef45067b989a1cadda8 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 16:36:31 -0700 Subject: [PATCH 27/56] fix(get_test_command): refine glob fail-closed boundary (round-18 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-18 review (Codex gpt-5.6-sol xhigh) found two false-positive gaps and two over-broad fail-closed rejections: F1 (medium): multiple leading `!` (minimatch toggles negation: `!!x` positive, `!!!x` negated) were mishandled — only one `!` was stripped, so `!!!packages/foo` tested a literal `!!packages/foo`, never excluded `packages/foo`, and falsely proved membership. Two-or-more leading `!` now fail membership closed. F2 (medium): a positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment (matches nothing), but it was fnmatch-ed literally so `#*` falsely matched a leaf `#foo`. Such entries are now skipped (not fail-closed, so a comment does not disable the rest of the set). F3/F4 (low): the unconditional `..` and `[` checks were too broad, rejecting legitimate literal globs. An *unmatched* `[` and a literal `..` *outside* a brace are literal in both fnmatch and minimatch, so `packages/foo[bar` and `packages/foo..bar` must still match. Detection is now precise: _has_complete_bracket_class (a closed `[...]`) and _has_brace_range (`..` at brace depth > 0) — closed classes and in-brace ranges still fail closed; the earlier `[^a]`/`{1..3}` cases are unchanged. All prior fail-closed guards (backslash, closed classes, in-brace ranges, extglobs, astral-`?`) re-verified. Regressions added for all four. Prompt R6 updated; meta re-synced; E2E green; 140 get_test_command + 52 other tests pass; lint 9.73. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_test_command.py | 71 +++++++++++++++++++--- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 43 +++++++++++++ 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 0720364610..287ddec74a 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "615dbd3f7237c5cf397b136c1b7069eaa0a950d3e720830bbb8b156601c5f721", - "code_hash": "26669e3c22a36b369476466fa4d8652042cf003e4755d3a9b5e0b7f5e4170645", + "prompt_hash": "082241d203356679df9cb4f1c3c1c7e0a30186dd8427ce7bb9b900b19c8de077", + "code_hash": "87fc39bcc15b89fd3516d3b742750a3600635abed2f198fb1a2d449ac122efe2", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "9f934f67ed6d015f4fc0237bbd28e8e68ebd24992599a0ed7b853e0d2ea608c7", + "test_hash": "79d6a4b0ec05a0e706f1fa5befe2bb52eeb94eb02e33efa270b7b318a3577d31", "test_files": { - "test_get_test_command.py": "9f934f67ed6d015f4fc0237bbd28e8e68ebd24992599a0ed7b853e0d2ea608c7" + "test_get_test_command.py": "79d6a4b0ec05a0e706f1fa5befe2bb52eeb94eb02e33efa270b7b318a3577d31" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 45e4034aaa..b30027266a 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -90,6 +90,34 @@ _EXTGLOB_MARKERS = ("?(", "*(", "+(", "@(", "!(") +def _has_complete_bracket_class(raw: str) -> bool: + """True when ``raw`` contains a *closed* ``[...]`` bracket group. An unmatched + ``[`` (no later ``]``) is literal in both minimatch and ``fnmatch``, so it is + NOT flagged — only a syntactically complete class, whose ``[^…]`` negation and + POSIX ``[[:…:]]`` semantics diverge between the two, is.""" + start = raw.find("[") + return start != -1 and raw.find("]", start + 1) != -1 + + +def _has_brace_range(raw: str) -> bool: + """True when ``raw`` contains a ``..`` *inside a brace group* — i.e. minimatch + numeric/alphabetic range syntax (``{1..3}``, ``{a..c}``, ``{1..9..2}``) that a + comma-only expander would emit literally. A literal ``..`` outside braces (a + package dir named ``foo..bar``) is matched literally by ``fnmatch`` and is NOT + flagged.""" + depth = 0 + prev = "" + for ch in raw: + if ch == "{": + depth += 1 + elif ch == "}": + depth = max(0, depth - 1) + elif ch == "." and prev == "." and depth > 0: + return True + prev = ch + return False + + def _glob_beyond_supported_subset(raw: str, has_astral: bool) -> bool: """Return True when ``raw`` uses a minimatch construct this matcher does not implement with faithful parity, so the whole membership check must fail closed. @@ -99,12 +127,14 @@ def _glob_beyond_supported_subset(raw: str, has_astral: bool) -> bool: Everything below is rejected because ``fnmatch``/this expander would diverge — over-matching a positive or under-matching an exclusion into a false member: - * ``\\`` — backslash escapes of brace metacharacters (expander is not + * ``\\`` — backslash escapes of brace metacharacters (expander is not escape-aware; ``{foo\\,bar}`` is two options, not three). - * ``[`` — bracket character classes (``[^a]`` negates in minimatch but - ``^`` is literal in ``fnmatch``; POSIX ``[[:alpha:]]`` is unsupported). - * ``..`` — brace ranges (``{1..3}``, ``{a..c}``, ``{01..03}``, ``{1..9..2}``); - no legitimate workspace path holds ``..`` either. + * a *closed* ``[...]`` bracket class — ``[^a]`` negates in minimatch but ``^`` + is literal in ``fnmatch``; POSIX ``[[:alpha:]]`` is unsupported. An unmatched + literal ``[`` is fine (both treat it literally) and is NOT rejected. + * a brace *range* — ``..`` inside a brace group (``{1..3}``, ``{a..c}``, + ``{01..03}``, ``{1..9..2}``), which a comma-only expander emits literally. A + literal ``..`` outside braces (dir ``foo..bar``) is fine and is NOT rejected. * extglobs (``?(…)``/``*(…)``/``+(…)``/``@(…)``/``!(…)``) — ``fnmatch`` treats them literally, so an extglob exclusion fails to exclude. * ``?`` against an astral-character leaf — minimatch counts a non-BMP char as @@ -115,7 +145,11 @@ def _glob_beyond_supported_subset(raw: str, has_astral: bool) -> bool: globs use ``*``/``**``/``{,}`` — not these — so legitimate declarations are unaffected. """ - if "\\" in raw or "[" in raw or ".." in raw: + if "\\" in raw: + return True + if _has_complete_bracket_class(raw): + return True + if _has_brace_range(raw): return True if any(marker in raw for marker in _EXTGLOB_MARKERS): return True @@ -546,10 +580,29 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # An over-long glob would blow up expansion by bytes even under # the count budget → membership unproven (fail closed). raise _PatternBudgetError - if raw.startswith("!"): - negatives.extend(_expand_braces(raw[1:], budget, work)) + 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 *positive* pattern whose effective form (after an optional leading + # ``./``) begins with ``#`` is a minimatch comment: it matches nothing, + # so it must not be fnmatch-ed literally into a false member. Skip it. + # (A ``!``-exclusion body starting with ``#`` is left to match its literal + # ``#`` — the safe direction, since a spurious exclusion only removes a + # member, never adds one.) + if not negated: + effective = body[2:] if body.startswith("./") else body + if effective.startswith("#"): + continue + if negated: + negatives.extend(_expand_braces(body, budget, work)) else: - positives.extend(_expand_braces(raw, budget, work)) + positives.extend(_expand_braces(body, budget, work)) if not any(_relative_matches_workspace_glob(rel_parts, p, cells) for p in positives): return False return not any(_relative_matches_workspace_glob(rel_parts, n, cells) for n in negatives) diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index b107c27b85..4a283c03d7 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`) extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally), brace ranges (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}` — comma-only expanders emit these literally), and `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where `fnmatch` matches one `?`) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). Note a bracket appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`), though a *leading* `./` may be normalized away. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`), extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally), brace ranges — a `..` *inside a brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where `fnmatch` matches one `?`), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member. Conversely, fail-closed rejection MUST be precise, not over-broad: an *unmatched* `[` (no closing `]`) and a literal `..` *outside* any brace are literal in both `fnmatch` and minimatch, so a legitimate dir named `foo[bar` or `foo..bar` MUST still be matchable, not rejected. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`), though a *leading* `./` may be normalized away. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 3821cbebbc..9aa41d282d 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -827,6 +827,9 @@ def test_bracket_character_class_glob_fails_membership_closed(self): # 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)``, ``+(…)``, ``?(…)``, @@ -927,6 +930,46 @@ def test_question_mark_against_astral_character_fails_membership_closed(self): 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_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" From 09b1db85c2bde63697705e6884bbaf0f1e65b359 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 17:00:07 -0700 Subject: [PATCH 28/56] fix(get_test_command): dollar-brace + precise range/astral/empty-class handling (round-19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-19 review (Codex gpt-5.6-sol xhigh) found one false-positive and three over-broad fail-closed rejections: F1 (medium): `${foo,bar}` was brace-expanded, but minimatch's brace-expansion treats a `{` preceded by `$` as literal — so `packages/${foo,bar}` falsely proved `packages/$foo`. `_find_expandable_brace` now skips a `{` immediately preceded by `$` (continuing to scan for later real braces). F2 (medium): the `..` range guard flagged EVERY `..` inside any open brace, rejecting legitimate comma alternations like `{foo..bar,baz}` (literal `..` in one option) and unbalanced `{foo..bar`. `_has_brace_range` now tracks per-level state and flags only a balanced, comma-less brace group containing `..` (a true range); nested ranges inside an alternation are still caught. F3 (low): the astral-`?` gate rejected any `?` glob whenever the path held an astral char anywhere. Now `_astral_question_mark_risk` checks per-aligned-segment: with no `**`, alignment is positional so only a `?` opposite an astral segment fails closed; `**` stays conservative. `packages/*/a??` now matches `packages/😀/app` (the `*` consumes the emoji). F4 (low): empty classes `[]`/`[!]`/`[^]` were treated as complete classes and rejected; both fnmatch and minimatch treat them literally. `_has_complete_bracket_class` now requires a non-empty class (content after the optional `!`/`^`). All prior guards re-verified (escape, non-empty classes, comma-less ranges, extglobs, multi-bang, #-comment, internal-dot, unmatched-[, literal-.., aligned astral-?). Astral check extracted to a helper. Prompt R6 updated; meta re-synced; E2E green; 144 get_test_command + 52 other tests pass; lint 9.75. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_test_command.py | 154 +++++++++++++++------ pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 64 +++++++++ 4 files changed, 183 insertions(+), 45 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 287ddec74a..47d2d92a4e 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "082241d203356679df9cb4f1c3c1c7e0a30186dd8427ce7bb9b900b19c8de077", - "code_hash": "87fc39bcc15b89fd3516d3b742750a3600635abed2f198fb1a2d449ac122efe2", + "prompt_hash": "503b3e125c79b12128967ddf532f6aae20605b1a305c205e935f9a6b649125de", + "code_hash": "b15f8f83097e965667832de2c322d417894f7c0fd1c8a9764cce254baff3f6b7", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "79d6a4b0ec05a0e706f1fa5befe2bb52eeb94eb02e33efa270b7b318a3577d31", + "test_hash": "6c2bbe29fc0fd9211f7315547812c102b81a927316ca49dce206b5125b94942a", "test_files": { - "test_get_test_command.py": "79d6a4b0ec05a0e706f1fa5befe2bb52eeb94eb02e33efa270b7b318a3577d31" + "test_get_test_command.py": "6c2bbe29fc0fd9211f7315547812c102b81a927316ca49dce206b5125b94942a" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index b30027266a..242e8bcdf7 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -91,34 +91,75 @@ def _has_complete_bracket_class(raw: str) -> bool: - """True when ``raw`` contains a *closed* ``[...]`` bracket group. An unmatched - ``[`` (no later ``]``) is literal in both minimatch and ``fnmatch``, so it is - NOT flagged — only a syntactically complete class, whose ``[^…]`` negation and - POSIX ``[[:…:]]`` semantics diverge between the two, is.""" - start = raw.find("[") - return start != -1 and raw.find("]", start + 1) != -1 + """True when ``raw`` contains a *closed, non-empty* ``[...]`` bracket class. Two + kinds are NOT flagged because both ``fnmatch`` and minimatch treat them + literally, so rejecting them would needlessly refuse a legitimate dir name: + + * an *unmatched* ``[`` (no later ``]``), e.g. ``foo[bar``; and + * an *empty* class ``[]``, ``[!]``, ``[^]`` (nothing between the optional + negation marker and ``]``), e.g. a dir literally named ``[]``. + + Only a class with at least one content character — whose ``[^…]`` negation and + POSIX ``[[:…:]]`` semantics diverge between ``fnmatch`` and minimatch — is + flagged for fail-closed handling.""" + i, n = 0, len(raw) + while i < n: + start = raw.find("[", i) + if start == -1: + return False + k = start + 1 + if k < n and raw[k] in "!^": # a leading negation marker is not content + k += 1 + if k < n and raw[k] == "]": # empty class ([], [!], [^]) → literal + i = start + 1 + continue + if raw.find("]", k) != -1: # a ']' closes a non-empty class + return True + i = start + 1 # unmatched '[' → literal; keep scanning + return False def _has_brace_range(raw: str) -> bool: - """True when ``raw`` contains a ``..`` *inside a brace group* — i.e. minimatch - numeric/alphabetic range syntax (``{1..3}``, ``{a..c}``, ``{1..9..2}``) that a - comma-only expander would emit literally. A literal ``..`` outside braces (a - package dir named ``foo..bar``) is matched literally by ``fnmatch`` and is NOT - flagged.""" - depth = 0 - prev = "" + """True when ``raw`` contains a minimatch numeric/alphabetic *range* — a ``..`` + inside a brace group that is NOT a comma alternation (``{1..3}``, ``{a..c}``, + ``{01..03}``, ``{1..9..2}``), which a comma-only expander emits literally. + + A ``..`` is NOT a range, and so is NOT flagged, when it is: + * outside any brace (a dir named ``foo..bar``); + * inside an *unbalanced* brace (``{foo..bar`` — literal); or + * inside a brace group that also has a top-level comma (``{foo..bar,baz}`` is + an alternation whose ``..`` is a literal part of one option — the comma + expander already handles it correctly). + + Nesting is tracked per level, so a range nested inside an alternation + (``{a,{1..3}}``) is still flagged.""" + # Each stack frame tracks, for one open brace level: whether a top-level comma + # and whether a top-level ``..`` have been seen, plus whether the previous char + # at this level was a ``.``. + stack: list = [] for ch in raw: if ch == "{": - depth += 1 + stack.append([False, False, False]) # [has_comma, has_range, prev_dot] elif ch == "}": - depth = max(0, depth - 1) - elif ch == "." and prev == "." and depth > 0: - return True - prev = ch - return False + if stack: + has_comma, has_range, _ = stack.pop() + if has_range and not has_comma: + return True # balanced brace, a ``..``, and no comma → range + elif stack: + top = stack[-1] + if ch == ",": + top[0] = True + top[2] = False + elif ch == ".": + if top[2]: + top[1] = True + top[2] = True + else: + top[2] = False + return False # an unbalanced '{' left open never closes → literal, not a range -def _glob_beyond_supported_subset(raw: str, has_astral: bool) -> bool: +def _glob_beyond_supported_subset(raw: str) -> bool: """Return True when ``raw`` uses a minimatch construct this matcher does not implement with faithful parity, so the whole membership check must fail closed. @@ -129,16 +170,21 @@ def _glob_beyond_supported_subset(raw: str, has_astral: bool) -> bool: * ``\\`` — backslash escapes of brace metacharacters (expander is not escape-aware; ``{foo\\,bar}`` is two options, not three). - * a *closed* ``[...]`` bracket class — ``[^a]`` negates in minimatch but ``^`` - is literal in ``fnmatch``; POSIX ``[[:alpha:]]`` is unsupported. An unmatched - literal ``[`` is fine (both treat it literally) and is NOT rejected. - * a brace *range* — ``..`` inside a brace group (``{1..3}``, ``{a..c}``, - ``{01..03}``, ``{1..9..2}``), which a comma-only expander emits literally. A - literal ``..`` outside braces (dir ``foo..bar``) is fine and is NOT rejected. + * 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 brace group (``{1..3}``, + ``{a..c}``, ``{01..03}``, ``{1..9..2}``), which a comma-only expander emits + literally. A literal ``..`` outside braces or inside a comma alternation is + fine and is NOT rejected. * extglobs (``?(…)``/``*(…)``/``+(…)``/``@(…)``/``!(…)``) — ``fnmatch`` treats them literally, so an extglob exclusion fails to exclude. - * ``?`` against an astral-character leaf — minimatch counts a non-BMP char as - two UTF-16 code units, so it needs ``??`` where ``fnmatch`` matches one ``?``. + + (The astral ``?`` divergence — minimatch counts a non-BMP char as two UTF-16 + code units — is handled precisely per aligned segment in + ``_relative_matches_workspace_glob``, not here, so a ``?`` in a segment that + never meets an astral path segment is not needlessly rejected.) Failing closed only forgoes crossing a workspace boundary (the leaf still uses its nearest ``package.json``); it never adopts a foreign config. Real workspace @@ -151,9 +197,7 @@ def _glob_beyond_supported_subset(raw: str, has_astral: bool) -> bool: return True if _has_brace_range(raw): return True - if any(marker in raw for marker in _EXTGLOB_MARKERS): - return True - return has_astral and "?" in raw + return any(marker in raw for marker in _EXTGLOB_MARKERS) def _read_manifest_text(path: Path, max_bytes: int = _MAX_MANIFEST_BYTES) -> Optional[str]: @@ -201,6 +245,36 @@ class TestCommand: cwd: Optional[Path] = None +def _segment_has_astral(segment: str) -> bool: + """True when ``segment`` contains a non-BMP / "astral" character (code point + above U+FFFF), which minimatch counts as two UTF-16 code units.""" + return any(ord(ch) > 0xFFFF for ch in segment) + + +def _astral_question_mark_risk(pat_parts: list, rel: list) -> bool: + """True when matching ``pat_parts`` against path ``rel`` could align a ``?`` + pattern segment with an astral path segment, whose UTF-16-unit (minimatch) vs + code-point (``fnmatch``) counting diverges — so the caller must fail closed. + + The check is *precise*, not merely "a ``?`` and an astral char both appear": + * with no ``**`` the alignment is strictly positional (``*``/``?``/literal each + consume exactly one segment), so a risk exists only when the segment counts + match and a ``?`` segment sits opposite an astral segment; + * a ``**`` makes alignment flexible, so any ``?`` segment could reach an astral + segment → conservatively fail closed. + A ``?`` that can only ever meet BMP segments (e.g. ``a??`` opposite ``app`` while + ``*`` consumes the astral segment) is therefore not needlessly rejected. + """ + if not any(_segment_has_astral(seg) for seg in rel): + return False + if not any("?" in p for p in pat_parts): + return False + if "**" in pat_parts: + return True + return len(pat_parts) == len(rel) and any( + "?" in p and _segment_has_astral(r) for p, r in zip(pat_parts, rel)) + + def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, cell_budget: Optional[list] = None) -> bool: """Match a package's path segments against a single workspace glob pattern. @@ -238,6 +312,8 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, raise _PatternBudgetError rel = list(rel_parts) n, m = len(rel), len(pat_parts) + if _astral_question_mark_risk(pat_parts, rel): + raise _PatternBudgetError # `?` may meet an astral segment → fail closed # 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: @@ -453,7 +529,10 @@ def _find_expandable_brace(pattern: str, limit: int, work[0] -= 1 if work[0] < 0: raise _BraceBudgetError - if pattern[i] != "{": + if pattern[i] != "{" or (i > 0 and pattern[i - 1] == "$"): + # A ``{`` immediately preceded by ``$`` is a shell-style ``${...}`` group, + # which minimatch's brace-expansion treats as literal (never expanded). + # Skip it as a non-brace character and keep scanning for a later group. i += 1 continue depth, end = 0, -1 @@ -558,13 +637,6 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # otherwise a fresh per-call budget is used (direct/standalone callers). cells = cell_budget if cell_budget is not None else [_MAX_MATCH_CELLS] positives, negatives = [], [] - # minimatch (a JS regex) counts a string in UTF-16 code units, but Python - # `fnmatch` counts Unicode code points, so `?` (one unit) diverges from `?` - # (one code point) whenever a path segment holds a non-BMP / "astral" - # character (e.g. an emoji, two UTF-16 units): mine matches a single `?` - # where minimatch needs `??`. When the leaf has such a character, any `?` - # glob therefore fails membership closed. - has_astral = any(ord(ch) > 0xFFFF for part in rel_parts for ch in part) for raw in globs: # Do NOT strip surrounding whitespace: workspace tools treat it # literally, so `" packages/* "` is a package literally named with @@ -573,8 +645,10 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, raw = str(raw) if not raw: continue - if _glob_beyond_supported_subset(raw, has_astral): + if _glob_beyond_supported_subset(raw): # Unsupported minimatch construct → membership unproven (fail closed). + # (The astral-`?` divergence is handled per aligned segment during + # matching, in _relative_matches_workspace_glob.) raise _PatternBudgetError if len(raw) > _MAX_GLOB_LENGTH: # An over-long glob would blow up expansion by bytes even under diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 4a283c03d7..48862b6a1d 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`), extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally), brace ranges — a `..` *inside a brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where `fnmatch` matches one `?`), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member. Conversely, fail-closed rejection MUST be precise, not over-broad: an *unmatched* `[` (no closing `]`) and a literal `..` *outside* any brace are literal in both `fnmatch` and minimatch, so a legitimate dir named `foo[bar` or `foo..bar` MUST still be matchable, not rejected. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`), though a *leading* `./` may be normalized away. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`), extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally), brace ranges — a `..` *inside a brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where `fnmatch` matches one `?`), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member. A `{` immediately preceded by `$` (shell-style `${…}`) is literal in minimatch's brace-expansion and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in both `fnmatch` and minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]`; a literal `..` *outside* any brace **or inside a brace group that also has a top-level comma** (`{foo..bar,baz}` is an alternation whose `..` is a literal option part — only a *comma-less* brace range fails closed); and the astral-`?` rejection applies only to a `?` segment that can actually align with an astral path segment, not to any `?` when an astral char appears elsewhere (`packages/*/a??` still matches `packages/😀/app`). Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`), though a *leading* `./` may be normalized away. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 9aa41d282d..29a3963776 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -21,6 +21,7 @@ _relative_matches_workspace_glob, _lexical_repo_root, _find_expandable_brace, + _has_complete_bracket_class, _MAX_BRACE_SCAN_WORK, _MAX_MATCH_CELLS, _MAX_BRACE_EXPANSION, @@ -970,6 +971,69 @@ def test_literal_double_dot_and_unmatched_bracket_are_not_over_rejected(self): ("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_is_checked_per_aligned_segment(self): + """The astral-``?`` fail-closed applies only when a ``?`` segment can align + with an astral path segment — not merely because both appear somewhere. With + no ``**`` the alignment is positional, so ``packages/*/a??`` matches + ``packages/😀/app`` (``*`` consumes the emoji, ``a??`` matches ``app``).""" + emoji = "\U0001F600" + assert _package_matches_workspace( + ("packages", emoji, "app"), ["packages/*/a??"]) is True + # A `?` that actually aligns with the astral segment still fails closed. + assert _package_matches_workspace(("packages", emoji), ["packages/?"]) is False + assert _package_matches_workspace( + ("packages", "app", emoji), ["packages/app/?"]) is False + # `**` 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_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" From 62c26f6beddd5702796414f07ee003eb680d7959 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 17:22:54 -0700 Subject: [PATCH 29/56] fix(get_test_command): validate expanded patterns + opaque ${...} + length-first (round-20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-20 review (Codex gpt-5.6-sol xhigh) found three medium defects: F1 (DoS/ordering): the O(len) construct scan ran before the cheap length guard, and `_has_complete_bracket_class` rescanned the suffix for `]` per `[` (quadratic). A ~1 MiB unmatched-`[` glob under the manifest byte cap could stall discovery. The length guard now runs FIRST, and the bracket scan is a single left-to-right pass (a failed `]` search means no `]` exists later → stop). F2 (false-positive + over-rejection): construct checks ran on the RAW glob, but brace expansion can CREATE an unsupported construct from separate alternatives — `{?,x}(foo)` → `?(foo)` (an extglob fnmatch mishandles, so `a(foo)` was falsely proved a member) — or DISSOLVE an apparent one — `{[,x}]` → `[]`, `x]` (supported literals wrongly rejected). Bracket-class, extglob, and range checks now run on each fully-expanded CONCRETE pattern (`_concrete_pattern_unsupported`); only backslash (which expansion would mishandle) stays a raw-level check (`_raw_glob_unsupported`). F3 (false-positive + over-rejection): a balanced `${...}` was skipped by only one character, letting its nested `{bar,baz}` expand (`${foo,{bar,baz}}` falsely proved `${foo,bar}`); and `_has_brace_range` read a `..` inside `${1..3}` as a range (rejecting a legit literal dir). `${...}` is now opaque — the whole balanced group (nested braces included) is skipped in `_find_expandable_brace` (via `_skip_balanced_braces`) and treated as opaque in `_has_brace_range`. All prior guards re-verified (escape, closed/empty classes, comma/comma-less ranges, extglobs, multi-bang, #-comment, internal-dot, unmatched-[, literal-.., aligned astral-?, ${foo,bar}). Regressions added for all three. Prompt R6 updated; meta re-synced; E2E green; 147 get_test_command + 52 other tests pass; lint 9.74. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_test_command.py | 157 +++++++++++++-------- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 39 +++++ 4 files changed, 145 insertions(+), 61 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 47d2d92a4e..c0612dcf1b 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "503b3e125c79b12128967ddf532f6aae20605b1a305c205e935f9a6b649125de", - "code_hash": "b15f8f83097e965667832de2c322d417894f7c0fd1c8a9764cce254baff3f6b7", + "prompt_hash": "0d80c88459eae9304b71b43fc5fff12cbe6f1e0d64e3d6c1fab7e3348d5a89a4", + "code_hash": "65d0f558f501c5cc2fee763782629093153ee6f7aa42d1756c466e6446cb2bd9", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "6c2bbe29fc0fd9211f7315547812c102b81a927316ca49dce206b5125b94942a", + "test_hash": "bb8f2767fa73a36844c5ea70ebd240cbe71a86aa6c7475e07007f28803e9b275", "test_files": { - "test_get_test_command.py": "6c2bbe29fc0fd9211f7315547812c102b81a927316ca49dce206b5125b94942a" + "test_get_test_command.py": "bb8f2767fa73a36844c5ea70ebd240cbe71a86aa6c7475e07007f28803e9b275" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 242e8bcdf7..712b9e6483 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -101,21 +101,26 @@ def _has_complete_bracket_class(raw: str) -> bool: Only a class with at least one content character — whose ``[^…]`` negation and POSIX ``[[:…:]]`` semantics diverge between ``fnmatch`` and minimatch — is - flagged for fail-closed handling.""" + flagged for 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: - start = raw.find("[", i) - if start == -1: - return False - k = start + 1 - if k < n and raw[k] in "!^": # a leading negation marker is not content - k += 1 - if k < n and raw[k] == "]": # empty class ([], [!], [^]) → literal - i = start + 1 + if raw[i] != "[": + i += 1 + continue + j = i + 1 + if j < n and raw[j] in "!^": # a leading negation marker is not content + j += 1 + if j < n and raw[j] == "]": # empty class ([], [!], [^]) → literal + i = j + 1 continue - if raw.find("]", k) != -1: # a ']' closes a non-empty class + # A non-empty class needs a closing ']'. There is at most one forward search + # per string because a failure means no ']' exists at any later position. + if j < n and raw.find("]", j) != -1: return True - i = start + 1 # unmatched '[' → literal; keep scanning + return False return False @@ -132,20 +137,24 @@ def _has_brace_range(raw: str) -> bool: expander already handles it correctly). Nesting is tracked per level, so a range nested inside an alternation - (``{a,{1..3}}``) is still flagged.""" + (``{a,{1..3}}``) is still flagged. A balanced ``${...}`` group is opaque (its + ``..`` is literal, not a range), so ``${1..3}`` is NOT flagged.""" # Each stack frame tracks, for one open brace level: whether a top-level comma - # and whether a top-level ``..`` have been seen, plus whether the previous char - # at this level was a ``.``. + # and whether a top-level ``..`` have been seen, whether the previous char at + # this level was a ``.``, and whether this group (or an ancestor) is a ``${...}`` + # opaque region. stack: list = [] + prev = "" for ch in raw: if ch == "{": - stack.append([False, False, False]) # [has_comma, has_range, prev_dot] + opaque = prev == "$" or (bool(stack) and stack[-1][3]) + stack.append([False, False, False, opaque]) elif ch == "}": if stack: - has_comma, has_range, _ = stack.pop() - if has_range and not has_comma: - return True # balanced brace, a ``..``, and no comma → range - elif stack: + has_comma, has_range, _, opaque = stack.pop() + if has_range and not has_comma and not opaque: + return True # balanced, a ``..``, no comma, not opaque → range + elif stack and not stack[-1][3]: # ignore content of an opaque ${...} top = stack[-1] if ch == ",": top[0] = True @@ -156,28 +165,39 @@ def _has_brace_range(raw: str) -> bool: top[2] = True else: top[2] = False + prev = ch return False # an unbalanced '{' left open never closes → literal, not a range -def _glob_beyond_supported_subset(raw: str) -> bool: - """Return True when ``raw`` uses a minimatch construct this matcher does not - implement with faithful parity, so the whole membership check must fail closed. - - The supported glob language is exactly literal characters, ``*`` (one segment), - ``**`` (any depth), ``?`` (one character), and ``{a,b}`` brace alternation. - Everything below is rejected because ``fnmatch``/this expander would diverge — - over-matching a positive or under-matching an exclusion into a false member: +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 (expander is not + * ``\\`` — 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 + + +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 brace group (``{1..3}``, - ``{a..c}``, ``{01..03}``, ``{1..9..2}``), which a comma-only expander emits - literally. A literal ``..`` outside braces or inside a comma alternation is - fine 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. * extglobs (``?(…)``/``*(…)``/``+(…)``/``@(…)``/``!(…)``) — ``fnmatch`` treats them literally, so an extglob exclusion fails to exclude. @@ -191,13 +211,11 @@ def _glob_beyond_supported_subset(raw: str) -> bool: globs use ``*``/``**``/``{,}`` — not these — so legitimate declarations are unaffected. """ - if "\\" in raw: - return True - if _has_complete_bracket_class(raw): + if _has_complete_bracket_class(pattern): return True - if _has_brace_range(raw): + if _has_brace_range(pattern): return True - return any(marker in raw for marker in _EXTGLOB_MARKERS) + return any(marker in pattern for marker in _EXTGLOB_MARKERS) def _read_manifest_text(path: Path, max_bytes: int = _MAX_MANIFEST_BYTES) -> Optional[str]: @@ -496,6 +514,25 @@ def _split_top_level_commas(body: str, limit: int) -> list: 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), or ``len(pattern)`` when the group is unbalanced (literal to the end). + 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 n + + 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 @@ -529,12 +566,17 @@ def _find_expandable_brace(pattern: str, limit: int, work[0] -= 1 if work[0] < 0: raise _BraceBudgetError - if pattern[i] != "{" or (i > 0 and pattern[i - 1] == "$"): - # A ``{`` immediately preceded by ``$`` is a shell-style ``${...}`` group, - # which minimatch's brace-expansion treats as literal (never expanded). - # Skip it as a non-brace character and keep scanning for a later group. + if pattern[i] != "{": i += 1 continue + if i > 0 and pattern[i - 1] == "$": + # A balanced ``${...}`` is a shell-style group that minimatch's + # brace-expansion treats as fully literal (opaque) — nested braces + # included. Skip the WHOLE group (not just one character, which would let + # a nested ``{b,c}`` expand) and keep scanning for later independent + # braces. An unbalanced ``${...`` is literal to end of string. + i = _skip_balanced_braces(pattern, i, work) + continue depth, end = 0, -1 for j in range(i, n): work[0] -= 1 @@ -645,14 +687,12 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, raw = str(raw) if not raw: continue - if _glob_beyond_supported_subset(raw): - # Unsupported minimatch construct → membership unproven (fail closed). - # (The astral-`?` divergence is handled per aligned segment during - # matching, in _relative_matches_workspace_glob.) - raise _PatternBudgetError + # 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: - # An over-long glob would blow up expansion by bytes even under - # the count budget → membership unproven (fail closed). + 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 @@ -669,14 +709,19 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # (A ``!``-exclusion body starting with ``#`` is left to match its literal # ``#`` — the safe direction, since a spurious exclusion only removes a # member, never adds one.) - if not negated: - effective = body[2:] if body.startswith("./") else body - if effective.startswith("#"): - continue - if negated: - negatives.extend(_expand_braces(body, budget, work)) - else: - positives.extend(_expand_braces(body, budget, work)) + if not negated and ( + body[2:] if body.startswith("./") else body).startswith("#"): + continue + expanded = _expand_braces(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 + (negatives if negated else positives).extend(expanded) if not any(_relative_matches_workspace_glob(rel_parts, p, cells) for p in positives): return False return not any(_relative_matches_workspace_glob(rel_parts, n, cells) for n in negatives) diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 48862b6a1d..1fbe0a5e8d 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`), extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally), brace ranges — a `..` *inside a brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where `fnmatch` matches one `?`), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member. A `{` immediately preceded by `$` (shell-style `${…}`) is literal in minimatch's brace-expansion and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in both `fnmatch` and minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]`; a literal `..` *outside* any brace **or inside a brace group that also has a top-level comma** (`{foo..bar,baz}` is an alternation whose `..` is a literal option part — only a *comma-less* brace range fails closed); and the astral-`?` rejection applies only to a `?` segment that can actually align with an astral path segment, not to any `?` when an astral char appears elsewhere (`packages/*/a??` still matches `packages/😀/app`). Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`), though a *leading* `./` may be normalized away. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`), extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally), brace ranges — a `..` *inside a brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where `fnmatch` matches one `?`), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member. A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; a cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in both `fnmatch` and minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]`; a literal `..` *outside* any brace **or inside a brace group that also has a top-level comma** (`{foo..bar,baz}` is an alternation whose `..` is a literal option part — only a *comma-less* brace range fails closed); and the astral-`?` rejection applies only to a `?` segment that can actually align with an astral path segment, not to any `?` when an astral char appears elsewhere (`packages/*/a??` still matches `packages/😀/app`). Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`), though a *leading* `./` may be normalized away. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 29a3963776..82a1750f6f 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -22,6 +22,7 @@ _lexical_repo_root, _find_expandable_brace, _has_complete_bracket_class, + _MAX_GLOB_LENGTH, _MAX_BRACE_SCAN_WORK, _MAX_MATCH_CELLS, _MAX_BRACE_EXPANSION, @@ -1034,6 +1035,44 @@ def test_empty_bracket_class_is_not_treated_as_a_class(self): 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_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_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" From 7597ed7fd8a72ccbd8137867f9fa1f4cd6b8859f Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 17:45:08 -0700 Subject: [PATCH 30/56] fix(get_test_command): direct segment matcher + unbalanced-${ + one leading ./ (round-21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-21 review (Codex gpt-5.6-sol xhigh) found two HIGH false-positives and one medium: F1 (high): `_skip_balanced_braces` returned end-of-string for an UNBALANCED `${`, so a later balanced `{a,b}` never expanded — `!packages/${foo/{a,b}` failed to exclude `packages/${foo/a`. It now returns start+1 for an unbalanced `${` (the `{` is literal; scanning resumes after it), matching the round-14 unbalanced-brace rule. F2 (high): the per-segment matcher delegated to Python `fnmatch`, which reinterprets literal bracket forms the guard intentionally permits — `fnmatch` matched `^` against the empty class `[^]`, falsely proving membership. Replace `fnmatch` with a direct `*`/`?`/literal two-pointer matcher (`_wildcard_segment_match`): every other character, brackets included, is literal, so a dir named `[^]` matches the glob `[^]` (parity with minimatch) and no OS case-folding creeps in. `import fnmatch` removed. Also fixed the class detector: a `]` in first-member position makes `[]]`/`[^]]`/`[!]]` real non-empty classes (they now fail closed), while truly empty `[]`/`[!]`/`[^]` stay literal. F3 (medium): the leading-dot strip removed ALL leading `.` segments; npm normalizes only ONE `./`. `././packages/*` no longer collapses to `packages/*` and falsely matches `packages/app` — the second `.` is significant. All prior guards re-verified. Regressions added for all three (incl. direct-matcher literal-bracket controls and `[]]`/`[^]]`/`[!]]`). Prompt R6 updated (matcher is now fnmatch-free); meta re-synced; E2E green; 150 get_test_command + 52 other tests pass; lint 9.75. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_test_command.py | 90 +++++++++++++++------- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 38 +++++++++ 4 files changed, 107 insertions(+), 31 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index c0612dcf1b..86653d8dd7 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "0d80c88459eae9304b71b43fc5fff12cbe6f1e0d64e3d6c1fab7e3348d5a89a4", - "code_hash": "65d0f558f501c5cc2fee763782629093153ee6f7aa42d1756c466e6446cb2bd9", + "prompt_hash": "9fcdc80f8c8c7b70279fde37866f5127b4d82c3c935b4a8fe28ea53a8b4e28e7", + "code_hash": "d5517fb6159f18e50d7113054d24e42e4e7967fdd4ed9956f069ee905c9f7d58", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "bb8f2767fa73a36844c5ea70ebd240cbe71a86aa6c7475e07007f28803e9b275", + "test_hash": "74cd1fc45be915d11929c9033f2aafc3b3858429b3798d2261d5f3fb17ea181a", "test_files": { - "test_get_test_command.py": "bb8f2767fa73a36844c5ea70ebd240cbe71a86aa6c7475e07007f28803e9b275" + "test_get_test_command.py": "74cd1fc45be915d11929c9033f2aafc3b3858429b3798d2261d5f3fb17ea181a" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 712b9e6483..013b1db008 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -11,7 +11,6 @@ from pathlib import Path from typing import Optional, Tuple import csv -import fnmatch import json import os import re @@ -92,16 +91,18 @@ def _has_complete_bracket_class(raw: str) -> bool: """True when ``raw`` contains a *closed, non-empty* ``[...]`` bracket class. Two - kinds are NOT flagged because both ``fnmatch`` and minimatch treat them - literally, so rejecting them would needlessly refuse a legitimate dir name: + kinds are NOT flagged because minimatch treats them literally, so rejecting them + would needlessly refuse a legitimate dir name: - * an *unmatched* ``[`` (no later ``]``), e.g. ``foo[bar``; and - * an *empty* class ``[]``, ``[!]``, ``[^]`` (nothing between the optional - negation marker and ``]``), e.g. a dir literally named ``[]``. + * an *unmatched* ``[`` (no closing ``]``), e.g. ``foo[bar``; and + * an *empty* class ``[]``, ``[!]``, ``[^]`` (no member before the closing + ``]``), e.g. a dir literally named ``[]``. - Only a class with at least one content character — whose ``[^…]`` negation and - POSIX ``[[:…:]]`` semantics diverge between ``fnmatch`` and minimatch — is - flagged for fail-closed handling. + 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.""" @@ -111,14 +112,12 @@ def _has_complete_bracket_class(raw: str) -> bool: i += 1 continue j = i + 1 - if j < n and raw[j] in "!^": # a leading negation marker is not content + if j < n and raw[j] in "!^": # a leading negation marker is not a member j += 1 - if j < n and raw[j] == "]": # empty class ([], [!], [^]) → literal - i = j + 1 - continue - # A non-empty class needs a closing ']'. There is at most one forward search - # per string because a failure means no ']' exists at any later position. - if j < n and raw.find("]", 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 @@ -319,12 +318,13 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, # Cheap guard before allocating the split list (a "slash wall" attack). if pattern.count("/") > _MAX_GLOB_SEGMENTS: raise _PatternBudgetError - # Drop empty segments (from ``//`` or a trailing ``/``) and a *leading* ``./`` - # (npm normalizes a leading current-dir). An *internal* ``.`` segment is kept: - # minimatch does not collapse ``packages/./x``, so such a glob must not be - # treated as ``packages/x`` and falsely prove membership. + # Drop empty segments (from ``//`` or a trailing ``/``) and AT MOST ONE leading + # ``./`` (npm normalizes a single leading current-dir). Every *subsequent* ``.`` + # segment — leading (``././packages`` keeps the second) or internal + # (``packages/./x``) — is significant and kept, so such a glob must not be + # collapsed to ``packages/x`` and falsely prove membership. pat_parts = [p for p in pattern.strip("/").split("/") if p != ""] - while pat_parts and pat_parts[0] == ".": + if pat_parts and pat_parts[0] == ".": pat_parts.pop(0) if len(pat_parts) > _MAX_GLOB_SEGMENTS: raise _PatternBudgetError @@ -358,13 +358,48 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, return dp[0][0] +def _wildcard_segment_match(name: str, pat: str) -> bool: + """Match one path segment ``name`` against one glob segment ``pat`` in the + matcher's *own* supported language — literal characters plus ``*`` (any run, + including empty) and ``?`` (exactly one character) — and NOTHING else. + + Every other character, including ``[ ] ( ) { } ^ ! $`` and ``.``, is treated as + a literal. Unsupported minimatch constructs (real ``[...]`` classes, extglobs, + ranges) never reach here — they fail membership closed at the guard — so 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: no recursion, no + regex, O(len(name) * number of ``*``) time and O(1) space.""" + s = p = 0 + star_p = star_s = -1 + ns, npat = len(name), len(pat) + while s < ns: + if p < npat and (pat[p] == "?" or pat[p] == name[s]): + s += 1 + p += 1 + elif p < npat and pat[p] == "*": + star_p, star_s = p, s + p += 1 + elif star_p != -1: + p = star_p + 1 + star_s += 1 + s = star_s + else: + return False + while p < npat and pat[p] == "*": + p += 1 + return p == npat + + def _segment_matches(name: str, pattern_segment: str) -> bool: - """fnmatch a single path segment with minimatch ``dot:false`` semantics: a + """Match a single path segment with minimatch ``dot:false`` semantics: a wildcard pattern segment does not match a ``name`` that begins with ``.`` unless the pattern segment itself begins with ``.``.""" if name.startswith(".") and not pattern_segment.startswith("."): return False - return fnmatch.fnmatch(name, pattern_segment) + return _wildcard_segment_match(name, pattern_segment) def _workspace_globs_for(ancestor: Path, cache: Optional[dict] = None) -> list: @@ -516,8 +551,11 @@ def _split_top_level_commas(body: str, limit: int) -> list: def _skip_balanced_braces(pattern: str, start: int, work: list) -> int: """Return the index just past the ``}`` matching the ``{`` at ``start`` (nesting - included), or ``len(pattern)`` when the group is unbalanced (literal to the end). - Each scanned character is charged against the ``work`` budget.""" + 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 @@ -530,7 +568,7 @@ def _skip_balanced_braces(pattern: str, start: int, work: list) -> int: if depth == 0: return i + 1 i += 1 - return n + return start + 1 # unbalanced ${ → ``{`` is literal; resume after it def _find_expandable_brace(pattern: str, limit: int, diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 1fbe0a5e8d..47db57cae7 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, whose semantics differ from Python `fnmatch`), extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but `fnmatch` treats literally), brace ranges — a `..` *inside a brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where `fnmatch` matches one `?`), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member. A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; a cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in both `fnmatch` and minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]`; a literal `..` *outside* any brace **or inside a brace group that also has a top-level comma** (`{foo..bar,baz}` is an alternation whose `..` is a literal option part — only a *comma-less* brace range fails closed); and the astral-`?` rejection applies only to a `?` segment that can actually align with an astral path segment, not to any `?` when an astral char appears elsewhere (`packages/*/a??` still matches `packages/😀/app`). Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`), though a *leading* `./` may be normalized away. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but a literal matcher would not), brace ranges — a `..` *inside a comma-less brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where a code-point `?` matches one), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member. A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; a cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one character) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace **or inside a brace group that also has a top-level comma** (`{foo..bar,baz}` is an alternation whose `..` is a literal option part — only a *comma-less* brace range fails closed); and the astral-`?` rejection applies only to a `?` segment that can actually align with an astral path segment, not to any `?` when an astral char appears elsewhere (`packages/*/a??` still matches `packages/😀/app`). Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 82a1750f6f..c49a94c024 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1063,6 +1063,44 @@ def test_dollar_brace_is_opaque_including_nested(self): # 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_at_most_one_leading_dot_slash_is_normalized(self): + """npm normalizes only ONE leading ``./``; a second leading ``.`` segment is + significant. ``././packages/*`` therefore does NOT match ``packages/app``, + while a single ``./packages/*`` still does.""" + assert _package_matches_workspace(("packages", "app"), ["././packages/*"]) is False + assert _package_matches_workspace(("packages", "app"), [".//./packages/*"]) is False + assert _package_matches_workspace(("packages", "app"), ["./packages/*"]) is True + assert _package_matches_workspace(("packages", "app"), ["packages/*"]) is True + 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 From 8f198653dc07ee5f29714eda3ec880fa8ded7c2f Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 18:08:10 -0700 Subject: [PATCH 31/56] fix(get_test_command): mask ${...} pre-expansion + precise comment/astral/extglob (round-22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-22 review (Codex gpt-5.6-sol xhigh) found two medium false-positives and two low over-rejections: F1 (med): comment classification ran before leading-`/` normalization while matching strips `/`, so `/#*` (a minimatch comment after normalization) was matched literally and falsely proved a package named `#evil`. Comment classification now uses `_effective_leading` — the SAME normalization as matching (strip leading `/` and at most one `./`). F2 (med): `${...}` opacity was inferred from `$`+`{` adjacency in the partially-expanded worklist string, so expanding `{$,x}{a,b}` generated a spurious `${a,b}` that was frozen — `!packages/{$,x}{a,b}` failed to exclude `packages/$a`. Genuine balanced `${...}` spans are now masked out of the ORIGINAL pattern before expansion (`_mask_dollar_braces`, restored after via `_restore_dollar_braces`), and the fragile adjacency check is removed, so a generated `$`+`{` expands normally. F3 (low): the astral-`?` gate failed closed on any `**`+`?`+astral combo. `_astral_question_mark_risk` now fails closed only when some `?` segment can actually (code-point) match some astral segment, so `packages/ap?/**` matches `("packages","app","😀")` (`ap?` can't match the emoji). F4 (low): the extglob check was a substring test, rejecting incomplete markers like `foo?(bar` (minimatch reads these as `?` + literal `(`). `_has_complete_extglob` now flags only a marker with a matching `)`. All prior guards re-verified. Regressions added for all four. Prompt R6 updated; meta re-synced; E2E green; 150 get_test_command + 52 other tests pass; lint 9.78. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_test_command.py | 158 +++++++++++++++------ pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 47 ++++++ 4 files changed, 170 insertions(+), 45 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 86653d8dd7..610bcc3bd6 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "9fcdc80f8c8c7b70279fde37866f5127b4d82c3c935b4a8fe28ea53a8b4e28e7", - "code_hash": "d5517fb6159f18e50d7113054d24e42e4e7967fdd4ed9956f069ee905c9f7d58", + "prompt_hash": "f27daa4b1ebadb1381b8a75901adc7ee363ebebfbf929073ea108b1a522eb5b0", + "code_hash": "aed4ec0559a2eb37a210da7a91fd865552a964144f782de0bd438f9178515c78", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "74cd1fc45be915d11929c9033f2aafc3b3858429b3798d2261d5f3fb17ea181a", + "test_hash": "aeb413ecc5b4b831c41abb4599ed9c5d4e91da294731103299fd65ef2dc440aa", "test_files": { - "test_get_test_command.py": "74cd1fc45be915d11929c9033f2aafc3b3858429b3798d2261d5f3fb17ea181a" + "test_get_test_command.py": "aeb413ecc5b4b831c41abb4599ed9c5d4e91da294731103299fd65ef2dc440aa" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 013b1db008..70f7d1a60f 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -82,11 +82,30 @@ # expand to a handful of short patterns — orders of magnitude under this bound. _MAX_BRACE_SCAN_WORK = 8_000_000 -# Extglob prefixes (``?(``/``*(``/``+(``/``@(``/``!(``). minimatch expands these -# but the per-segment ``fnmatch`` matcher treats them literally, so a workspace -# glob using one fails membership closed rather than under-/over-matching. See -# the guard in ``_package_matches_workspace``. -_EXTGLOB_MARKERS = ("?(", "*(", "+(", "@(", "!(") +# 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: @@ -184,6 +203,17 @@ def _raw_glob_unsupported(raw: str) -> bool: return "\\" in raw +def _effective_leading(pattern: str) -> str: + """Return ``pattern`` with the SAME leading normalization the matcher applies — + all leading ``/`` and at most one leading ``./`` removed — so a caller can + classify the effective first segment (e.g. a ``#`` comment) consistently with + matching. A second/further leading ``.`` segment is significant and is kept.""" + eff = pattern.lstrip("/") + if eff.startswith("./"): + eff = eff[2:].lstrip("/") + return eff + + 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 @@ -197,8 +227,11 @@ def _concrete_pattern_unsupported(pattern: str) -> bool: (``{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. - * extglobs (``?(…)``/``*(…)``/``+(…)``/``@(…)``/``!(…)``) — ``fnmatch`` treats - them literally, so an extglob exclusion fails to exclude. + * 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. (The astral ``?`` divergence — minimatch counts a non-BMP char as two UTF-16 code units — is handled precisely per aligned segment in @@ -214,7 +247,7 @@ def _concrete_pattern_unsupported(pattern: str) -> bool: return True if _has_brace_range(pattern): return True - return any(marker in pattern for marker in _EXTGLOB_MARKERS) + return _has_complete_extglob(pattern) def _read_manifest_text(path: Path, max_bytes: int = _MAX_MANIFEST_BYTES) -> Optional[str]: @@ -271,25 +304,25 @@ def _segment_has_astral(segment: str) -> bool: def _astral_question_mark_risk(pat_parts: list, rel: list) -> bool: """True when matching ``pat_parts`` against path ``rel`` could align a ``?`` pattern segment with an astral path segment, whose UTF-16-unit (minimatch) vs - code-point (``fnmatch``) counting diverges — so the caller must fail closed. - - The check is *precise*, not merely "a ``?`` and an astral char both appear": - * with no ``**`` the alignment is strictly positional (``*``/``?``/literal each - consume exactly one segment), so a risk exists only when the segment counts - match and a ``?`` segment sits opposite an astral segment; - * a ``**`` makes alignment flexible, so any ``?`` segment could reach an astral - segment → conservatively fail closed. - A ``?`` that can only ever meet BMP segments (e.g. ``a??`` opposite ``app`` while - ``*`` consumes the astral segment) is therefore not needlessly rejected. + code-point counting diverges — so the caller must fail closed. + + The check is *precise*: a risk exists only when some ``?``-bearing pattern + segment could actually match (code-point-wise) some astral path segment. A + ``?``-segment whose own literal/length structure can never match a given astral + segment (e.g. ``ap?`` against a one-character emoji, while a ``**`` or ``*`` + consumes that segment) is NOT a risk and is not rejected — this holds whether or + not the pattern contains ``**``, so ``packages/ap?/**`` still matches + ``("packages", "app", "😀")``. """ - if not any(_segment_has_astral(seg) for seg in rel): - return False - if not any("?" in p for p in pat_parts): + astral_segs = [r for r in rel if _segment_has_astral(r)] + if not astral_segs: return False - if "**" in pat_parts: - return True - return len(pat_parts) == len(rel) and any( - "?" in p and _segment_has_astral(r) for p, r in zip(pat_parts, rel)) + for p in pat_parts: + if p == "**" or "?" not in p: + continue + if any(_wildcard_segment_match(r, p) for r in astral_segs): + return True + return False def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, @@ -607,14 +640,6 @@ def _find_expandable_brace(pattern: str, limit: int, if pattern[i] != "{": i += 1 continue - if i > 0 and pattern[i - 1] == "$": - # A balanced ``${...}`` is a shell-style group that minimatch's - # brace-expansion treats as fully literal (opaque) — nested braces - # included. Skip the WHOLE group (not just one character, which would let - # a nested ``{b,c}`` expand) and keep scanning for later independent - # braces. An unbalanced ``${...`` is literal to end of string. - i = _skip_balanced_braces(pattern, i, work) - continue depth, end = 0, -1 for j in range(i, n): work[0] -= 1 @@ -640,10 +665,57 @@ def _find_expandable_brace(pattern: str, limit: int, 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. + 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 @@ -662,6 +734,11 @@ def _expand_braces(pattern: str, budget: Optional[list] = 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 @@ -684,7 +761,7 @@ def _emit(value: str) -> None: prefix, suffix = pat[:start], pat[end + 1:] for option in options: worklist.append(prefix + option + suffix) - return out + return [_restore_dollar_braces(p, literals) for p in out] def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, @@ -741,14 +818,15 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # mis-classified (e.g. treating ``!!!packages/foo`` as a literal and # falsely proving membership). raise _PatternBudgetError - # A *positive* pattern whose effective form (after an optional leading - # ``./``) begins with ``#`` is a minimatch comment: it matches nothing, - # so it must not be fnmatch-ed literally into a false member. Skip it. + # A *positive* pattern whose effective form is a minimatch comment (a + # leading ``#``) matches nothing, so it must not be matched literally into + # a false member. Classify it AFTER the SAME leading normalization the + # matcher applies (strip leading ``/`` and at most one ``./``) so ``/#*`` + # and ``.//#*`` are recognized as comments too, not just ``#*``/``./#*``. # (A ``!``-exclusion body starting with ``#`` is left to match its literal # ``#`` — the safe direction, since a spurious exclusion only removes a # member, never adds one.) - if not negated and ( - body[2:] if body.startswith("./") else body).startswith("#"): + if not negated and _effective_leading(body).startswith("#"): continue expanded = _expand_braces(body, budget, work) # Validate the CONCRETE (expanded) patterns, not the raw glob: brace diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 47db57cae7..8f7b1bfe9a 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), extglobs (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)`, which minimatch expands but a literal matcher would not), brace ranges — a `..` *inside a comma-less brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where a code-point `?` matches one), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form (after an optional leading `./`) begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member. A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; a cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one character) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace **or inside a brace group that also has a top-level comma** (`{foo..bar,baz}` is an alternation whose `..` is a literal option part — only a *comma-less* brace range fails closed); and the astral-`?` rejection applies only to a `?` segment that can actually align with an astral path segment, not to any `?` when an astral char appears elsewhere (`packages/*/a??` still matches `packages/😀/app`). Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where a code-point `?` matches one), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; a cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one character) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace **or inside a brace group that also has a top-level comma** (`{foo..bar,baz}` is an alternation whose `..` is a literal option part — only a *comma-less* brace range fails closed); and the astral-`?` rejection applies only to a `?` segment that can actually align with an astral path segment, not to any `?` when an astral char appears elsewhere (`packages/*/a??` still matches `packages/😀/app`). Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index c49a94c024..962ee6848f 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -22,6 +22,7 @@ _lexical_repo_root, _find_expandable_brace, _has_complete_bracket_class, + _has_complete_extglob, _MAX_GLOB_LENGTH, _MAX_BRACE_SCAN_WORK, _MAX_MATCH_CELLS, @@ -1111,6 +1112,52 @@ def test_length_guard_precedes_syntax_scan(self): # 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_comment_is_normalized_before_classification(self): + """A leading ``/`` (or ``//`` / ``.//``) is normalized the SAME way for + comment classification as for matching, so ``/#*`` is recognized as a + minimatch comment (matches nothing) instead of being matched literally into a + false member. A leading ``/`` on a real glob is still just normalized away.""" + assert _package_matches_workspace(("#evil", "package"), ["/#*"]) is False + assert _package_matches_workspace(("#evil",), ["//#*"]) is False + assert _package_matches_workspace(("#evil",), [".//#*"]) is False + 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_only_fails_closed_on_feasible_alignment(self): + """The astral-``?`` fail-closed triggers only when a ``?`` segment could + actually match an astral segment — even with ``**`` present. ``ap?`` cannot + match a one-character emoji, so ``packages/ap?/**`` still matches + ``("packages", "app", "😀")``; a lone ``?`` opposite the emoji still fails + closed.""" + emoji = "\U0001F600" + assert _package_matches_workspace( + ("packages", "app", emoji), ["packages/ap?/**"]) is True + assert _package_matches_workspace( + ("packages", emoji, "app"), ["packages/?/**"]) is False + + 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_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" From 202b5d2698d5d25ded2529357779afcad59b86f7 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 18:35:36 -0700 Subject: [PATCH 32/56] fix(get_test_command): UTF-16 ?, per-segment class/extglob, range grammar (round-23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-23 review (Codex gpt-5.6-sol xhigh) found two medium and one low over-rejection: F1 (med): the bracket-class and extglob checks scanned the whole pattern, but a class/extglob cannot cross `/`. `packages/foo[/bar]` (literal `[` in minimatch, class can't span the `/`) and `packages/foo?(/bar)` were wrongly rejected. Both checks now run per `/`-delimited segment; brace ranges still scan the whole pattern (a brace body may contain `/`). F2 (med): the astral-`?` fail-closed took a Cartesian product of `?` segments and astral path segments without checking reachable DP alignments, so a non-matching positive like `packages/?/x` failed the WHOLE check closed even when another positive matched — making include semantics order-dependent. Replaced the entire approximation by matching `?` over UTF-16 code units in `_wildcard_segment_match` (`_utf16_units`): `?` matches exactly one unit, an astral char needs `??` — exact minimatch parity. `_astral_question_mark_risk`/`_segment_has_astral` deleted. F3 (low): every comma-less brace containing `..` was treated as a range, so a literal dir `{foo..bar}` (multi-character endpoints) was rejected. `_has_brace_range` now validates minimatch range grammar via `_is_range_body` (integer or single-character endpoints, optional integer step) and inspects only leaf groups, keeping the scan linear. `{foo..bar}`/`{1.0..3.0}`/`{..}` are literal. All prior guards re-verified; astral tests updated to the exact (non-fail-closed) UTF-16 behavior. Regressions added for all three. Prompt R6 updated; meta re-synced; E2E green; 152 get_test_command + 52 other tests pass; lint 9.78. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_test_command.py | 180 +++++++++++---------- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 86 +++++++--- 4 files changed, 163 insertions(+), 113 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 610bcc3bd6..1bf63cef52 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "f27daa4b1ebadb1381b8a75901adc7ee363ebebfbf929073ea108b1a522eb5b0", - "code_hash": "aed4ec0559a2eb37a210da7a91fd865552a964144f782de0bd438f9178515c78", + "prompt_hash": "bc4de88a4da9eea5079dc93208bd3317c4b2d2db71a804c248d4f48c7754be25", + "code_hash": "e0348509e8272b57b50e1b1688e8266cf96bd04e4cf687d6b286a5acc9b7e16c", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "aeb413ecc5b4b831c41abb4599ed9c5d4e91da294731103299fd65ef2dc440aa", + "test_hash": "48107bfaca89d3ec79bafa73019a96044a988d8d0429672f239b1c6c9afd9c11", "test_files": { - "test_get_test_command.py": "aeb413ecc5b4b831c41abb4599ed9c5d4e91da294731103299fd65ef2dc440aa" + "test_get_test_command.py": "48107bfaca89d3ec79bafa73019a96044a988d8d0429672f239b1c6c9afd9c11" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 70f7d1a60f..5678473e17 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -142,47 +142,56 @@ def _has_complete_bracket_class(raw: str) -> bool: 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 signed + integers or BOTH single characters, and the optional step ``Z`` is an integer. + + Multi-character non-integer endpoints (``foo..bar``), non-integer numeric-looking + endpoints (``1.0..3.0``), and empty endpoints (``..``) are NOT ranges — minimatch + leaves them literal — so they are NOT flagged.""" + parts = body.split("..") + if len(parts) not in (2, 3): + return False + + def _is_int(text: str) -> bool: + digits = text[1:] if text[:1] in "+-" else text + return bool(digits) and digits.isdigit() + + start, end = parts[0], parts[1] + endpoints_ok = (_is_int(start) and _is_int(end)) or ( + len(start) == 1 and len(end) == 1) + if not endpoints_ok: + return False + return len(parts) == 2 or _is_int(parts[2]) + + def _has_brace_range(raw: str) -> bool: - """True when ``raw`` contains a minimatch numeric/alphabetic *range* — a ``..`` - inside a brace group that is NOT a comma alternation (``{1..3}``, ``{a..c}``, - ``{01..03}``, ``{1..9..2}``), which a comma-only expander emits literally. - - A ``..`` is NOT a range, and so is NOT flagged, when it is: - * outside any brace (a dir named ``foo..bar``); - * inside an *unbalanced* brace (``{foo..bar`` — literal); or - * inside a brace group that also has a top-level comma (``{foo..bar,baz}`` is - an alternation whose ``..`` is a literal part of one option — the comma - expander already handles it correctly). - - Nesting is tracked per level, so a range nested inside an alternation - (``{a,{1..3}}``) is still flagged. A balanced ``${...}`` group is opaque (its - ``..`` is literal, not a range), so ``${1..3}`` is NOT flagged.""" - # Each stack frame tracks, for one open brace level: whether a top-level comma - # and whether a top-level ``..`` have been seen, whether the previous char at - # this level was a ``.``, and whether this group (or an ancestor) is a ``${...}`` - # opaque region. + """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 ch in raw: + for i, ch in enumerate(raw): if ch == "{": - opaque = prev == "$" or (bool(stack) and stack[-1][3]) - stack.append([False, False, False, opaque]) - elif ch == "}": + opaque = prev == "$" or (bool(stack) and stack[-1][1]) if stack: - has_comma, has_range, _, opaque = stack.pop() - if has_range and not has_comma and not opaque: - return True # balanced, a ``..``, no comma, not opaque → range - elif stack and not stack[-1][3]: # ignore content of an opaque ${...} - top = stack[-1] - if ch == ",": - top[0] = True - top[2] = False - elif ch == ".": - if top[2]: - top[1] = True - top[2] = True - else: - top[2] = False + 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 @@ -233,21 +242,25 @@ def _concrete_pattern_unsupported(pattern: str) -> bool: wildcard plus a literal ``(`` — the direct matcher agrees — so it is NOT rejected. - (The astral ``?`` divergence — minimatch counts a non-BMP char as two UTF-16 - code units — is handled precisely per aligned segment in - ``_relative_matches_workspace_glob``, not here, so a ``?`` in a segment that - never meets an astral path segment is not needlessly 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. """ - if _has_complete_bracket_class(pattern): - return True - if _has_brace_range(pattern): - return True - return _has_complete_extglob(pattern) + 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]: @@ -295,36 +308,6 @@ class TestCommand: cwd: Optional[Path] = None -def _segment_has_astral(segment: str) -> bool: - """True when ``segment`` contains a non-BMP / "astral" character (code point - above U+FFFF), which minimatch counts as two UTF-16 code units.""" - return any(ord(ch) > 0xFFFF for ch in segment) - - -def _astral_question_mark_risk(pat_parts: list, rel: list) -> bool: - """True when matching ``pat_parts`` against path ``rel`` could align a ``?`` - pattern segment with an astral path segment, whose UTF-16-unit (minimatch) vs - code-point counting diverges — so the caller must fail closed. - - The check is *precise*: a risk exists only when some ``?``-bearing pattern - segment could actually match (code-point-wise) some astral path segment. A - ``?``-segment whose own literal/length structure can never match a given astral - segment (e.g. ``ap?`` against a one-character emoji, while a ``**`` or ``*`` - consumes that segment) is NOT a risk and is not rejected — this holds whether or - not the pattern contains ``**``, so ``packages/ap?/**`` still matches - ``("packages", "app", "😀")``. - """ - astral_segs = [r for r in rel if _segment_has_astral(r)] - if not astral_segs: - return False - for p in pat_parts: - if p == "**" or "?" not in p: - continue - if any(_wildcard_segment_match(r, p) for r in astral_segs): - return True - return False - - def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, cell_budget: Optional[list] = None) -> bool: """Match a package's path segments against a single workspace glob pattern. @@ -363,8 +346,8 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, raise _PatternBudgetError rel = list(rel_parts) n, m = len(rel), len(pat_parts) - if _astral_question_mark_risk(pat_parts, rel): - raise _PatternBudgetError # `?` may meet an astral segment → fail closed + # (``?`` now matches over UTF-16 code units in ``_wildcard_segment_match``, giving + # exact minimatch parity for astral characters — no separate astral guard needed.) # 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: @@ -391,28 +374,49 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, 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_segment_match(name: str, pat: str) -> bool: """Match one path segment ``name`` against one glob segment ``pat`` in the matcher's *own* supported language — literal characters plus ``*`` (any run, - including empty) and ``?`` (exactly one character) — and NOTHING else. + including empty) and ``?`` (exactly one UTF-16 code unit) — and NOTHING else. - Every other character, including ``[ ] ( ) { } ^ ! $`` and ``.``, is treated as - a literal. Unsupported minimatch constructs (real ``[...]`` classes, extglobs, - ranges) never reach here — they fail membership closed at the guard — so 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 ``[^]``). + Matching is over UTF-16 code units so ``?`` has exact minimatch parity even for + astral characters (``?`` matches one surrogate unit; an emoji needs ``??``). + Every other character, including ``[ ] ( ) { } ^ ! $`` and ``.``, is a literal. + Unsupported minimatch constructs (real ``[...]`` classes, extglobs, ranges) never + reach here — they fail membership closed at the guard — so 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: no recursion, no regex, O(len(name) * number of ``*``) time and O(1) space.""" + q, star = 0x3F, 0x2A # ord('?'), ord('*') — always wildcards in a glob + name_u, pat_u = _utf16_units(name), _utf16_units(pat) s = p = 0 star_p = star_s = -1 - ns, npat = len(name), len(pat) + ns, npat = len(name_u), len(pat_u) while s < ns: - if p < npat and (pat[p] == "?" or pat[p] == name[s]): + if p < npat and (pat_u[p] == q or pat_u[p] == name_u[s]): s += 1 p += 1 - elif p < npat and pat[p] == "*": + elif p < npat and pat_u[p] == star: star_p, star_s = p, s p += 1 elif star_p != -1: @@ -421,7 +425,7 @@ def _wildcard_segment_match(name: str, pat: str) -> bool: s = star_s else: return False - while p < npat and pat[p] == "*": + while p < npat and pat_u[p] == star: p += 1 return p == npat diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 8f7b1bfe9a..1ee665030c 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* (`{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, which comma-only expanders emit literally), `?` matched against a leaf segment holding a non-BMP / "astral" character (minimatch counts UTF-16 code units, so it needs `??` where a code-point `?` matches one), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; a cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one character) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace **or inside a brace group that also has a top-level comma** (`{foo..bar,baz}` is an alternation whose `..` is a literal option part — only a *comma-less* brace range fails closed); and the astral-`?` rejection applies only to a `?` segment that can actually align with an astral path segment, not to any `?` when an astral char appears elsewhere (`packages/*/a??` still matches `packages/😀/app`). Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (integer or single-character endpoints with an optional integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, or empty `{..}`, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 962ee6848f..58d80d46bf 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -21,6 +21,7 @@ _relative_matches_workspace_glob, _lexical_repo_root, _find_expandable_brace, + _has_brace_range, _has_complete_bracket_class, _has_complete_extglob, _MAX_GLOB_LENGTH, @@ -918,17 +919,24 @@ def test_internal_dot_segment_is_not_collapsed(self): # A genuine literal `.`-named segment still matches itself. assert _package_matches_workspace(("packages", "."), ["packages/*"]) is False - def test_question_mark_against_astral_character_fails_membership_closed(self): - """``fnmatch`` counts a non-BMP (astral) character as one code point, but - minimatch (a JS regex) counts it as two UTF-16 code units, so a single ``?`` - matches an emoji here where minimatch needs ``??``. When the leaf holds an - astral character, any ``?`` glob fails membership closed rather than falsely - match; ``*`` (which spans the whole segment) and BMP ``?`` are unaffected.""" + 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 - 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", emoji), ["packages/*", "!packages/?"]) is False + ("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 @@ -1002,18 +1010,21 @@ def test_double_dot_in_comma_alternation_is_not_a_range(self): ("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_is_checked_per_aligned_segment(self): - """The astral-``?`` fail-closed applies only when a ``?`` segment can align - with an astral path segment — not merely because both appear somewhere. With - no ``**`` the alignment is positional, so ``packages/*/a??`` matches - ``packages/😀/app`` (``*`` consumes the emoji, ``a??`` matches ``app``).""" + 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 - # A `?` that actually aligns with the astral segment still fails closed. + # `?` (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 @@ -1135,17 +1146,19 @@ def test_generated_dollar_brace_adjacency_still_expands(self): # A genuine balanced ${...} is still opaque (masked, restored literally). assert _expand_braces("packages/${foo,bar}") == ["packages/${foo,bar}"] - def test_astral_question_mark_only_fails_closed_on_feasible_alignment(self): - """The astral-``?`` fail-closed triggers only when a ``?`` segment could - actually match an astral segment — even with ``**`` present. ``ap?`` cannot - match a one-character emoji, so ``packages/ap?/**`` still matches - ``("packages", "app", "😀")``; a lone ``?`` opposite the emoji still fails - closed.""" + 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 @@ -1158,6 +1171,39 @@ def test_incomplete_extglob_marker_is_not_rejected(self): 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 + # 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" From 972a31721539253cd9f0711bd5a01a7433003552 Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 18:57:19 -0700 Subject: [PATCH 33/56] fix(get_test_command): pnpm YAML 1.2 scalar+dup-key parity, ASCII range grammar (round-24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-24 review (Codex gpt-5.6-sol xhigh) found one medium false-positive and one low over-rejection: F1 (med): pnpm parses YAML 1.2 but PyYAML defaults to YAML 1.1. An unquoted `packages: [0o12]` is octal 10 (a non-string entry pnpm rejects) in 1.2 but the string "0o12" in PyYAML — falsely proving membership for a dir literally named `0o12`. Same for exponent/leading-dot floats (`1e3`, `+.5`). And PyYAML silently keeps the last of duplicate mapping keys where pnpm errors. Parse pnpm YAML with a dedicated loader (`_pnpm_yaml_loader`) that adds YAML-1.2 core int/float resolvers (so unquoted number-forms resolve to non-strings → rejected by `_string_globs`, while a quoted "0o12" stays a string glob) and rejects duplicate keys (fail closed). npm/yarn/lerna use JSON, which is unaffected. F2 (low): `_is_range_body` accepted `+`-prefixed and Unicode-digit endpoints, so a literal dir `{+1..+3}` was misclassified as a range and rejected. Range grammar is now ASCII-only: integer endpoints with an optional leading `-` (not `+`), or single ASCII letters, with an optional ASCII-integer step. `{+1..+3}`/`{١..٣}` are literal. Regressions added (unquoted-number + quoted-string + duplicate-key pnpm YAML; plus/Unicode range endpoints). Prompt R6/R8 updated; meta re-synced; E2E green; 154 get_test_command + 52 other tests pass; lint 9.79. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_test_command.py | 80 ++++++++++++++++++---- pdd/prompts/get_test_command_python.prompt | 4 +- tests/test_get_test_command.py | 28 ++++++++ 4 files changed, 101 insertions(+), 19 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 1bf63cef52..9fb38f0a35 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "bc4de88a4da9eea5079dc93208bd3317c4b2d2db71a804c248d4f48c7754be25", - "code_hash": "e0348509e8272b57b50e1b1688e8266cf96bd04e4cf687d6b286a5acc9b7e16c", + "prompt_hash": "3bd71feb6f160bc3190e5a37df8a33d04231b337ea1d08cf964d0f8252e007e1", + "code_hash": "51a35d7ca04cb3d5a183c791d2f8bbf4b7845fd64819397edf1e3641fae2c8b3", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "48107bfaca89d3ec79bafa73019a96044a988d8d0429672f239b1c6c9afd9c11", + "test_hash": "1d872e3b6bcb966ca9708f9e2f664434e3e4ecd40ebe1fa3f8774bd621c53476", "test_files": { - "test_get_test_command.py": "48107bfaca89d3ec79bafa73019a96044a988d8d0429672f239b1c6c9afd9c11" + "test_get_test_command.py": "1d872e3b6bcb966ca9708f9e2f664434e3e4ecd40ebe1fa3f8774bd621c53476" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 5678473e17..7ed8c831df 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -144,26 +144,31 @@ def _has_complete_bracket_class(raw: str) -> bool: 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 signed - integers or BOTH single characters, and the optional step ``Z`` is an integer. - - Multi-character non-integer endpoints (``foo..bar``), non-integer numeric-looking - endpoints (``1.0..3.0``), and empty endpoints (``..``) are NOT ranges — minimatch - leaves them literal — so they are NOT flagged.""" + *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_int(text: str) -> bool: - digits = text[1:] if text[:1] in "+-" else text - return bool(digits) and digits.isdigit() + 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_int(start) and _is_int(end)) or ( - len(start) == 1 and len(end) == 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_int(parts[2]) + return len(parts) == 2 or _is_ascii_int(parts[2]) def _has_brace_range(raw: str) -> bool: @@ -476,6 +481,53 @@ def _workspace_globs_for(ancestor: Path, cache: Optional[dict] = None) -> list: 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 more closely than PyYAML's + default YAML 1.1 rules, so a ``pnpm-workspace.yaml`` cannot falsely prove + membership through a version discrepancy: + + * YAML 1.2's core schema resolves several unquoted scalar forms as NUMBERS that + PyYAML (1.1) leaves as strings — octal ``0o12`` and exponent/leading-dot + floats (``1e3``, ``+.5``). Resolving them as numbers makes ``_string_globs`` + reject a bare-number ``packages`` entry (as pnpm does), while a *quoted* + ``"0o12"`` — a string in both — stays a valid glob. + * pnpm 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.add_implicit_resolver( + "tag:yaml.org,2002:int", re.compile(r"^0o[0-7]+$"), list("0")) + _Loader.add_implicit_resolver( + "tag:yaml.org,2002:float", + re.compile(r"^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$"), + list("-+0123456789.")) + + 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) + if key in mapping: + raise yaml.constructor.ConstructorError( + None, None, f"duplicate key {key!r}", key_node.start_mark) + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + _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 @@ -493,7 +545,9 @@ def _workspace_globs_uncached(ancestor: Path) -> list: if text is None: return [] try: - data = yaml.safe_load(text) + # 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 diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 1ee665030c..008c111a94 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,11 +26,11 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (integer or single-character endpoints with an optional integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, or empty `{..}`, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. +R8 (MUST): **Fail closed** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 semantics, not PyYAML's default YAML 1.1: an unquoted scalar that YAML 1.2 resolves as a NUMBER (octal `0o12`, exponent/leading-dot floats like `1e3`/`+.5`) is a non-string entry that MUST be rejected (a *quoted* `"0o12"`, a string in both, stays a valid glob), and duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed. R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 58d80d46bf..c6bbad8055 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -675,6 +675,29 @@ def fake_import(name, *args, **kwargs): (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_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_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" @@ -1197,6 +1220,11 @@ def test_brace_range_grammar_multi_char_endpoints_are_literal(self): 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 From 578b562e57ec86240e13592f3b0ea26aa18c84be Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 21:04:07 -0700 Subject: [PATCH 34/56] fix(get_test_command): complete YAML 1.2 core schema for pnpm parsing (round-25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-25 review (Codex gpt-5.6-sol xhigh) found the round-24 pnpm YAML fix was incomplete: it layered octal/float resolvers onto PyYAML's inherited YAML 1.1 table, but the 1.1-vs-1.2 discrepancy runs both directions. Forms YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS — `yes`/`no`/`on`/`off` (1.1 bool), `0b10` (binary), `1:20` (sexagesimal), `2020-01-01` (timestamp), `1_000` (underscore int) — were still coerced to non-strings, so a valid pnpm declaration like `packages: [packages/*, yes]` was rejected wholesale, denying even `packages/app` its workspace membership and root runner. Replace the inherited YAML 1.1 implicit-resolver table wholesale with the YAML 1.2 core schema (bool = true/false only; null = null/~/empty; int = decimal/0o/0x; float = exponent/.inf/.nan). Now the 1.1-only forms stay strings (valid globs), while true 1.2 numbers/bool/null (`0o12`, `1e3`, `123`, `true`, `null`) are still rejected, and quoting is still respected. A date-like `2020-99-99` is now a plain string glob (YAML 1.2 has no timestamp type), not a construction that crashes — the round-10 fail-closed test is updated accordingly (no crash; literal glob that simply doesn't match `packages/app`). Regressions: `yes`/`on`/`0b10`/`1:20`/`2020-01-01`/`1_000` kept as globs; numbers rejected; quoted string kept; dup keys fail closed. Prompt R8 updated; meta re-synced; E2E green; 156 get_test_command + 52 other tests pass; lint 9.79. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 ++-- pdd/get_test_command.py | 44 +++++++++++++++------- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 38 ++++++++++++++----- 4 files changed, 65 insertions(+), 27 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 9fb38f0a35..69bd8bcb1a 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "3bd71feb6f160bc3190e5a37df8a33d04231b337ea1d08cf964d0f8252e007e1", - "code_hash": "51a35d7ca04cb3d5a183c791d2f8bbf4b7845fd64819397edf1e3641fae2c8b3", + "prompt_hash": "fe165585ff2721d2c3e6d7e90bb83410aeb5c5e2974e25bf759ad5cf7612a8e8", + "code_hash": "8b55444ec4fa9222b975f0bf2df227ed6cf0f8c89a1204bdf5c1a82f171caa14", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "1d872e3b6bcb966ca9708f9e2f664434e3e4ecd40ebe1fa3f8774bd621c53476", + "test_hash": "4f52cc92c03a828843e2f744d7609d1e7bfcd2836923b0b0903fd7cfc998185c", "test_files": { - "test_get_test_command.py": "1d872e3b6bcb966ca9708f9e2f664434e3e4ecd40ebe1fa3f8774bd621c53476" + "test_get_test_command.py": "4f52cc92c03a828843e2f744d7609d1e7bfcd2836923b0b0903fd7cfc998185c" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 7ed8c831df..53da3fba48 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -486,17 +486,24 @@ def _workspace_globs_for(ancestor: Path, cache: Optional[dict] = None) -> list: 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 more closely than PyYAML's - default YAML 1.1 rules, so a ``pnpm-workspace.yaml`` cannot falsely prove - membership through a version discrepancy: - - * YAML 1.2's core schema resolves several unquoted scalar forms as NUMBERS that - PyYAML (1.1) leaves as strings — octal ``0o12`` and exponent/leading-dot - floats (``1e3``, ``+.5``). Resolving them as numbers makes ``_string_globs`` - reject a bare-number ``packages`` entry (as pnpm does), while a *quoted* - ``"0o12"`` — a string in both — stays a valid glob. - * pnpm rejects duplicate mapping keys; PyYAML silently keeps the last. A custom - map constructor raises on a duplicate so the parse fails closed. + 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: @@ -505,11 +512,22 @@ def _pnpm_yaml_loader(yaml): 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:int", re.compile(r"^0o[0-7]+$"), list("0")) + "tag:yaml.org,2002:bool", + re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"), list("tTfF")) + _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]+)?$"), + re.compile( + r"^(?:[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?" + r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$"), list("-+0123456789.")) def _construct_mapping_no_duplicates(loader, node, deep=False): diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 008c111a94..1b1a0a3a75 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -30,7 +30,7 @@ R6 (MUST): Determine **workspace member** status by matching the package's relat 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** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 semantics, not PyYAML's default YAML 1.1: an unquoted scalar that YAML 1.2 resolves as a NUMBER (octal `0o12`, exponent/leading-dot floats like `1e3`/`+.5`) is a non-string entry that MUST be rejected (a *quoted* `"0o12"`, a string in both, stays a valid glob), and duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed. +R8 (MUST): **Fail closed** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 **core schema**, not PyYAML's default YAML 1.1 — and the 1.1 implicit-resolver table MUST be replaced wholesale, not merely patched, because the discrepancy runs both directions. Forms YAML 1.2 resolves as non-strings (`true`/`false`, `null`/`~`, `[-+]?[0-9]+`/`0o…`/`0x…` ints, and exponent/`.inf`/`.nan` floats) are non-string entries that MUST be rejected (so unquoted `0o12`/`1e3` reject the entry as pnpm does). Conversely, forms YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS — `yes`/`no`/`on`/`off`, `0b10` binary, `1:20` sexagesimal, `2020-01-01` timestamps, `1_000` underscore ints — are valid string globs that MUST NOT reject the declaration (a quoted `"0o12"`, a string in both, likewise stays a valid glob). Duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed. R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index c6bbad8055..24320dc235 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -688,6 +688,23 @@ def test_pnpm_yaml_uses_yaml_1_2_scalar_resolution(self, tmp_path): (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 @@ -1457,16 +1474,19 @@ def test_pnpm_yaml_recursion_bomb_fails_closed(self, tmp_path): (anc / "pnpm-workspace.yaml").write_text("packages: " + "[" * 3000 + "]" * 3000 + "\n") assert _workspace_globs_for(anc) == [] - def test_pnpm_yaml_malformed_timestamp_fails_closed(self, tmp_path): - """A malformed YAML timestamp scalar raises a bare ValueError from PyYAML's - constructor — not a yaml.YAMLError — and must fail closed, not crash.""" + 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") - for body in ("packages: [2020-99-99]\n", "packages: 2020-13-45\n"): - anc = tmp_path / body[:12].replace(":", "_").replace(" ", "") - anc.mkdir() - (anc / "pnpm-workspace.yaml").write_text(body) - assert _workspace_globs_for(anc) == [] - (anc / "pnpm-workspace.yaml").unlink() + 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 From 9a08db7a30c95cf4a493ee15fdcb9b10719bd1fa Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 21:54:41 -0700 Subject: [PATCH 35/56] fix(get_test_command): order-dependent membership + YAML 1.2 empty-scalar/int-construction (round-26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-26 review (Codex gpt-5.6-sol xhigh) found two high and one medium: F1 (high): an empty YAML list item (`- ` with nothing after) resolves to null in YAML 1.2 (a non-string that must fail closed), but the loader's null resolver omitted the empty scalar, so `packages: [packages/*, ]` yielded `["packages/*",""]` and `_string_globs` accepted it → `packages/app` falsely a member. The null resolver now matches the empty scalar (regex allows "", first-char list includes ""). F2 (high): the loader replaced YAML resolution but kept PyYAML's YAML 1.1 integer CONSTRUCTOR, so `012` constructed as octal 10 rather than decimal 12 — making the YAML-1.2-duplicate keys `012:`/`12:` (both integer 12) look distinct and parse successfully instead of failing closed. Added a YAML 1.2 int constructor (`012`→12; `0o`/`0x`→bases 8/16) and a (tag, value) duplicate-key comparison. F3 (medium): membership split all positives/negatives and treated every exclusion as permanent, so npm's re-inclusion `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` wrongly denied `packages/legacy/app`. Membership is now evaluated in declaration order, last matching pattern wins (a later positive re-includes) — matching both `@npmcli/map-workspaces` and `@pnpm/matcher`. A trailing exclusion still excludes; the pnpm `!**/test/**` case is unchanged. Regressions added for all three. Prompt R6 (order semantics) + R8 (empty scalar, int construction) updated; meta re-synced; E2E green; 163 get_test_command + 52 other tests pass; lint 9.80. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_test_command.py | 66 ++++++++++++++++------ pdd/prompts/get_test_command_python.prompt | 4 +- tests/test_get_test_command.py | 41 ++++++++++++++ 4 files changed, 96 insertions(+), 23 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 69bd8bcb1a..bd1303fbd1 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "fe165585ff2721d2c3e6d7e90bb83410aeb5c5e2974e25bf759ad5cf7612a8e8", - "code_hash": "8b55444ec4fa9222b975f0bf2df227ed6cf0f8c89a1204bdf5c1a82f171caa14", + "prompt_hash": "f4f98bbb8c0dc677466c0942361d4d52d9d12033a6f089f63107f695691f6e62", + "code_hash": "887c7254eab8db54be8dbb7b5ae500b3ec78297f0193c473e505e12e8981afee", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "4f52cc92c03a828843e2f744d7609d1e7bfcd2836923b0b0903fd7cfc998185c", + "test_hash": "2d2a2009b7574eefe91ca1c0b16c1eceec8dcd6f3ed3998d85b320ea0d49e5e3", "test_files": { - "test_get_test_command.py": "4f52cc92c03a828843e2f744d7609d1e7bfcd2836923b0b0903fd7cfc998185c" + "test_get_test_command.py": "2d2a2009b7574eefe91ca1c0b16c1eceec8dcd6f3ed3998d85b320ea0d49e5e3" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 53da3fba48..a2f5036f26 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -516,9 +516,12 @@ class _Loader(yaml.SafeLoader): # pylint: disable=too-few-public-methods _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"]) + 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]+)$"), @@ -530,15 +533,36 @@ class _Loader(yaml.SafeLoader): # pylint: disable=too-few-public-methods 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) - if key in mapping: + # 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[key] = loader.construct_object(value_node, deep=deep) - return mapping + 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) @@ -845,14 +869,19 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, work_budget: Optional[list] = None, expand_budget: Optional[list] = None) -> bool: """Return True when ``rel_parts`` matches the workspace globs' include/exclude - semantics: at least one positive pattern matches and no ``!`` exclusion does. - - Exclusions (a leading ``!``, e.g. pnpm's ``!**/test/**``) are honored, and - brace alternations are expanded before matching. 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. + semantics, evaluated **in declaration order** (last matching pattern wins): a + positive pattern includes, a ``!`` exclusion excludes, and a later positive + *re-includes* a previously-excluded path. This matches both npm's + ``@npmcli/map-workspaces`` and pnpm's ``@pnpm/matcher`` (a later + ``packages/legacy/app`` after ``!packages/legacy/**`` re-includes that package); + treating every exclusion as permanent would wrongly deny such a member. + + 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) @@ -869,7 +898,7 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # 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] - positives, negatives = [], [] + member = False for raw in globs: # Do NOT strip surrounding whitespace: workspace tools treat it # literally, so `" packages/* "` is a package literally named with @@ -913,10 +942,13 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, for pat in expanded: if _concrete_pattern_unsupported(pat): raise _PatternBudgetError - (negatives if negated else positives).extend(expanded) - if not any(_relative_matches_workspace_glob(rel_parts, p, cells) for p in positives): - return False - return not any(_relative_matches_workspace_glob(rel_parts, n, cells) for n in negatives) + # Order-dependent: if this raw glob matches (any expansion), it sets + # membership to its polarity — a later positive can re-include a path an + # earlier ``!`` excluded, and vice versa. + if any(_relative_matches_workspace_glob(rel_parts, p, cells) + for p in expanded): + member = not negated + return member except (_PatternBudgetError, RecursionError): # Pathological pattern from an untrusted manifest (brace bomb, ``**`` # wall, or deep nesting) → cannot be evaluated safely, so membership is diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 1b1a0a3a75..579ef05a9b 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,11 +26,11 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics: a member matches at least one positive glob and no `!`-prefixed exclusion glob (e.g. pnpm's `!**/test/**`). Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics evaluated **in declaration order, last matching pattern wins**: a positive glob includes, a `!`-prefixed exclusion (e.g. pnpm's `!**/test/**`) excludes, and a *later* positive RE-INCLUDES a path an earlier exclusion removed (so `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` makes `packages/legacy/app` a member) — matching both npm's `@npmcli/map-workspaces` and pnpm's `@pnpm/matcher`. Treating every exclusion as permanent (a plain "matches a positive AND no exclusion") is WRONG — it denies legitimately re-included members. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 **core schema**, not PyYAML's default YAML 1.1 — and the 1.1 implicit-resolver table MUST be replaced wholesale, not merely patched, because the discrepancy runs both directions. Forms YAML 1.2 resolves as non-strings (`true`/`false`, `null`/`~`, `[-+]?[0-9]+`/`0o…`/`0x…` ints, and exponent/`.inf`/`.nan` floats) are non-string entries that MUST be rejected (so unquoted `0o12`/`1e3` reject the entry as pnpm does). Conversely, forms YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS — `yes`/`no`/`on`/`off`, `0b10` binary, `1:20` sexagesimal, `2020-01-01` timestamps, `1_000` underscore ints — are valid string globs that MUST NOT reject the declaration (a quoted `"0o12"`, a string in both, likewise stays a valid glob). Duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed. +R8 (MUST): **Fail closed** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 **core schema**, not PyYAML's default YAML 1.1 — and the 1.1 implicit-resolver table MUST be replaced wholesale, not merely patched, because the discrepancy runs both directions. Forms YAML 1.2 resolves as non-strings (`true`/`false`, `null`/`~`/**the empty scalar** — an empty list item `- ` is null, MUST reject the declaration, `[-+]?[0-9]+`/`0o…`/`0x…` ints, and exponent/`.inf`/`.nan` floats) are non-string entries that MUST be rejected (so unquoted `0o12`/`1e3` reject the entry as pnpm does). Conversely, forms YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS — `yes`/`no`/`on`/`off`, `0b10` binary, `1:20` sexagesimal, `2020-01-01` timestamps, `1_000` underscore ints — are valid string globs that MUST NOT reject the declaration (a quoted `"0o12"`, a string in both, likewise stays a valid glob). Integers MUST also be *constructed* per YAML 1.2 — an ordinary digit run is base-10 (`012` is 12, not octal 10) — so that duplicate mapping keys such as `012:` and `12:` (both integer 12) are detected. Duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed (compared by YAML tag plus canonical value, so `012`/`12` collide while a string `"12"` and int `12` stay distinct). R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 24320dc235..b64ec38639 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -715,6 +715,47 @@ def test_pnpm_yaml_duplicate_keys_fail_closed(self, tmp_path): "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" From 3248eed6a2bb40d3ce3de37c13d2115e73a4ff5f Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 22:35:06 -0700 Subject: [PATCH 36/56] fix: verify-template injection, JSON constants, one-pass leading norm, matcher DoS (round-27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-27 review (Codex gpt-5.6-sol xhigh) found one high and three medium: F1 (high, agentic_fix.py): a verify-command TEMPLATE that nests a placeholder in its own quotes (`pytest "{test}"`) defeated shlex.quote — the inserted single quotes become literal inside the double quotes, so a `$(...)` in the repo path was executed (preflight and final gate). Substitution now goes through `_substitute_verify_template`, which requires each `{test}`/`{cwd}` to be a standalone bare word (bounded by whitespace/ends) and REFUSES the template otherwise — the gate fails closed instead of building an injectable command. F2 (medium): `json.loads` accepts `NaN`/`Infinity`/`-Infinity`, which npm's strict JSON parser rejects, so a manifest containing them (even outside `workspaces`) falsely proved membership. Both package.json and lerna.json now pass a `parse_constant` callback that fails the whole-document parse closed. F3 (medium): leading normalization stripped `/` and then `./` independently (two passes), collapsing `/./packages/*` to `packages/*` and falsely matching `packages/app`. It is now a single pass (`_strip_one_leading`: one `./` OR a leading `/`-run), used identically for matching and `#`-comment classification; a residual `/`/`./` is significant and does not match. F4 (medium): the UTF-16 `?` matcher rebuilt unit arrays per DP cell and did unbudgeted per-character work (~11s within the cell cap). Units and dot-flags are now precomputed once per segment (`_segments_dp_match`), character comparisons are charged against the shared work budget, and a `**`-free pattern whose segment count differs from the path is fast-rejected before the DP. Regressions added for all four. Prompts (get_test_command R6/R8/R9, agentic_fix) updated; get_test_command meta re-synced (agentic_fix has no meta); E2E green; 163 get_test_command + 54 other tests pass; lint 9.80. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/agentic_fix.py | 59 ++++++-- pdd/get_test_command.py | 155 ++++++++++++++------- pdd/prompts/agentic_fix_python.prompt | 2 +- pdd/prompts/get_test_command_python.prompt | 6 +- tests/test_agentic_fix.py | 27 ++++ tests/test_get_test_command.py | 48 +++++-- 7 files changed, 227 insertions(+), 78 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index bd1303fbd1..d014711a00 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -2,12 +2,12 @@ "pdd_version": "0.0.302.dev0", "timestamp": "2026-07-12T21:48:31.995075+00:00", "command": "test", - "prompt_hash": "f4f98bbb8c0dc677466c0942361d4d52d9d12033a6f089f63107f695691f6e62", - "code_hash": "887c7254eab8db54be8dbb7b5ae500b3ec78297f0193c473e505e12e8981afee", + "prompt_hash": "eb365b1ead8180c575022d38171a5690e1470f0a394867a2783e6f56abc2d26a", + "code_hash": "60faf9b185f47ed2bed835fcae4bd77c293805d99745c8e0df5f223fe327eacd", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "2d2a2009b7574eefe91ca1c0b16c1eceec8dcd6f3ed3998d85b320ea0d49e5e3", + "test_hash": "06ae8406691df242e79af692ae158473be48dfb864b464ec67d848be8867f533", "test_files": { - "test_get_test_command.py": "2d2a2009b7574eefe91ca1c0b16c1eceec8dcd6f3ed3998d85b320ea0d49e5e3" + "test_get_test_command.py": "06ae8406691df242e79af692ae158473be48dfb864b464ec67d848be8867f533" }, "include_deps": { "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", diff --git a/pdd/agentic_fix.py b/pdd/agentic_fix.py index 63fa4d7cce..9ecfea5aff 100644 --- a/pdd/agentic_fix.py +++ b/pdd/agentic_fix.py @@ -117,6 +117,36 @@ def _run_testcmd(cmd: str, cwd: Path) -> bool: return proc.returncode == 0 +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, or return ``None`` when the template is unsafe. + + ``shlex.quote`` wraps its value in single quotes, which only neutralizes shell + metacharacters when the placeholder is a standalone *bare word*. If the template + instead nests a placeholder inside its own quotes (``pytest "{test}"``) or + adjoins it to another token, the inserted single quotes become literal and a + ``$(...)``/backtick in the resolved path is still executed by the shell. So each + placeholder occurrence must be bounded by whitespace or a string end; otherwise + the template is refused (``None``) rather than turned into an injectable command. + """ + for placeholder in ("{test}", "{cwd}"): + start = 0 + while True: + i = template.find(placeholder, start) + if i == -1: + break + end = i + len(placeholder) + before_ok = i == 0 or template[i - 1].isspace() + after_ok = end == len(template) or template[end].isspace() + if not (before_ok and after_ok): + return None # placeholder inside quotes / a token → injectable + start = end + return template.replace( + "{test}", shlex.quote(str(Path(unit_test_file).resolve())) + ).replace("{cwd}", shlex.quote(str(cwd))) + + def _verify_and_log(unit_test_file: str, cwd: Path, *, verify_cmd: Optional[str], enabled: bool, verify_cmd_is_template: bool = False) -> bool: """ @@ -138,9 +168,13 @@ def _verify_and_log(unit_test_file: str, cwd: Path, *, verify_cmd: Optional[str] return True if verify_cmd: if verify_cmd_is_template: - cmd = verify_cmd.replace( - "{test}", shlex.quote(str(Path(unit_test_file).resolve())) - ).replace("{cwd}", shlex.quote(str(cwd))) + 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) @@ -306,15 +340,18 @@ def _is_useless_error_content(content: str) -> bool: try: lang = get_language(os.path.splitext(code_path)[1]) env_tpl = os.getenv("PDD_AGENTIC_VERIFY_CMD") - pre_cmd = env_tpl or default_verify_cmd_for(lang, unit_test_file) + # 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: - # Only a user template's `{test}`/`{cwd}` placeholders are - # substituted (shell-quoted); a finalized default command runs - # as-is so its quoting is not corrupted (injection). - if env_tpl: - pre_cmd = env_tpl.replace( - "{test}", shlex.quote(str(Path(unit_test_file).resolve())) - ).replace("{cwd}", shlex.quote(str(working_dir))) pre = subprocess.run( ["bash", "-lc", pre_cmd], capture_output=True, text=True, check=False, diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index a2f5036f26..5937529698 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -217,15 +217,29 @@ def _raw_glob_unsupported(raw: str) -> bool: return "\\" in raw +def _strip_one_leading(pattern: str) -> str: + """Strip exactly ONE leading prefix token — npm normalizes a leading run of + ``/`` OR a single ``./``, once — and return the remainder. A prefix left over + afterward 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. (One pass, matching @npmcli/map-workspaces + rather than independently stripping ``/`` and then ``./``.)""" + if pattern.startswith("./"): + return pattern[2:] + if pattern.startswith("/"): + return pattern.lstrip("/") + return pattern + + def _effective_leading(pattern: str) -> str: - """Return ``pattern`` with the SAME leading normalization the matcher applies — - all leading ``/`` and at most one leading ``./`` removed — so a caller can - classify the effective first segment (e.g. a ``#`` comment) consistently with - matching. A second/further leading ``.`` segment is significant and is kept.""" - eff = pattern.lstrip("/") - if eff.startswith("./"): - eff = eff[2:].lstrip("/") - return eff + """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: @@ -314,7 +328,8 @@ class TestCommand: def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, - cell_budget: Optional[list] = None) -> bool: + cell_budget: Optional[list] = None, + work_budget: Optional[list] = None) -> bool: """Match a package's path segments against a single workspace glob pattern. Supports the segment semantics workspace tools use: ``*`` matches exactly one @@ -339,26 +354,49 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, # Cheap guard before allocating the split list (a "slash wall" attack). if pattern.count("/") > _MAX_GLOB_SEGMENTS: raise _PatternBudgetError - # Drop empty segments (from ``//`` or a trailing ``/``) and AT MOST ONE leading - # ``./`` (npm normalizes a single leading current-dir). Every *subsequent* ``.`` - # segment — leading (``././packages`` keeps the second) or internal - # (``packages/./x``) — is significant and kept, so such a glob must not be - # collapsed to ``packages/x`` and falsely prove membership. - pat_parts = [p for p in pattern.strip("/").split("/") if p != ""] - if pat_parts and pat_parts[0] == ".": - pat_parts.pop(0) + # 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. + rest = _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) - # (``?`` now matches over UTF-16 code units in ``_wildcard_segment_match``, giving - # exact minimatch parity for astral characters — no separate astral guard needed.) + # 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) + + +def _segments_dp_match(rel: list, pat_parts: list, work: list) -> 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, ``dot:false``). 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.""" + 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] + 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 @@ -366,16 +404,19 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, 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): - seg_is_dot = rel[i].startswith(".") for j in range(m - 1, -1, -1): - head = pat_parts[j] - if head == "**": + if 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 (not seg_is_dot and dp[i + 1][j]) + dp[i][j] = dp[i][j + 1] or (not rel_is_dot[i] and dp[i + 1][j]) + elif 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] = _segment_matches(rel[i], head) and dp[i + 1][j + 1] + dp[i][j] = (dp[i + 1][j + 1] + and _wildcard_units_match(rel_units[i], pat_units[j], work)) return dp[0][0] @@ -396,28 +437,32 @@ def _utf16_units(text: str) -> list: return units -def _wildcard_segment_match(name: str, pat: str) -> bool: - """Match one path segment ``name`` against one glob segment ``pat`` in the - matcher's *own* supported language — literal characters plus ``*`` (any run, - including empty) and ``?`` (exactly one UTF-16 code unit) — and NOTHING else. - - Matching is over UTF-16 code units so ``?`` has exact minimatch parity even for - astral characters (``?`` matches one surrogate unit; an emoji needs ``??``). - Every other character, including ``[ ] ( ) { } ^ ! $`` and ``.``, is a literal. - Unsupported minimatch constructs (real ``[...]`` classes, extglobs, ranges) never - reach here — they fail membership closed at the guard — so 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: no recursion, no - regex, O(len(name) * number of ``*``) time and O(1) space.""" +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 - name_u, pat_u = _utf16_units(name), _utf16_units(pat) 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] == q or pat_u[p] == name_u[s]): s += 1 p += 1 @@ -435,13 +480,10 @@ def _wildcard_segment_match(name: str, pat: str) -> bool: return p == npat -def _segment_matches(name: str, pattern_segment: str) -> bool: - """Match a single path segment with minimatch ``dot:false`` semantics: a - wildcard pattern segment does not match a ``name`` that begins with ``.`` - unless the pattern segment itself begins with ``.``.""" - if name.startswith(".") and not pattern_segment.startswith("."): - return False - return _wildcard_segment_match(name, pattern_segment) +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_globs_for(ancestor: Path, cache: Optional[dict] = None) -> list: @@ -607,7 +649,8 @@ def _workspace_globs_uncached(ancestor: Path) -> list: if manifest_path.exists(): text = _read_manifest_text(manifest_path) try: - manifest = json.loads(text) if text is not None else {} + 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. @@ -621,7 +664,8 @@ def _workspace_globs_uncached(ancestor: Path) -> list: if lerna_path.exists(): text = _read_manifest_text(lerna_path) try: - lerna = json.loads(text) if text is not None else None + 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): @@ -637,6 +681,15 @@ def _workspace_globs_uncached(ancestor: Path) -> list: 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 @@ -945,7 +998,7 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # Order-dependent: if this raw glob matches (any expansion), it sets # membership to its polarity — a later positive can re-include a path an # earlier ``!`` excluded, and vice versa. - if any(_relative_matches_workspace_glob(rel_parts, p, cells) + if any(_relative_matches_workspace_glob(rel_parts, p, cells, work) for p in expanded): member = not negated return member diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index 0a0935c89b..a94e6e2fad 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -8,7 +8,7 @@ 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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`); a *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient environment state. + - **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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) — but ONLY when each placeholder is a **standalone, unquoted bare word** (bounded by whitespace or a string end). `shlex.quote` wraps its value in single quotes, which only neutralizes metacharacters at a bare word; if the template instead nests a placeholder inside its own quotes (`pytest "{test}"`) or adjoins it to another token, the inserted single quotes become literal and a `$(...)`/backtick in the resolved path is still executed — so such a template MUST be refused (the verification gate fails closed) rather than turned into an injectable command. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient 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. diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 579ef05a9b..d3cc67c23f 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,13 +26,13 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics evaluated **in declaration order, last matching pattern wins**: a positive glob includes, a `!`-prefixed exclusion (e.g. pnpm's `!**/test/**`) excludes, and a *later* positive RE-INCLUDES a path an earlier exclusion removed (so `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` makes `packages/legacy/app` a member) — matching both npm's `@npmcli/map-workspaces` and pnpm's `@pnpm/matcher`. Treating every exclusion as permanent (a plain "matches a positive AND no exclusion") is WRONG — it denies legitimately re-included members. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). At most ONE leading `./` may be normalized away; a second/further leading `.` segment is significant (`././packages/*` must NOT match `packages/app`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics evaluated **in declaration order, last matching pattern wins**: a positive glob includes, a `!`-prefixed exclusion (e.g. pnpm's `!**/test/**`) excludes, and a *later* positive RE-INCLUDES a path an earlier exclusion removed (so `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` makes `packages/legacy/app` a member) — matching both npm's `@npmcli/map-workspaces` and pnpm's `@pnpm/matcher`. Treating every exclusion as permanent (a plain "matches a positive AND no exclusion") is WRONG — it denies legitimately re-included members. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE — a single leading `./` OR a leading run of `/`, whichever the pattern starts with — using the identical normalized value for matching and `#`-comment classification. A prefix left over after that one pass is significant: `/./packages/*` normalizes to `./packages/*` (needing a literal `.` segment) and `././packages/*` keeps its second `./`, so NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse them). `./packages/*`, `/packages/*`, and `//packages/*` DO match. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 **core schema**, not PyYAML's default YAML 1.1 — and the 1.1 implicit-resolver table MUST be replaced wholesale, not merely patched, because the discrepancy runs both directions. Forms YAML 1.2 resolves as non-strings (`true`/`false`, `null`/`~`/**the empty scalar** — an empty list item `- ` is null, MUST reject the declaration, `[-+]?[0-9]+`/`0o…`/`0x…` ints, and exponent/`.inf`/`.nan` floats) are non-string entries that MUST be rejected (so unquoted `0o12`/`1e3` reject the entry as pnpm does). Conversely, forms YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS — `yes`/`no`/`on`/`off`, `0b10` binary, `1:20` sexagesimal, `2020-01-01` timestamps, `1_000` underscore ints — are valid string globs that MUST NOT reject the declaration (a quoted `"0o12"`, a string in both, likewise stays a valid glob). Integers MUST also be *constructed* per YAML 1.2 — an ordinary digit run is base-10 (`012` is 12, not octal 10) — so that duplicate mapping keys such as `012:` and `12:` (both integer 12) are detected. Duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed (compared by YAML tag plus canonical value, so `012`/`12` collide while a string `"12"` and int `12` stay distinct). +R8 (MUST): **Fail closed** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). For JSON (`package.json`, `lerna.json`), the non-standard constants `NaN`/`Infinity`/`-Infinity` — which Python's `json` accepts but strict JSON (npm/Node's `JSON.parse`) rejects — MUST fail the whole-document parse closed even when they sit outside the workspace field (use `parse_constant`), matching npm's rejection. A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 **core schema**, not PyYAML's default YAML 1.1 — and the 1.1 implicit-resolver table MUST be replaced wholesale, not merely patched, because the discrepancy runs both directions. Forms YAML 1.2 resolves as non-strings (`true`/`false`, `null`/`~`/**the empty scalar** — an empty list item `- ` is null, MUST reject the declaration, `[-+]?[0-9]+`/`0o…`/`0x…` ints, and exponent/`.inf`/`.nan` floats) are non-string entries that MUST be rejected (so unquoted `0o12`/`1e3` reject the entry as pnpm does). Conversely, forms YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS — `yes`/`no`/`on`/`off`, `0b10` binary, `1:20` sexagesimal, `2020-01-01` timestamps, `1_000` underscore ints — are valid string globs that MUST NOT reject the declaration (a quoted `"0o12"`, a string in both, likewise stays a valid glob). Integers MUST also be *constructed* per YAML 1.2 — an ordinary digit run is base-10 (`012` is 12, not octal 10) — so that duplicate mapping keys such as `012:` and `12:` (both integer 12) are detected. Duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed (compared by YAML tag plus canonical value, so `012`/`12` collide while a string `"12"` and int `12` stay distinct). -R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. +R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. The *segment matcher* likewise MUST bound its per-character work: a manifest of many long wildcard-heavy globs against a deep path can stay within the DP-cell cap yet burn CPU if each cell reconstructs per-character state — so precompute per-segment data once (not per DP cell), charge character comparisons against a shared budget, and fast-reject a `**`-free pattern whose segment count differs from the path (it can never match). Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. 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. diff --git a/tests/test_agentic_fix.py b/tests/test_agentic_fix.py index 5a4e15acd9..20729ae4dd 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, ) @@ -243,6 +244,32 @@ def test_verify_and_log_template_shell_quotes_path(self, tmp_path): 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.""" + 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 + # Quoted / adjacent placeholders are refused (None). + for tpl in ('pytest "{test}"', "pytest '{test}'", "pytest {test}x", + 'cd "{cwd}" && pytest {test}'): + assert _substitute_verify_template(tpl, str(test_file), tmp_path) is None, tpl + # 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 diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index b64ec38639..7012b1bf96 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1185,14 +1185,46 @@ def test_literal_bracket_forms_are_not_reinterpreted_by_fnmatch(self): assert _has_complete_bracket_class("packages/[]]") is True assert _package_matches_workspace(("packages", "a"), ["packages/[^]]"]) is False - def test_at_most_one_leading_dot_slash_is_normalized(self): - """npm normalizes only ONE leading ``./``; a second leading ``.`` segment is - significant. ``././packages/*`` therefore does NOT match ``packages/app``, - while a single ``./packages/*`` still does.""" - assert _package_matches_workspace(("packages", "app"), ["././packages/*"]) is False - assert _package_matches_workspace(("packages", "app"), [".//./packages/*"]) is False - assert _package_matches_workspace(("packages", "app"), ["./packages/*"]) is True - assert _package_matches_workspace(("packages", "app"), ["packages/*"]) is True + def test_leading_prefix_normalized_exactly_once(self): + """npm applies leading normalization exactly ONCE — a single leading ``./`` + OR a leading run of ``/``. A residual ``./`` afterward is significant, so + ``/./packages/*`` (→ ``./packages/*``), ``//./packages/*``, and + ``././packages/*`` do NOT match ``packages/app``, while a single ``./`` or a + leading ``/`` does. (Independently stripping ``/`` and then ``./`` would + wrongly collapse ``/./packages/*`` to ``packages/*``.)""" + for glob in ("/./packages/*", "//./packages/*", "././packages/*", + ".//./packages/*"): + assert _package_matches_workspace(("packages", "app"), [glob]) is False, glob + for glob in ("packages/*", "./packages/*", "/packages/*", "//packages/*"): + assert _package_matches_workspace(("packages", "app"), [glob]) is True, glob + + 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 From cfbca951a04722799aa1bc816ba478a8077cff1a Mon Sep 17 00:00:00 2001 From: Serhan Date: Sun, 12 Jul 2026 23:25:50 -0700 Subject: [PATCH 37/56] fix(get_test_command): source-dependent workspace membership + shell-safe substitution (round-28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-28 adversarial review (gpt-5.6-sol xhigh @ 4cf4e56d1) — 7 findings, all fixed: - F1/F2/F3 (HIGH): route every {file}/{test}/{cwd} template substitution through shell_safe_substitute (single-pass, shell-lexical-context-aware). A sequential str.replace rescanned inserted values, and a whitespace-bounded bare-word check did not prove a placeholder was UNQUOTED (`echo " {test} "` inside double quotes defeats shlex.quote). Now the helper tracks single/double/backtick state and refuses any quoted or adjacent placeholder (fails closed). Applied in get_test_command CSV path, get_run_command_for_file, agentic_fix template, agentic_langtest, and the grounding example. - F4 (med): resolve a relative template {test} against the passed cwd (the dir run_agentic_fix operates in), not the process CWD. - F5 (med): leading-prefix normalization is one regex ^\.?/+ (optional dot + the entire following slash run, single pass), so `.//`/`.///packages/*` collapse to `packages/*` and match, while `././`/`/./` residuals stay significant. - F6 (HIGH): membership combination rule is source-dependent — npm/yarn/lerna (@npmcli/map-workspaces) treat an exclusion as terminal (any-exclude-wins); only pnpm (@pnpm/matcher) is order-dependent (last-match-wins re-inclusion). Thread an `ordered` flag from _workspace_source_is_pnpm. (Corrects R26-3, which had over-generalized pnpm's re-inclusion to npm.) - F7 (med): recognize every Jest config extension (.js/.mjs/.cjs/.json/.ts/.mts/ .cts) plus .cjs/.mts/.cts for playwright/vitest, so a project whose only config is jest.config.cjs is detected as Jest instead of falling through to tsx. Adds negative regressions for each finding; updates the get_test_command and agentic_fix prompts and re-syncs .pdd/meta/get_test_command_python.json (prompt + code + test + agentic_langtest/example include-dep hashes changed). Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 14 +-- context/agentic_langtest_example.py | 18 ++-- pdd/agentic_fix.py | 38 +++---- pdd/agentic_langtest.py | 16 ++- pdd/get_run_command.py | 61 ++++++++++- pdd/get_test_command.py | 115 ++++++++++++++------- pdd/prompts/agentic_fix_python.prompt | 2 +- pdd/prompts/get_test_command_python.prompt | 6 +- tests/test_agentic_fix.py | 16 +++ tests/test_get_run_command.py | 47 ++++++++- tests/test_get_test_command.py | 41 ++++++-- 11 files changed, 283 insertions(+), 91 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index d014711a00..59b3876696 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,17 +1,17 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-12T21:48:31.995075+00:00", + "timestamp": "2026-07-13T06:23:55.099663+00:00", "command": "test", - "prompt_hash": "eb365b1ead8180c575022d38171a5690e1470f0a394867a2783e6f56abc2d26a", - "code_hash": "60faf9b185f47ed2bed835fcae4bd77c293805d99745c8e0df5f223fe327eacd", + "prompt_hash": "30f89a98c53f9887f9cb67536c6b28480ed7d737d3d30d703ee37a80a368c101", + "code_hash": "4c95b94dfcbff2721abc35067c7307a949c65343ddc0946ab7a0a51f8a9e5d11", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "06ae8406691df242e79af692ae158473be48dfb864b464ec67d848be8867f533", + "test_hash": "85c72aefeae744bcbedba6dc42806af97ebe667b1e6c63512e1001ab431b0103", "test_files": { - "test_get_test_command.py": "06ae8406691df242e79af692ae158473be48dfb864b464ec67d848be8867f533" + "test_get_test_command.py": "85c72aefeae744bcbedba6dc42806af97ebe667b1e6c63512e1001ab431b0103" }, "include_deps": { - "context/agentic_langtest_example.py": "d3c676b0c12a7848ce428428e26cb28e6a255eedee28566d3d86264359a10d0a", - "pdd/agentic_langtest.py": "5257c999b30c44063f0ed76c0f680a591391d7e1674887bdf9fe15cef7cce02b", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", + "pdd/agentic_langtest.py": "441f56ac5f74f56b67ee01d66f0f2cea38eaa0648461513ab45d6be872432372", "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" } diff --git a/context/agentic_langtest_example.py b/context/agentic_langtest_example.py index 3081a16c95..4d332e3814 100644 --- a/context/agentic_langtest_example.py +++ b/context/agentic_langtest_example.py @@ -6,6 +6,8 @@ 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. @@ -71,18 +73,22 @@ def default_verify_cmd_for(lang: str, unit_test_file: str) -> str | None: shell-quoted). 3. Otherwise `None`, so agentic mode handles test discovery/execution. - The command is executed by callers with `bash -lc` / `shell=True`, so every - path spliced into it MUST be shell-quoted (`shlex.quote`). Bare double quotes - are NOT enough — `"$(...)"` is still expanded inside double quotes — so an - unquoted path with spaces or shell metacharacters (`$()`, `;`, …) would be - re-split or run as a command-injection vector. + 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). """ lang = lang.lower() # 1. CSV lookup by language name. csv_cmd = _run_test_command_for_language(lang) if csv_cmd: - return csv_cmd.replace("{file}", shlex.quote(unit_test_file)) + substituted = shell_safe_substitute(csv_cmd, {"{file}": unit_test_file}) + if substituted is not None: + return substituted # 2. Hardcoded Python fallback. if lang == "python": diff --git a/pdd/agentic_fix.py b/pdd/agentic_fix.py index 9ecfea5aff..3084a977fe 100644 --- a/pdd/agentic_fix.py +++ b/pdd/agentic_fix.py @@ -11,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 @@ -120,31 +120,19 @@ def _run_testcmd(cmd: str, cwd: Path) -> 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, or return ``None`` when the template is unsafe. - - ``shlex.quote`` wraps its value in single quotes, which only neutralizes shell - metacharacters when the placeholder is a standalone *bare word*. If the template - instead nests a placeholder inside its own quotes (``pytest "{test}"``) or - adjoins it to another token, the inserted single quotes become literal and a - ``$(...)``/backtick in the resolved path is still executed by the shell. So each - placeholder occurrence must be bounded by whitespace or a string end; otherwise - the template is refused (``None``) rather than turned into an injectable command. + 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). + + The test path is resolved against the supplied ``cwd`` (not the process CWD), so + a relative ``unit_test_file`` targets the same file ``run_agentic_fix`` resolved. + Substitution is single-pass, so a value containing a literal ``{cwd}``/``{test}`` + is never rescanned as another placeholder. """ - for placeholder in ("{test}", "{cwd}"): - start = 0 - while True: - i = template.find(placeholder, start) - if i == -1: - break - end = i + len(placeholder) - before_ok = i == 0 or template[i - 1].isspace() - after_ok = end == len(template) or template[end].isspace() - if not (before_ok and after_ok): - return None # placeholder inside quotes / a token → injectable - start = end - return template.replace( - "{test}", shlex.quote(str(Path(unit_test_file).resolve())) - ).replace("{cwd}", shlex.quote(str(cwd))) + test_abs = str((cwd / unit_test_file).resolve()) + return shell_safe_substitute( + template, {"{test}": test_abs, "{cwd}": str(cwd)}) def _verify_and_log(unit_test_file: str, cwd: Path, *, verify_cmd: Optional[str], diff --git a/pdd/agentic_langtest.py b/pdd/agentic_langtest.py index 5f3088d511..b5cde5493f 100644 --- a/pdd/agentic_langtest.py +++ b/pdd/agentic_langtest.py @@ -14,6 +14,8 @@ 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.""" @@ -55,11 +57,15 @@ 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: - # Shell-quote the substituted path: this command is executed with - # ``shell=True`` by pdd callers, so an unquoted path with spaces or - # shell metacharacters (e.g. ``$()``/``;``) would be re-split or run - # via command substitution — a command-injection vector. - return csv_cmd.replace('{file}', shlex.quote(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": diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 40fc7e36d9..cb9eedd504 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -3,9 +3,63 @@ import os import csv import shlex +from typing import Dict, Optional from pdd.path_resolution import get_default_resolver +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`` wraps a value in single quotes, which only neutralizes shell + metacharacters when the placeholder occupies a *standalone, unquoted bare word*. + A template that nests a placeholder inside its own single/double quotes or + backticks (``printf %s "{file}"``, even ``" {file} "``), or adjoins it to another + token (``{file}x``), would let the inserted quotes become literal and still + execute a ``$(...)``/backtick in the value — so any such placeholder makes the + whole template unsafe (``None``). Substitution is single-pass, so a value that + itself contains a ``{...}`` token is never rescanned as a placeholder. + """ + out: list = [] + i, n = 0, len(template) + in_single = in_double = in_back = False + at_word_boundary = True # position could begin an unquoted bare word + while i < n: + placeholder = next( + (k for k in values if template.startswith(k, i)), None) + if placeholder is not None: + end = i + len(placeholder) + after = template[end] if end < n else " " + if (in_single or in_double or in_back) or not at_word_boundary \ + or not after.isspace(): + return None + out.append(shlex.quote(values[placeholder])) + i = end + at_word_boundary = False + continue + char = template[i] + if char == "\\" and not in_single: + out.append(char) + if i + 1 < n: + out.append(template[i + 1]) + i += 2 + else: + i += 1 + at_word_boundary = False + continue + if char == "'" and not in_double and not in_back: + in_single = not in_single + elif char == '"' and not in_single and not in_back: + in_double = not in_double + elif char == "`" and not in_single: + in_back = not in_back + out.append(char) + at_word_boundary = char.isspace() and not (in_single or in_double or in_back) + i += 1 + return "".join(out) + + def get_run_command(extension: str) -> str: """ Retrieves the run command for a given file extension. @@ -76,5 +130,8 @@ def get_run_command_for_file(file_path: str) -> str: # 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 — a command-injection vector. - return run_command_template.replace('{file}', shlex.quote(file_path)) + # 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 5937529698..94c7053de3 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -19,6 +19,7 @@ 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 @@ -217,18 +218,17 @@ def _raw_glob_unsupported(raw: str) -> bool: return "\\" in raw +_LEADING_PREFIX_RE = re.compile(r"^\.?/+") + + def _strip_one_leading(pattern: str) -> str: - """Strip exactly ONE leading prefix token — npm normalizes a leading run of - ``/`` OR a single ``./``, once — and return the remainder. A prefix left over - afterward 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. (One pass, matching @npmcli/map-workspaces - rather than independently stripping ``/`` and then ``./``.)""" - if pattern.startswith("./"): - return pattern[2:] - if pattern.startswith("/"): - return pattern.lstrip("/") - return pattern + """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: @@ -486,6 +486,17 @@ def _wildcard_segment_match(name: str, pat: str) -> bool: 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). @@ -920,14 +931,20 @@ def _emit(value: str) -> None: 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) -> bool: + expand_budget: Optional[list] = None, + ordered: bool = True) -> bool: """Return True when ``rel_parts`` matches the workspace globs' include/exclude - semantics, evaluated **in declaration order** (last matching pattern wins): a - positive pattern includes, a ``!`` exclusion excludes, and a later positive - *re-includes* a previously-excluded path. This matches both npm's - ``@npmcli/map-workspaces`` and pnpm's ``@pnpm/matcher`` (a later - ``packages/legacy/app`` after ``!packages/legacy/**`` re-includes that package); - treating every exclusion as permanent would wrongly deny such a member. + semantics. The algorithm depends on the declaring tool (``ordered``): + + * ``ordered=True`` (pnpm's ``@pnpm/matcher``): evaluate in DECLARATION ORDER, last + matching pattern wins — a positive includes, a ``!`` exclusion excludes, and a + *later* positive RE-INCLUDES a previously-excluded path + (``["packages/**", "!packages/legacy/**", "packages/legacy/app"]`` includes + ``packages/legacy/app``). This is the default for direct callers. + * ``ordered=False`` (npm/yarn's ``@npmcli/map-workspaces`` and lerna): a member + matches at least one positive AND no ``!`` exclusion — exclusion is TERMINAL, so + a later broad positive does NOT re-include + (``["packages/app", "!packages/app", "packages/*"]`` EXCLUDES ``packages/app``). Brace alternations are expanded before matching (a raw glob matches when any of its expansions does). The brace-expansion count (``expand_budget``), the @@ -951,7 +968,9 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # 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] - member = False + member = False # ordered (pnpm): last-match-wins accumulator + matched_positive = False # unordered (npm/lerna): any positive matched? + matched_negative = False # unordered (npm/lerna): any exclusion matched? for raw in globs: # Do NOT strip surrounding whitespace: workspace tools treat it # literally, so `" packages/* "` is a package literally named with @@ -995,13 +1014,18 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, for pat in expanded: if _concrete_pattern_unsupported(pat): raise _PatternBudgetError - # Order-dependent: if this raw glob matches (any expansion), it sets - # membership to its polarity — a later positive can re-include a path an - # earlier ``!`` excluded, and vice versa. - if any(_relative_matches_workspace_glob(rel_parts, p, cells, work) - for p in expanded): + matches = any(_relative_matches_workspace_glob(rel_parts, p, cells, work) + for p in expanded) + if matches: + # ordered (pnpm): last matching pattern's polarity wins, so a later + # positive re-includes. unordered (npm/lerna): accumulate — an + # exclusion is terminal regardless of a later broad positive. member = not negated - return member + if negated: + matched_negative = True + else: + matched_positive = True + return member if ordered else (matched_positive and not matched_negative) except (_PatternBudgetError, RecursionError): # Pathological pattern from an untrusted manifest (brace bomb, ``**`` # wall, or deep nesting) → cannot be evaluated safely, so membership is @@ -1035,12 +1059,16 @@ def _workspace_root_for(package_dir: Path, cache: Optional[dict] = None, 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): + rel_parts, globs, cell_budget, work_budget, expand_budget, + ordered=ordered): return ancestor if (ancestor / ".git").exists(): break @@ -1139,10 +1167,23 @@ def _lexical_repo_root(test_path: Path) -> Optional[Path]: _RUNNER_CONFIGS = ( - # (command prefix, (config filenames...), spec_only) - ("npx playwright test", ("playwright.config.ts", "playwright.config.js", "playwright.config.mjs"), True), - ("npx jest --no-coverage --runTestsByPath", ("jest.config.js", "jest.config.ts", "jest.config.mjs"), False), - ("npx vitest run", ("vitest.config.ts", "vitest.config.js", "vitest.config.mjs"), False), + # (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), ) @@ -1397,11 +1438,15 @@ def get_test_command_for_file(test_file: str, language: Optional[str] = None) -> if ext in lang_formats: csv_cmd = lang_formats[ext].get('run_test_command', '').strip() if csv_cmd: - # Shell-quote the substituted path: callers run this command string with - # ``shell=True``, so an unquoted path with spaces or shell metacharacters - # (e.g. ``/repo/$(touch PWN)/a.test.ts``) would be re-split or executed - # via command substitution — a command-injection vector. - return TestCommand(command=csv_cmd.replace('{file}', shlex.quote(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 a94e6e2fad..13921a2b85 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -8,7 +8,7 @@ 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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) — but ONLY when each placeholder is a **standalone, unquoted bare word** (bounded by whitespace or a string end). `shlex.quote` wraps its value in single quotes, which only neutralizes metacharacters at a bare word; if the template instead nests a placeholder inside its own quotes (`pytest "{test}"`) or adjoins it to another token, the inserted single quotes become literal and a `$(...)`/backtick in the resolved path is still executed — so such a template MUST be refused (the verification gate fails closed) rather than turned into an injectable command. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient environment state. + - **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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) via a **shell-lexical-context-aware single pass** (`shell_safe_substitute`) — but ONLY when each placeholder occupies a **standalone, unquoted bare word**. Membership as a bare word MUST be decided from the template's actual shell-lexical context (tracking single-quote, double-quote, and backtick state), NOT from mere whitespace bounds: a placeholder surrounded by spaces but sitting *inside* an enclosing quote (`echo " {test} "`) is still quoted and MUST be refused. `shlex.quote` wraps its value in single quotes, which only neutralizes metacharacters at an unquoted bare word; if the template instead nests a placeholder inside its own quotes (`pytest "{test}"`) or adjoins it to another token (`{test}x`), the inserted single quotes become literal and a `$(...)`/backtick in the resolved path is still executed — so such a template MUST be refused (the verification gate fails closed) rather than turned into an injectable command. Substitution MUST be single-pass: an inserted value that itself contains a literal `{test}`/`{cwd}` MUST NOT be rescanned as another placeholder (a sequential `str.replace` chain would re-expand it). Resolve a relative `{test}` path against the supplied `cwd` (not the process CWD), so the substituted path targets the same file `run_agentic_fix` resolved. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient 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. diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index d3cc67c23f..f2101aa241 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -7,7 +7,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul - `_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. # Vocabulary -- **Runner config**: a `playwright.config`, `jest.config`, or `vitest.config` file (`.js`, `.ts`, or `.mjs`). +- **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. - **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. @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics evaluated **in declaration order, last matching pattern wins**: a positive glob includes, a `!`-prefixed exclusion (e.g. pnpm's `!**/test/**`) excludes, and a *later* positive RE-INCLUDES a path an earlier exclusion removed (so `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` makes `packages/legacy/app` a member) — matching both npm's `@npmcli/map-workspaces` and pnpm's `@pnpm/matcher`. Treating every exclusion as permanent (a plain "matches a positive AND no exclusion") is WRONG — it denies legitimately re-included members. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE — a single leading `./` OR a leading run of `/`, whichever the pattern starts with — using the identical normalized value for matching and `#`-comment classification. A prefix left over after that one pass is significant: `/./packages/*` normalizes to `./packages/*` (needing a literal `.` segment) and `././packages/*` keeps its second `./`, so NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse them). `./packages/*`, `/packages/*`, and `//packages/*` DO match. Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics whose *combination rule depends on the declaring source*, because the two ecosystems' matchers genuinely differ. For **pnpm** (a `pnpm-workspace.yaml`, `@pnpm/matcher`) evaluation is **order-dependent, last matching pattern wins**: a positive glob includes, a `!`-prefixed exclusion (e.g. `!**/test/**`) excludes, and a *later* positive RE-INCLUDES a path an earlier exclusion removed (so under pnpm `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` makes `packages/legacy/app` a member). For **npm/yarn/lerna** (`@npmcli/map-workspaces`) an exclusion is **terminal (any-exclude-wins)**: a path matched by ANY `!` exclusion is NOT a member regardless of pattern order, and a later broad positive does NOT re-include it (so under npm `["packages/*", "!packages/app", "packages/app"]` leaves `packages/app` excluded, and `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` leaves `packages/legacy/app` excluded). Choose the rule from the source that actually governs the ancestor — apply pnpm's last-match-wins ONLY when the declaring file is a `pnpm-workspace.yaml`, npm's terminal-exclusion otherwise. Collapsing the two into one rule is WRONG in *both* directions: applying npm's terminal-exclusion to pnpm denies legitimately re-included members, while applying pnpm's last-match-wins to an npm/yarn/lerna manifest fabricates members those tools exclude. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE, as the single regex `^\.?/+` — an optional leading `.` followed by the ENTIRE immediately-following run of `/`, stripped in one pass (NOT a `/` run and a `./` removed independently). So `./packages/*`, `/packages/*`, `//packages/*`, `///packages/*`, and — because the dot plus its whole trailing slash run go together — `.//packages/*` and `.///packages/*` all normalize to `packages/*` and DO match `packages/app`. Use the identical normalized value for matching and `#`-comment classification. A prefix left over AFTER that one pass is significant: the leading `.` is consumed only when a slash run immediately follows it, so `././packages/*` strips just its first `./` and keeps a second literal `.` segment, and `/./packages/*` normalizes to `./packages/*` (a literal `.` segment) — NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse `/./packages/*`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. @@ -42,7 +42,7 @@ R11 (MUST): Target the test path literally. Invoke Jest with `--runTestsByPath` 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. (This is a POSIX-shell command string, matching how all pdd callers run verify commands.) -R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder with the **shell-quoted** input file string (callers execute with `shell=True`, so an unquoted path with spaces or shell metacharacters such as `$()`/`;` would be re-split or injected) and return `cwd=None`. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. +R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder via a shell-lexical-context-aware single pass (`shell_safe_substitute`) and return `cwd=None`. Callers execute with `shell=True`, and `shlex.quote` only neutralizes metacharacters (`$()`/`;`/spaces) when the placeholder is a *standalone, unquoted bare word*: a CSV template that wraps `{file}` in its own quotes (`mocha "{file}"` — the inserted single quotes would become literal and a `$(...)` in the path would still execute) or fuses it to an adjacent token MUST be refused (return None → fall through to the next resolution step) rather than emitted as an injectable command. The substitution MUST be single-pass — an inserted value that itself contains a literal `{file}` MUST NOT be rescanned as another placeholder. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. # Dependencies diff --git a/tests/test_agentic_fix.py b/tests/test_agentic_fix.py index 20729ae4dd..55c874cb4c 100644 --- a/tests/test_agentic_fix.py +++ b/tests/test_agentic_fix.py @@ -287,6 +287,22 @@ def test_verify_and_log_finalized_command_is_not_resubstituted(self, tmp_path): 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_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_get_run_command.py b/tests/test_get_run_command.py index 33f8710ecb..973c5d8fd3 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -1,6 +1,11 @@ import pytest import os -from pdd.get_run_command import get_run_command, get_run_command_for_file +import shlex +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 @@ -129,3 +134,43 @@ 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') + + +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_adjacent_placeholder_is_refused(self): + """A placeholder nested in the template's own quotes, or fused to an adjacent + word, cannot be made safe by ``shlex.quote`` — the helper returns None so the + caller falls through rather than emit an injectable command.""" + for tpl in ('pytest "{file}"', "pytest '{file}'", "pytest {file}x", + "x{file}", "run `{file}`", 'echo "a {file} b"'): + assert shell_safe_substitute(tpl, {"{file}": "x"}) is None, tpl + + 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" diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 7012b1bf96..acffbff568 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -25,6 +25,7 @@ _has_complete_bracket_class, _has_complete_extglob, _MAX_GLOB_LENGTH, + _RUNNER_CONFIGS, _MAX_BRACE_SCAN_WORK, _MAX_MATCH_CELLS, _MAX_BRACE_EXPANSION, @@ -1186,18 +1187,46 @@ def test_literal_bracket_forms_are_not_reinterpreted_by_fnmatch(self): assert _package_matches_workspace(("packages", "a"), ["packages/[^]]"]) is False def test_leading_prefix_normalized_exactly_once(self): - """npm applies leading normalization exactly ONCE — a single leading ``./`` - OR a leading run of ``/``. A residual ``./`` afterward is significant, so + """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``, while a single ``./`` or a - leading ``/`` does. (Independently stripping ``/`` and then ``./`` would - wrongly collapse ``/./packages/*`` to ``packages/*``.)""" + ``././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/*"): + 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): + """npm/yarn/lerna (``@npmcli/map-workspaces``) treat an exclusion as terminal + (any-exclude-wins) — a later broad positive does NOT re-include — while pnpm + (``@pnpm/matcher``) is order-dependent (last-match-wins, re-inclusion). The + ``ordered`` flag selects the algorithm; direct callers default to pnpm.""" + # A broad positive after a specific exclusion. + globs = ["packages/app", "!packages/app", "packages/*"] + assert _package_matches_workspace(("packages", "app"), globs, ordered=False) is False + assert _package_matches_workspace(("packages", "app"), globs, ordered=True) is True + # A specific re-inclusion after a broad exclusion. + reinc = ["packages/**", "!packages/legacy/**", "packages/legacy/app"] + assert _package_matches_workspace( + ("packages", "legacy", "app"), reinc, ordered=False) is False # npm excludes + assert _package_matches_workspace( + ("packages", "legacy", "app"), reinc, ordered=True) is True # pnpm re-includes + # Both algorithms agree on a plain positive and a 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 + + 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 — From 3168017f5bacbfe0f35342f70a257b441207da2b Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 00:08:47 -0700 Subject: [PATCH 38/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-29=20r?= =?UTF-8?q?eview=20fixes=20=E2=80=94=20npm=20re-inclusion,=20shell=20hered?= =?UTF-8?q?oc/adjacency,=20jest/vitest=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-29 adversarial review (gpt-5.6-sol xhigh @ d5dd36279) — 6 blocking + 1 low, all fixed: - F1 (high): shell_safe_substitute accepted here-document and comment templates (`cat < --- .pdd/meta/get_test_command_python.json | 10 +- pdd/agentic_fix.py | 16 +- pdd/get_run_command.py | 52 ++++--- pdd/get_test_command.py | 167 +++++++++++++++++---- pdd/prompts/agentic_fix_python.prompt | 2 +- pdd/prompts/get_test_command_python.prompt | 6 +- tests/test_agentic_fix.py | 27 +++- tests/test_get_run_command.py | 80 +++++++++- tests/test_get_test_command.py | 91 +++++++++-- 9 files changed, 374 insertions(+), 77 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 59b3876696..a1c3cc0983 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T06:23:55.099663+00:00", + "timestamp": "2026-07-13T07:10:00.000000+00:00", "command": "test", - "prompt_hash": "30f89a98c53f9887f9cb67536c6b28480ed7d737d3d30d703ee37a80a368c101", - "code_hash": "4c95b94dfcbff2721abc35067c7307a949c65343ddc0946ab7a0a51f8a9e5d11", + "prompt_hash": "865787d9e9a06d2d73b61a3d3d8e88caa571a2a2a64827828f30053258df7d45", + "code_hash": "c9bd3e5b6b707373f11c0fdaf3ad2ec3765acd631f8b07b23a11932d031295b2", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "85c72aefeae744bcbedba6dc42806af97ebe667b1e6c63512e1001ab431b0103", + "test_hash": "56ba5d5cd02a24ba20101d3b1591ed57ced344270c0b982c0dbd6cde89ef17b1", "test_files": { - "test_get_test_command.py": "85c72aefeae744bcbedba6dc42806af97ebe667b1e6c63512e1001ab431b0103" + "test_get_test_command.py": "56ba5d5cd02a24ba20101d3b1591ed57ced344270c0b982c0dbd6cde89ef17b1" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/agentic_fix.py b/pdd/agentic_fix.py index 3084a977fe..98bfddd869 100644 --- a/pdd/agentic_fix.py +++ b/pdd/agentic_fix.py @@ -125,14 +125,18 @@ def _substitute_verify_template(template: str, unit_test_file: str, placeholder inside quotes/backticks or not a standalone bare word — where ``shlex.quote`` would not actually neutralize a ``$(...)`` in the path). - The test path is resolved against the supplied ``cwd`` (not the process CWD), so - a relative ``unit_test_file`` targets the same file ``run_agentic_fix`` resolved. - Substitution is single-pass, so a value containing a literal ``{cwd}``/``{test}`` - is never rescanned as another placeholder. + 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. """ - test_abs = str((cwd / unit_test_file).resolve()) + 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)}) + template, {"{test}": test_abs, "{cwd}": str(cwd_abs)}) def _verify_and_log(unit_test_file: str, cwd: Path, *, verify_cmd: Optional[str], diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index cb9eedd504..d2278bbd24 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -12,30 +12,45 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str shell-quoted values in a SINGLE left-to-right pass, or return ``None`` when the template is unsafe. - ``shlex.quote`` wraps a value in single quotes, which only neutralizes shell - metacharacters when the placeholder occupies a *standalone, unquoted bare word*. - A template that nests a placeholder inside its own single/double quotes or - backticks (``printf %s "{file}"``, even ``" {file} "``), or adjoins it to another - token (``{file}x``), would let the inserted quotes become literal and still - execute a ``$(...)``/backtick in the value — so any such placeholder makes the - whole template unsafe (``None``). Substitution is single-pass, so a value that - itself contains a ``{...}`` token is never rescanned as a placeholder. + ``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). It is only unsafe where the surrounding context would + let the inserted quoting be reinterpreted: + + * inside the template's own single/double quotes or backticks (``"{file}"``, even + ``" {file} "``) — the value's quotes become literal and a ``$(...)`` in it still + executes; + * immediately preceded by ``$`` (``${file}`` → ``$'...'`` ANSI-C / ``${...}``) or a + backslash (``\\{file}`` escapes the value's opening quote); + * inside a shell comment (``echo hi # {file}``) or here-document body — neither is + an ordinary word context, and a newline in the value would break out of it. + + To keep the analysis sound, a multiline template (any newline — the only way to + form a here-document body) is refused outright, and a placeholder reached in a + comment context is refused. Substitution is single-pass, so a value that itself + contains a ``{...}`` token is never rescanned as a placeholder. """ + # A here-document body requires a newline; comment/quoting analysis below assumes + # a single logical line. Refuse any multiline template rather than model heredocs. + if "\n" in template or "\r" in template: + return None out: list = [] i, n = 0, len(template) in_single = in_double = in_back = False - at_word_boundary = True # position could begin an unquoted bare word + in_comment = False # after an unquoted, word-boundary '#' to end of line + at_word_boundary = True # position could begin an unquoted bare word / comment while i < n: placeholder = next( (k for k in values if template.startswith(k, i)), None) if placeholder is not None: - end = i + len(placeholder) - after = template[end] if end < n else " " - if (in_single or in_double or in_back) or not at_word_boundary \ - or not after.isspace(): + prev = template[i - 1] if i > 0 else "" + if (in_single or in_double or in_back or in_comment) \ + or prev in ("$", "\\"): return None out.append(shlex.quote(values[placeholder])) - i = end + i += len(placeholder) at_word_boundary = False continue char = template[i] @@ -48,12 +63,15 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str i += 1 at_word_boundary = False continue - if char == "'" and not in_double and not in_back: + if char == "'" and not in_double and not in_back and not in_comment: in_single = not in_single - elif char == '"' and not in_single and not in_back: + elif char == '"' and not in_single and not in_back and not in_comment: in_double = not in_double - elif char == "`" and not in_single: + elif char == "`" and not in_single and not in_comment: in_back = not in_back + elif char == "#" and at_word_boundary \ + and not (in_single or in_double or in_back): + in_comment = True out.append(char) at_word_boundary = char.isspace() and not (in_single or in_double or in_back) i += 1 diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 94c7053de3..b52d199541 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -936,15 +936,22 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, """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 — a positive includes, a ``!`` exclusion excludes, and a - *later* positive RE-INCLUDES a previously-excluded path - (``["packages/**", "!packages/legacy/**", "packages/legacy/app"]`` includes - ``packages/legacy/app``). This is the default for direct callers. - * ``ordered=False`` (npm/yarn's ``@npmcli/map-workspaces`` and lerna): a member - matches at least one positive AND no ``!`` exclusion — exclusion is TERMINAL, so - a later broad positive does NOT re-include - (``["packages/app", "!packages/app", "packages/*"]`` EXCLUDES ``packages/app``). + * ``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 @@ -968,9 +975,18 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # 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] - member = False # ordered (pnpm): last-match-wins accumulator - matched_positive = False # unordered (npm/lerna): any positive matched? - matched_negative = False # unordered (npm/lerna): any exclusion matched? + # 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 @@ -1014,18 +1030,52 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, for pat in expanded: if _concrete_pattern_unsupported(pat): raise _PatternBudgetError - matches = any(_relative_matches_workspace_glob(rel_parts, p, cells, work) - for p in expanded) - if matches: - # ordered (pnpm): last matching pattern's polarity wins, so a later - # positive re-includes. unordered (npm/lerna): accumulate — an - # exclusion is terminal regardless of a later broad positive. - member = not negated - if negated: - matched_negative = True - else: - matched_positive = True - return member if ordered else (matched_positive and not matched_negative) + parsed.append((negated, expanded)) + + def _matches_any(pattern_list) -> bool: + return any( + _relative_matches_workspace_glob(rel_parts, p, cells, work) + 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. + member = False + for negated, expanded 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 + # entirely (``minimatch(pattern, negatedPattern)`` upstream), so + # ``["packages/**", "!packages/legacy/**", "packages/legacy/app"]`` drops the + # ``!packages/legacy/**`` exclusion and every ``packages/**`` path — including + # ``packages/legacy/app`` — is a member again. A path is then a member iff it + # matches a surviving positive and no surviving negation. + positives = [] # concrete positive pattern strings, declaration order + negatives = [] # concrete negation pattern strings still in force + for negated, expanded in parsed: + if negated: + negatives.extend(expanded) + else: + for pos in expanded: + # npm applies the same leading normalization to every pattern + # before comparing, so strip it before treating the positive + # pattern string as a literal path for the removal test. + pos_parts = tuple(_strip_one_leading(pos).split("/")) + negatives = [ + neg for neg in negatives + if not _relative_matches_workspace_glob( + pos_parts, neg, cells, work) + ] + positives.append(pos) + if not _matches_any(positives): + return False + return not _matches_any(negatives) except (_PatternBudgetError, RecursionError): # Pathological pattern from an untrusted manifest (brace bomb, ``**`` # wall, or deep nesting) → cannot be evaluated safely, so membership is @@ -1225,10 +1275,75 @@ def _config_allowed(config_path: Path, repo_root: Optional[Path]) -> bool: 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", +) + + +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`` in any dependency map or a script that invokes it. Without such proof an + ordinary Vite *application* (which also has a ``vite.config.*`` but no tests) MUST + NOT be treated as a test project.""" + for key in ("dependencies", "devDependencies", + "peerDependencies", "optionalDependencies"): + deps = manifest.get(key) + if isinstance(deps, dict) and "vitest" in deps: + return True + scripts = manifest.get("scripts") + if isinstance(scripts, dict): + for val in scripts.values(): + if isinstance(val, str) and "vitest" in 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.""" + ``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 @@ -1236,7 +1351,7 @@ def _find_runner_here(search_dir: Path, is_spec: bool, repo_root: Optional[Path] cfg = search_dir / name if cfg.exists() and _config_allowed(cfg, repo_root): return (command, search_dir) - return None + return _package_json_runner(search_dir, is_spec, repo_root) def _detect_ts_test_runner(test_path: Path) -> Optional[Tuple[str, Path]]: diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index 13921a2b85..71f0ea9374 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -8,7 +8,7 @@ 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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) via a **shell-lexical-context-aware single pass** (`shell_safe_substitute`) — but ONLY when each placeholder occupies a **standalone, unquoted bare word**. Membership as a bare word MUST be decided from the template's actual shell-lexical context (tracking single-quote, double-quote, and backtick state), NOT from mere whitespace bounds: a placeholder surrounded by spaces but sitting *inside* an enclosing quote (`echo " {test} "`) is still quoted and MUST be refused. `shlex.quote` wraps its value in single quotes, which only neutralizes metacharacters at an unquoted bare word; if the template instead nests a placeholder inside its own quotes (`pytest "{test}"`) or adjoins it to another token (`{test}x`), the inserted single quotes become literal and a `$(...)`/backtick in the resolved path is still executed — so such a template MUST be refused (the verification gate fails closed) rather than turned into an injectable command. Substitution MUST be single-pass: an inserted value that itself contains a literal `{test}`/`{cwd}` MUST NOT be rescanned as another placeholder (a sequential `str.replace` chain would re-expand it). Resolve a relative `{test}` path against the supplied `cwd` (not the process CWD), so the substituted path targets the same file `run_agentic_fix` resolved. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient environment state. + - **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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) via a **shell-lexical-context-aware single pass** (`shell_safe_substitute`). `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 (`{test}x`, `./{test}`, `{test}.log` all substitute correctly). Safety MUST be decided from the template's actual shell-lexical context, NOT from mere whitespace bounds: the substitution is unsafe — and MUST be refused so the verification gate fails closed — ONLY where the inserted quoting could be reinterpreted: the placeholder inside the template's own single/double quotes or backticks (`pytest "{test}"`, even a space-surrounded `echo " {test} "` sitting *inside* enclosing quotes), immediately preceded by `$` (`${test}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body (`shlex.quote` single-quoting does not stop a `$(...)` in a heredoc body, and a value newline breaks out of a comment). Any multiline template (a newline — the only way to form a heredoc body) is refused outright. Substitution MUST be single-pass: an inserted value that itself contains a literal `{test}`/`{cwd}` MUST NOT be rescanned as another placeholder (a sequential `str.replace` chain would re-expand it). Resolve BOTH `{test}` and `{cwd}` to ABSOLUTE paths anchored at the supplied `cwd` (not the process CWD): a relative `{test}` targets the same file `run_agentic_fix` resolved, and an absolute `{cwd}` prevents a template like `cd {cwd} && pytest {test}` from double-joining a relative `cwd` onto the process directory. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient 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. diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index f2101aa241..c2de151bf0 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -7,7 +7,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul - `_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. # Vocabulary -- **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. +- **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, or a script that invokes `vitest`) — 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. @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics whose *combination rule depends on the declaring source*, because the two ecosystems' matchers genuinely differ. For **pnpm** (a `pnpm-workspace.yaml`, `@pnpm/matcher`) evaluation is **order-dependent, last matching pattern wins**: a positive glob includes, a `!`-prefixed exclusion (e.g. `!**/test/**`) excludes, and a *later* positive RE-INCLUDES a path an earlier exclusion removed (so under pnpm `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` makes `packages/legacy/app` a member). For **npm/yarn/lerna** (`@npmcli/map-workspaces`) an exclusion is **terminal (any-exclude-wins)**: a path matched by ANY `!` exclusion is NOT a member regardless of pattern order, and a later broad positive does NOT re-include it (so under npm `["packages/*", "!packages/app", "packages/app"]` leaves `packages/app` excluded, and `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` leaves `packages/legacy/app` excluded). Choose the rule from the source that actually governs the ancestor — apply pnpm's last-match-wins ONLY when the declaring file is a `pnpm-workspace.yaml`, npm's terminal-exclusion otherwise. Collapsing the two into one rule is WRONG in *both* directions: applying npm's terminal-exclusion to pnpm denies legitimately re-included members, while applying pnpm's last-match-wins to an npm/yarn/lerna manifest fabricates members those tools exclude. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE, as the single regex `^\.?/+` — an optional leading `.` followed by the ENTIRE immediately-following run of `/`, stripped in one pass (NOT a `/` run and a `./` removed independently). So `./packages/*`, `/packages/*`, `//packages/*`, `///packages/*`, and — because the dot plus its whole trailing slash run go together — `.//packages/*` and `.///packages/*` all normalize to `packages/*` and DO match `packages/app`. Use the identical normalized value for matching and `#`-comment classification. A prefix left over AFTER that one pass is significant: the leading `.` is consumed only when a slash run immediately follows it, so `././packages/*` strips just its first `./` and keeps a second literal `.` segment, and `/./packages/*` normalizes to `./packages/*` (a literal `.` segment) — NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse `/./packages/*`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics whose *combination rule depends on the declaring source*, because the two ecosystems' matchers genuinely differ — BOTH honor re-inclusion of a path an earlier `!` exclusion removed, but by different algorithms, so the difference is only visible on the *sibling* of a re-included path. For **pnpm** (a `pnpm-workspace.yaml`, `@pnpm/matcher`) evaluation is **order-dependent, last matching pattern wins PER PATH**: a positive includes, a `!` exclusion (e.g. `!**/test/**`) excludes, and a *later* positive re-includes only the specific paths it matches. For **npm/yarn/lerna** (`@npmcli/map-workspaces`) apply npm's actual **`appendNegatedPatterns` preprocessing**: walking the patterns in order, a later POSITIVE pattern whose *pattern string* is matched by an earlier negation's glob (`minimatch(pattern, negatedPattern)`) REMOVES that negation wholesale; a path is then a member iff it matches a surviving positive and no surviving negation. So for `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` BOTH tools make `packages/legacy/app` a member, but the SIBLING `packages/legacy/other` is a member under **npm** (dropping `!packages/legacy/**` un-excludes the whole subtree) yet NOT under **pnpm** (its last matching pattern is still the exclusion). Likewise a later positive equal to an earlier exclusion (`["packages/*", "!packages/app", "packages/app"]`) re-includes `packages/app` under both. Choose the rule from the source that actually governs the ancestor — apply pnpm's per-path last-match-wins ONLY when the declaring file is a `pnpm-workspace.yaml`, npm's negation-preprocessing otherwise; do NOT model either as a simple terminal "any-exclude-wins" (that denies npm's and pnpm's legitimate re-inclusions) nor collapse the two (npm re-includes the sibling, pnpm does not). **Built-in exclusion (both tools):** a path *inside* any `node_modules/` directory is NEVER a member — npm appends `**/node_modules/**` to its ignore set and pnpm/yarn skip `node_modules` during discovery — so `["**"]` MUST NOT make `node_modules/dep` a member (a leaf directory literally *named* `node_modules` with nothing under it is not itself excluded). npm's leading normalization (below) is applied to every pattern, positive and negation, before these comparisons. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE, as the single regex `^\.?/+` — an optional leading `.` followed by the ENTIRE immediately-following run of `/`, stripped in one pass (NOT a `/` run and a `./` removed independently). So `./packages/*`, `/packages/*`, `//packages/*`, `///packages/*`, and — because the dot plus its whole trailing slash run go together — `.//packages/*` and `.///packages/*` all normalize to `packages/*` and DO match `packages/app`. Use the identical normalized value for matching and `#`-comment classification. A prefix left over AFTER that one pass is significant: the leading `.` is consumed only when a slash run immediately follows it, so `././packages/*` strips just its first `./` and keeps a second literal `.` segment, and `/./packages/*` normalizes to `./packages/*` (a literal `.` segment) — NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse `/./packages/*`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). 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. @@ -42,7 +42,7 @@ R11 (MUST): Target the test path literally. Invoke Jest with `--runTestsByPath` 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. (This is a POSIX-shell command string, matching how all pdd callers run verify commands.) -R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder via a shell-lexical-context-aware single pass (`shell_safe_substitute`) and return `cwd=None`. Callers execute with `shell=True`, and `shlex.quote` only neutralizes metacharacters (`$()`/`;`/spaces) when the placeholder is a *standalone, unquoted bare word*: a CSV template that wraps `{file}` in its own quotes (`mocha "{file}"` — the inserted single quotes would become literal and a `$(...)` in the path would still execute) or fuses it to an adjacent token MUST be refused (return None → fall through to the next resolution step) rather than emitted as an injectable command. The substitution MUST be single-pass — an inserted value that itself contains a literal `{file}` MUST NOT be rescanned as another placeholder. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. +R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder via a shell-lexical-context-aware single pass (`shell_safe_substitute`) and return `cwd=None`. Callers execute with `shell=True`. `shlex.quote` yields a SELF-CONTAINED shell word, which is safe to splice in AND safe to concatenate with ordinary literal characters on either side — so a suffix or prefix template like `gfortran -o {file}.out {file}` or `fpc {file} && ./{file}` (real CSV rows) MUST still substitute and return a command, NOT be rejected as "adjacent"; rejecting them silently disables crash verification for those languages. The substitution is unsafe, and MUST return None (→ fall through to the next resolution step), ONLY where the inserted quoting could be reinterpreted: the placeholder inside the template's own single/double quotes or backticks (`mocha "{file}"` — the inserted quotes become literal and a `$(...)` in the path still executes), immediately preceded by `$` (`${file}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body — and any multiline template (a newline, the only way to form a heredoc body) is refused outright. The substitution MUST be single-pass — an inserted value that itself contains a literal `{file}` MUST NOT be rescanned as another placeholder. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. # Dependencies diff --git a/tests/test_agentic_fix.py b/tests/test_agentic_fix.py index 55c874cb4c..8f39bf017a 100644 --- a/tests/test_agentic_fix.py +++ b/tests/test_agentic_fix.py @@ -250,6 +250,7 @@ def test_verify_template_with_quoted_placeholder_is_refused(self, tmp_path): 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" @@ -258,10 +259,15 @@ def test_verify_template_with_quoted_placeholder_is_refused(self, tmp_path): 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 - # Quoted / adjacent placeholders are refused (None). - for tpl in ('pytest "{test}"', "pytest '{test}'", "pytest {test}x", + # 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, @@ -303,6 +309,23 @@ def test_verify_template_resolves_relative_path_against_cwd(self, tmp_path): 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_get_run_command.py b/tests/test_get_run_command.py index 973c5d8fd3..384a6d8344 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -1,6 +1,9 @@ import pytest import os import shlex +import subprocess +import tempfile +from pathlib import Path from pdd.get_run_command import ( get_run_command, get_run_command_for_file, @@ -135,6 +138,32 @@ def test_get_run_command_for_file_missing_environment(self, monkeypatch): 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. @@ -151,14 +180,53 @@ def test_bare_word_value_is_single_quoted_and_inert(self): assert shlex.split(out) == ["pytest", evil], out assert "$(touch" not in shlex.split(out) - def test_quoted_or_adjacent_placeholder_is_refused(self): - """A placeholder nested in the template's own quotes, or fused to an adjacent - word, cannot be made safe by ``shlex.quote`` — the helper returns None so the - caller falls through rather than emit an injectable command.""" - for tpl in ('pytest "{file}"', "pytest '{file}'", "pytest {file}x", - "x{file}", "run `{file}`", 'echo "a {file} b"'): + 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 < {})") + 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 → adopt vite.config.ts as Vitest config. + (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 script invoking vitest also proves it. + (repo / "package.json").write_text('{"scripts": {"test": "vitest run"}}') + cmd2, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") + assert "npx vitest run" in cmd2, cmd2 + def test_symlink_to_foreign_checkout_is_refused(self, tmp_path): """A symlink whose target is itself a git checkout must not be adopted. From 52c1b87cd6e5eaf82b7aae7a364c4035d5e1d413 Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 00:10:47 -0700 Subject: [PATCH 39/56] chore(meta): re-sync get_test_command fingerprint after merging origin/main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging latest origin/main changed two include-deps of the get_test_command prompt fingerprint — pdd/data/language_format.csv and pdd/get_language.py — so the composite prompt_hash shifted. Recompute prompt_hash + both include-dep hashes with the merged calculate_prompt_hash; code/test/example unchanged. Fingerprint verified mutually consistent; 231 focused tests, E2E, and pylint (E) all green post-merge. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index a1c3cc0983..03d6efa983 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,8 +1,8 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T07:10:00.000000+00:00", + "timestamp": "2026-07-13T07:20:00.000000+00:00", "command": "test", - "prompt_hash": "865787d9e9a06d2d73b61a3d3d8e88caa571a2a2a64827828f30053258df7d45", + "prompt_hash": "7bd03451a23c273d0d5a74fd06e70c138b64c46e5d7796b02c0fcf5c530b9369", "code_hash": "c9bd3e5b6b707373f11c0fdaf3ad2ec3765acd631f8b07b23a11932d031295b2", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", "test_hash": "56ba5d5cd02a24ba20101d3b1591ed57ced344270c0b982c0dbd6cde89ef17b1", @@ -12,7 +12,7 @@ "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", "pdd/agentic_langtest.py": "441f56ac5f74f56b67ee01d66f0f2cea38eaa0648461513ab45d6be872432372", - "pdd/data/language_format.csv": "db3542c10a4c5c2a4a9bbe02558b4bd02fbb357fb243e33de4831d337484373e", - "pdd/get_language.py": "65004add40c93365c1cbd705a6ef24d5bda6f7016c3a67749b25e61122c50ce7" + "pdd/data/language_format.csv": "691a4e14b76c176269e34dbbf03167c8fa17b76be9212e4546750b0a80315ff9", + "pdd/get_language.py": "1490ea5281a086896be3a3f20cc5b216cd79c7974bcf8a6bbd13129656dd8080" } } From 426f6420229225633b61ac2194998fcdfe80594f Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 01:04:22 -0700 Subject: [PATCH 40/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-30b=20?= =?UTF-8?q?review=20fixes=20=E2=80=94=20shell=20expansion=20contexts,=20vi?= =?UTF-8?q?test=20proof,=20npm=20comment=20parity,=20prompt=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-30b adversarial review (gpt-5.6-sol xhigh @ 95e7fc0cc) — 4 blocking findings, all fixed: - F1 (high): shell_safe_substitute still accepted command-evaluation contexts — a placeholder inside arithmetic `$(( ))` / `$( )` / `${ }` / backticks / a `(...)` subshell, and a `#` comment beginning right after a `;`/`&`/`|` control operator (whitespace-only comment detection missed it). Switched to an allowlist of simple command lines: refuse any template containing a newline, `$`, a backtick, or `(`/`)`, and track comment starts after control operators. Real bash -lc regressions for the arithmetic and operator-comment exploits; adjacency + real templates still substitute. - F2 (med): _vitest_proven_by_manifest used substring `"vitest" in val`, so a script `echo no-vitest-installed` false-proved Vitest. Now requires vitest as an invoked command token (basename of a shell token == vitest), covering npx/pnpm exec/yarn/ ./node_modules/.bin wrappers; negative tests for substring-only scripts. - F3 (med): a positive `#` comment pattern was skipped before npm's appendNegatedPatterns preprocessing, so it could not remove an earlier matching negation the way npm does (`["packages/**","!**","#noop"]` must re-include packages/**). Comments now participate in npm negation-removal while never matching a concrete path; pnpm still ignores them. - F4 (med): get_run_command_python.prompt now declares shell_safe_substitute as a required public interface (a regeneration could otherwise drop it, breaking imports in get_test_command/agentic_fix/agentic_langtest); agentic_langtest_python.prompt no longer claims "no internal PDD dependencies". Re-syncs .pdd/meta/get_test_command_python.json (code+test hashes). 233 focused tests pass, E2E green, pylint 10.00. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_run_command.py | 77 ++++++++++++---------- pdd/get_test_command.py | 65 ++++++++++++------ pdd/prompts/agentic_langtest_python.prompt | 5 +- pdd/prompts/get_run_command_python.prompt | 31 +++++++-- tests/test_get_run_command.py | 19 ++++++ tests/test_get_test_command.py | 29 ++++++-- 7 files changed, 164 insertions(+), 70 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 03d6efa983..7b998b62d6 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T07:20:00.000000+00:00", + "timestamp": "2026-07-13T07:45:00.000000+00:00", "command": "test", "prompt_hash": "7bd03451a23c273d0d5a74fd06e70c138b64c46e5d7796b02c0fcf5c530b9369", - "code_hash": "c9bd3e5b6b707373f11c0fdaf3ad2ec3765acd631f8b07b23a11932d031295b2", + "code_hash": "e411badc44c2b5ed7f2a2ac8031afb71c649866d3789b4a424eb90da7ae79626", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "56ba5d5cd02a24ba20101d3b1591ed57ced344270c0b982c0dbd6cde89ef17b1", + "test_hash": "66c6b13d7d92d66f14c1cb40b2d9bbe2551d474bb9edfd53583ff55f0d46c703", "test_files": { - "test_get_test_command.py": "56ba5d5cd02a24ba20101d3b1591ed57ced344270c0b982c0dbd6cde89ef17b1" + "test_get_test_command.py": "66c6b13d7d92d66f14c1cb40b2d9bbe2551d474bb9edfd53583ff55f0d46c703" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index d2278bbd24..1cdbf9693d 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -16,64 +16,73 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str 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). It is only unsafe where the surrounding context would - let the inserted quoting be reinterpreted: - - * inside the template's own single/double quotes or backticks (``"{file}"``, even - ``" {file} "``) — the value's quotes become literal and a ``$(...)`` in it still - executes; - * immediately preceded by ``$`` (``${file}`` → ``$'...'`` ANSI-C / ``${...}``) or a - backslash (``\\{file}`` escapes the value's opening quote); - * inside a shell comment (``echo hi # {file}``) or here-document body — neither is - an ordinary word context, and a newline in the value would break out of it. - - To keep the analysis sound, a multiline template (any newline — the only way to - form a here-document body) is refused outright, and a placeholder reached in a - comment context is refused. Substitution is single-pass, so a value that itself - contains a ``{...}`` token is never rescanned as a placeholder. + 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); + * 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). + + Substitution is single-pass, so a value that itself contains a ``{...}`` token is + never rescanned as a placeholder. """ - # A here-document body requires a newline; comment/quoting analysis below assumes - # a single logical line. Refuse any multiline template rather than model heredocs. + # 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 out: list = [] i, n = 0, len(template) - in_single = in_double = in_back = False - in_comment = False # after an unquoted, word-boundary '#' to end of line - at_word_boundary = True # position could begin an unquoted bare word / comment + 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: - prev = template[i - 1] if i > 0 else "" - if (in_single or in_double or in_back or in_comment) \ - or prev in ("$", "\\"): + if in_single or in_double or in_comment or prev_significant == "\\": return None out.append(shlex.quote(values[placeholder])) i += len(placeholder) - at_word_boundary = False + prev_significant = "x" # placeholder resolves to a non-boundary word char continue char = template[i] if char == "\\" and not in_single: out.append(char) - if i + 1 < n: - out.append(template[i + 1]) + nxt = template[i + 1] if i + 1 < n else "" + if nxt: + out.append(nxt) i += 2 else: i += 1 - at_word_boundary = False + prev_significant = "x" # an escaped char is a word char, not a boundary continue - if char == "'" and not in_double and not in_back and not in_comment: + if char == "'" and not in_double and not in_comment: in_single = not in_single - elif char == '"' and not in_single and not in_back and not in_comment: + elif char == '"' and not in_single and not in_comment: in_double = not in_double - elif char == "`" and not in_single and not in_comment: - in_back = not in_back - elif char == "#" and at_word_boundary \ - and not (in_single or in_double or in_back): + 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) - at_word_boundary = char.isspace() and not (in_single or in_double or in_back) + prev_significant = char i += 1 return "".join(out) diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index b52d199541..0fe938d1d4 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -1012,15 +1012,17 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # falsely proving membership). raise _PatternBudgetError # A *positive* pattern whose effective form is a minimatch comment (a - # leading ``#``) matches nothing, so it must not be matched literally into - # a false member. Classify it AFTER the SAME leading normalization the - # matcher applies (strip leading ``/`` and at most one ``./``) so ``/#*`` - # and ``.//#*`` are recognized as comments too, not just ``#*``/``./#*``. - # (A ``!``-exclusion body starting with ``#`` is left to match its literal - # ``#`` — the safe direction, since a spurious exclusion only removes a - # member, never adds one.) - if not negated and _effective_leading(body).startswith("#"): - continue + # leading ``#``) matches NO concrete path, so it must never be matched + # literally into a member. But it is still a real pattern STRING: under + # npm's ``appendNegatedPatterns`` a later positive — comment or not — whose + # pattern string is matched by an earlier negation REMOVES that negation + # (so ``["packages/**", "!**", "#noop"]`` re-includes ``packages/**``). + # Record the comment flag (classified after the SAME leading normalization + # the matcher applies, so ``/#*``/``.//#*`` count) and resolve it per-source + # below rather than dropping the pattern here. (A ``!``-exclusion body + # starting with ``#`` is left to match its literal ``#`` — the safe + # direction, since a spurious exclusion only removes a member.) + is_comment = (not negated) and _effective_leading(body).startswith("#") expanded = _expand_braces(body, budget, work) # Validate the CONCRETE (expanded) patterns, not the raw glob: brace # expansion can create an unsupported construct out of separate @@ -1030,7 +1032,7 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, for pat in expanded: if _concrete_pattern_unsupported(pat): raise _PatternBudgetError - parsed.append((negated, expanded)) + parsed.append((negated, expanded, is_comment)) def _matches_any(pattern_list) -> bool: return any( @@ -1041,9 +1043,11 @@ def _matches_any(pattern_list) -> bool: 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. + # earlier ``!`` excluded, per-path. A positive comment matches no path. member = False - for negated, expanded in parsed: + for negated, expanded, is_comment in parsed: + if is_comment: + continue if _matches_any(expanded): member = not negated return member @@ -1054,11 +1058,14 @@ def _matches_any(pattern_list) -> bool: # entirely (``minimatch(pattern, negatedPattern)`` upstream), so # ``["packages/**", "!packages/legacy/**", "packages/legacy/app"]`` drops the # ``!packages/legacy/**`` exclusion and every ``packages/**`` path — including - # ``packages/legacy/app`` — is a member again. A path is then a member iff it - # matches a surviving positive and no surviving negation. - positives = [] # concrete positive pattern strings, declaration order + # ``packages/legacy/app`` — is a member again. A positive COMMENT pattern + # (``#noop``) also participates in this removal (its pattern string can match an + # earlier negation) even though it never matches a concrete path. A path is then + # a member iff it matches a surviving non-comment positive and no surviving + # negation. + positives = [] # concrete positive pattern strings for path matching (no comments) negatives = [] # concrete negation pattern strings still in force - for negated, expanded in parsed: + for negated, expanded, is_comment in parsed: if negated: negatives.extend(expanded) else: @@ -1072,7 +1079,8 @@ def _matches_any(pattern_list) -> bool: if not _relative_matches_workspace_glob( pos_parts, neg, cells, work) ] - positives.append(pos) + if not is_comment: + positives.append(pos) if not _matches_any(positives): return False return not _matches_any(negatives) @@ -1281,12 +1289,27 @@ def _config_allowed(config_path: Path, repo_root: Optional[Path]) -> bool: ) +def _script_invokes_vitest(script: str) -> bool: + """True when a package.json script actually INVOKES vitest as a command, rather + than merely mentioning the substring (``echo no-vitest-installed`` must NOT count). + A token invokes vitest if its final path component is exactly ``vitest`` — covering + ``vitest run``, ``npx vitest``, ``pnpm exec vitest``, ``yarn vitest``, and + ``./node_modules/.bin/vitest`` — but not ``no-vitest-installed`` or + ``cat vitest.config.ts``.""" + try: + tokens = shlex.split(script) + except ValueError: + tokens = script.split() + return any(tok.split("/")[-1] == "vitest" for tok in tokens) + + 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`` in any dependency map or a script that invokes it. Without such proof an - ordinary Vite *application* (which also has a ``vite.config.*`` but no tests) MUST - NOT be treated as a test project.""" + ``vitest`` declared in any dependency map, 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.""" for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"): deps = manifest.get(key) @@ -1295,7 +1318,7 @@ def _vitest_proven_by_manifest(manifest: dict) -> bool: scripts = manifest.get("scripts") if isinstance(scripts, dict): for val in scripts.values(): - if isinstance(val, str) and "vitest" in val: + if isinstance(val, str) and _script_invokes_vitest(val): return True return False diff --git a/pdd/prompts/agentic_langtest_python.prompt b/pdd/prompts/agentic_langtest_python.prompt index 6f581d91d7..8bae145c6c 100644 --- a/pdd/prompts/agentic_langtest_python.prompt +++ b/pdd/prompts/agentic_langtest_python.prompt @@ -53,7 +53,10 @@ 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. % Deliverables - Code: `pdd/agentic_langtest.py` diff --git a/pdd/prompts/get_run_command_python.prompt b/pdd/prompts/get_run_command_python.prompt index e20391c1cc..8179b4f08c 100644 --- a/pdd/prompts/get_run_command_python.prompt +++ b/pdd/prompts/get_run_command_python.prompt @@ -18,15 +18,34 @@ - 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. + - 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 it cannot fill safely, because callers run the + result with `bash -lc` / `shell=True`: a multiline template (here-document + body), any template containing `$`, a backtick, or `(`/`)` (command-substitution, + parameter/arithmetic expansion, or subshell/process-substitution contexts), or 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). + + 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 **shell-quoted** - `file_path` (`shlex.quote`). 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). + - 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. % Data Source diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index 384a6d8344..5132209515 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -227,6 +227,25 @@ def test_heredoc_comment_and_multiline_are_refused(self): capture_output=True, text=True, timeout=10) assert "PWN_HD" in os.listdir(d), "expected the naive heredoc form to inject" + def test_expansion_and_operator_comment_contexts_are_refused(self): + """Command-evaluation contexts — arithmetic `$(( ))`, command substitution + `$( )`, parameter expansion `${ }`, backticks, and `(...)` subshells / + process substitution — plus a `#` comment that starts right after a `;`/`&`/`|` + control operator are not ordinary word contexts and are refused (None). Proven + with real `bash -lc` execution of the naive forms.""" + for tpl in ("printf X:$(({file}))", "echo $({file})", "echo ${x:-{file}}", + "( {file} )", "cat <({file})", "run `{file}`", + "printf SAFE;# {file}", "printf X&# {file}", "printf X|# {file}"): + assert shell_safe_substitute(tpl, {"{file}": "x"}) is None, tpl + # A `#` that is mid-word (not a comment) must NOT cause refusal. + assert shell_safe_substitute("echo a#b {file}", {"{file}": "x"}) == "echo a#b x" + # Prove the arithmetic-context exploit the guard prevents is genuine. + with tempfile.TemporaryDirectory() as d: + naive = "printf X:$((" + shlex.quote("$(touch PWN_AR >&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. diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 1617529537..a42efeb660 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1246,6 +1246,19 @@ def test_node_modules_is_never_a_workspace_member(self): assert _package_matches_workspace( ("packages", "node_modules"), ["packages/*"], ordered=False) is True + def test_npm_positive_comment_participates_in_negation_removal(self): + """Under npm's ``appendNegatedPatterns`` a positive pattern — INCLUDING a + ``#`` comment — whose pattern string is matched by an earlier negation removes + that negation, even though a comment never matches a concrete path itself. So + ``["packages/**", "!**", "#noop"]`` re-includes ``packages/app`` under npm. + pnpm ignores the comment (its last matching pattern is still ``!**``).""" + 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 positive comment never matches a concrete path on its own. + assert _package_matches_workspace(("#noop",), ["#noop"], ordered=False) is False + assert _package_matches_workspace(("#noop",), ["#noop"], ordered=True) is False + 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.""" @@ -1446,10 +1459,18 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): cmd, cwd = _detect_ts_test_runner(repo / "src" / "a.test.ts") assert "npx vitest run" in cmd, cmd assert cwd == repo.resolve() - # A script invoking vitest also proves it. - (repo / "package.json").write_text('{"scripts": {"test": "vitest run"}}') - cmd2, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") - assert "npx vitest run" in cmd2, cmd2 + # A script invoking vitest also proves it (including via a runner wrapper). + for script in ("vitest run", "npx vitest", "pnpm exec vitest", + "./node_modules/.bin/vitest"): + (repo / "package.json").write_text('{"scripts": {"test": "%s"}}' % script) + cmd2, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") + assert cmd2 is not None and "npx vitest run" in cmd2, (script, cmd2) + # A script that merely MENTIONS the substring "vitest" without invoking it does + # NOT prove Vitest — it must not be adopted (semantic token, not substring). + for script in ("echo no-vitest-installed", "cat vitest.config.ts", + "echo run-vitest-later"): + (repo / "package.json").write_text('{"scripts": {"test": "%s"}}' % script) + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None, script def test_symlink_to_foreign_checkout_is_refused(self, tmp_path): """A symlink whose target is itself a git checkout must not be adopted. From 269e65fa97973420c0b48f64814f63fc8c671d4f Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 01:42:56 -0700 Subject: [PATCH 41/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-31b=20?= =?UTF-8?q?review=20fixes=20=E2=80=94=20npm=20raw-pattern=20preprocessing,?= =?UTF-8?q?=20vitest=20executable=20position,=20substitute=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-31b adversarial review (gpt-5.6-sol xhigh @ 12cc809c3) — 3 findings, all fixed: - F1 (med): npm appendNegatedPatterns preprocessing brace-expanded the positive BEFORE comparing it to earlier negations, so `["packages/**","!packages/a","packages/{a,b}"]` wrongly removed `!packages/a` (via the `packages/a` expansion) and made packages/a a member. npm compares the RAW positive pattern string (braces literal) against each negation glob and expands braces only for the final path test — now `packages/a` stays excluded, `packages/b` is a member. Negations are grouped and removed atomically (a brace negation still expands and is removed as a unit). pnpm is unaffected (per-path last-match re-includes packages/a, correctly). - F2 (med): _vitest_proven_by_manifest counted `vitest` as any token, so `echo vitest` / `node vitest` false-proved. Now the script is split into command clauses and vitest counts only in executable position — as the command basename or the binary invoked by a supported runner (npx/pnpm/yarn/bun, incl. exec/dlx/run). Neighboring negatives/ positives tested. - F3 (low): shell_safe_substitute infinite-looped on an empty placeholder key (`{"": ...}` matched everywhere, cursor never advanced) and returned an unresolved template for an escaped `\{test}`. Now rejects an empty key up front and declines an escaped placeholder (None). Re-syncs .pdd/meta/get_test_command_python.json (code+test hashes). 235 focused tests pass, E2E green, pylint 10.00. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +- pdd/get_run_command.py | 15 +++- pdd/get_test_command.py | 108 +++++++++++++++++-------- tests/test_get_run_command.py | 12 +++ tests/test_get_test_command.py | 31 ++++++- 5 files changed, 133 insertions(+), 41 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 7b998b62d6..425cdc8e0a 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T07:45:00.000000+00:00", + "timestamp": "2026-07-13T08:20:00.000000+00:00", "command": "test", "prompt_hash": "7bd03451a23c273d0d5a74fd06e70c138b64c46e5d7796b02c0fcf5c530b9369", - "code_hash": "e411badc44c2b5ed7f2a2ac8031afb71c649866d3789b4a424eb90da7ae79626", + "code_hash": "5b1d1a8f5f2a61cdfe7d0e6062ccbbaf1605314c002e9f8c2836d9db903fa190", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "66c6b13d7d92d66f14c1cb40b2d9bbe2551d474bb9edfd53583ff55f0d46c703", + "test_hash": "976c9db7f3b579748db558f344a25ec8cc0187133d8e889c683abd418a425f33", "test_files": { - "test_get_test_command.py": "66c6b13d7d92d66f14c1cb40b2d9bbe2551d474bb9edfd53583ff55f0d46c703" + "test_get_test_command.py": "976c9db7f3b579748db558f344a25ec8cc0187133d8e889c683abd418a425f33" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 1cdbf9693d..0b743803b2 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -36,7 +36,9 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str the value would break out onto a new command line). Substitution is single-pass, so a value that itself contains a ``{...}`` token is - never rescanned as a placeholder. + 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 `$(`, @@ -46,6 +48,10 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str 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 out: list = [] i, n = 0, len(template) in_single = in_double = False @@ -55,7 +61,7 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str 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 or prev_significant == "\\": + if in_single or in_double or in_comment: return None out.append(shlex.quote(values[placeholder])) i += len(placeholder) @@ -63,6 +69,11 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str 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: diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 0fe938d1d4..f766651292 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -1032,7 +1032,7 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, for pat in expanded: if _concrete_pattern_unsupported(pat): raise _PatternBudgetError - parsed.append((negated, expanded, is_comment)) + parsed.append((negated, body, expanded, is_comment)) def _matches_any(pattern_list) -> bool: return any( @@ -1045,7 +1045,7 @@ def _matches_any(pattern_list) -> bool: # that matches this path decides — a later positive RE-INCLUDES a path an # earlier ``!`` excluded, per-path. A positive comment matches no path. member = False - for negated, expanded, is_comment in parsed: + for negated, _body, expanded, is_comment in parsed: if is_comment: continue if _matches_any(expanded): @@ -1060,30 +1060,38 @@ def _matches_any(pattern_list) -> bool: # ``!packages/legacy/**`` exclusion and every ``packages/**`` path — including # ``packages/legacy/app`` — is a member again. A positive COMMENT pattern # (``#noop``) also participates in this removal (its pattern string can match an - # earlier negation) even though it never matches a concrete path. A path is then - # a member iff it matches a surviving non-comment positive and no surviving - # negation. - positives = [] # concrete positive pattern strings for path matching (no comments) - negatives = [] # concrete negation pattern strings still in force - for negated, expanded, is_comment in parsed: + # earlier negation) even though it never matches a concrete path. + # + # The removal MUST compare the RAW positive pattern *string* (braces literal, as + # upstream ``minimatch(pattern, negatedPattern)`` treats the pattern arg as a + # path) against each negation glob — NOT its brace expansions: for + # ``["packages/**", "!packages/a", "packages/{a,b}"]`` the raw ``packages/{a,b}`` + # does NOT match ``packages/a``, so the exclusion survives and ``packages/a`` + # stays excluded while ``packages/b`` is a member. Braces are expanded only for + # the final concrete-path membership test. Each negation is a group (its own + # brace expansions) removed atomically. A path is a member iff it matches a + # surviving non-comment positive expansion and no surviving negation expansion. + positives = [] # concrete positive expansions for path matching (no comments) + neg_groups = [] # per-negation lists of concrete expansions, still in force + for negated, body, expanded, is_comment in parsed: if negated: - negatives.extend(expanded) + neg_groups.append(expanded) else: - for pos in expanded: - # npm applies the same leading normalization to every pattern - # before comparing, so strip it before treating the positive - # pattern string as a literal path for the removal test. - pos_parts = tuple(_strip_one_leading(pos).split("/")) - negatives = [ - neg for neg in negatives - if not _relative_matches_workspace_glob( - pos_parts, neg, cells, work) - ] - if not is_comment: - positives.append(pos) + # RAW positive string as a literal path (braces NOT expanded), leading- + # normalized the SAME way npm normalizes every pattern before comparing. + raw_pos_parts = tuple(_strip_one_leading(body).split("/")) + neg_groups = [ + grp for grp in neg_groups + if not any( + _relative_matches_workspace_glob(raw_pos_parts, np, cells, work) + for np in grp) + ] + if not is_comment: + positives.extend(expanded) + neg_flat = [np for grp in neg_groups for np in grp] if not _matches_any(positives): return False - return not _matches_any(negatives) + return not _matches_any(neg_flat) except (_PatternBudgetError, RecursionError): # Pathological pattern from an untrusted manifest (brace bomb, ``**`` # wall, or deep nesting) → cannot be evaluated safely, so membership is @@ -1289,18 +1297,54 @@ def _config_allowed(config_path: Path, repo_root: Optional[Path]) -> bool: ) -def _script_invokes_vitest(script: str) -> bool: - """True when a package.json script actually INVOKES vitest as a command, rather - than merely mentioning the substring (``echo no-vitest-installed`` must NOT count). - A token invokes vitest if its final path component is exactly ``vitest`` — covering - ``vitest run``, ``npx vitest``, ``pnpm exec vitest``, ``yarn vitest``, and - ``./node_modules/.bin/vitest`` — but not ``no-vitest-installed`` or - ``cat vitest.config.ts``.""" +_VITEST_RUNNERS = frozenset({"npx", "pnpm", "yarn", "bun", "bunx", "npm"}) +# Runner sub-commands/flags to skip while looking for the invoked binary after a runner +# (``pnpm exec vitest``, ``pnpm dlx vitest``, ``yarn run vitest``, ``npx --yes vitest``). +_RUNNER_SKIP = frozenset({"exec", "dlx", "run", "--yes", "-y", "--"}) + + +def _clause_invokes_vitest(clause: str) -> bool: + """True when a single command clause runs ``vitest`` in EXECUTABLE position — as the + command itself (basename ``vitest``) or as the binary a supported runner invokes + (``npx vitest``, ``pnpm exec vitest``, ``yarn vitest``, ``pnpm dlx vitest``). A + ``vitest`` appearing only as an ARGUMENT (``echo vitest``, ``node vitest``, + ``command -v vitest``) does NOT count.""" try: - tokens = shlex.split(script) + tokens = shlex.split(clause) except ValueError: - tokens = script.split() - return any(tok.split("/")[-1] == "vitest" for tok in tokens) + tokens = clause.split() + # Drop leading ``VAR=value`` environment assignments. + 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] + if cmd == "vitest": + return True + if cmd in _VITEST_RUNNERS: + for tok in tokens[idx + 1:]: + base = tok.split("/")[-1] + if base == "vitest": + return True + if base in _RUNNER_SKIP or base.startswith("-"): + continue + break # first real argument that is not vitest → not a vitest invocation + return False + + +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), + rather than merely mentioning the string. The script is split into command clauses + on shell control operators (``;``, ``&&``, ``||``, ``|``, ``&``) and each clause is + checked independently, so ``echo vitest``, ``node vitest``, ``cat vitest.config.ts``, + and ``echo no-vitest-installed`` are all correctly rejected while ``vitest run`` and + ``test:unit && vitest`` are accepted.""" + for clause in re.split(r"&&|\|\||[;&|\n]", script): + if clause.strip() and _clause_invokes_vitest(clause): + return True + return False def _vitest_proven_by_manifest(manifest: dict) -> bool: diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index 5132209515..3d32011c36 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -261,3 +261,15 @@ def test_values_are_not_rescanned_for_other_placeholders(self): 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" diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index a42efeb660..d24921cc3c 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1259,6 +1259,25 @@ def test_npm_positive_comment_participates_in_negation_removal(self): assert _package_matches_workspace(("#noop",), ["#noop"], ordered=False) is False assert _package_matches_workspace(("#noop",), ["#noop"], ordered=True) is False + 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_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.""" @@ -1465,12 +1484,18 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): (repo / "package.json").write_text('{"scripts": {"test": "%s"}}' % script) cmd2, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") assert cmd2 is not None and "npx vitest run" in cmd2, (script, cmd2) - # A script that merely MENTIONS the substring "vitest" without invoking it does - # NOT prove Vitest — it must not be adopted (semantic token, not substring). + # A script where "vitest" is not in EXECUTABLE position (a bare argument, an + # arg to echo/node/command, or just a substring) does NOT prove Vitest. for script in ("echo no-vitest-installed", "cat vitest.config.ts", - "echo run-vitest-later"): + "echo run-vitest-later", "echo vitest", "node vitest", + "command -v vitest", "printf vitest", "pnpm run test"): (repo / "package.json").write_text('{"scripts": {"test": "%s"}}' % script) assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None, script + # Executable-position invocations (incl. env prefix and a later clause) DO prove. + for script in ("CI=1 vitest run", "build && vitest", "yarn run vitest"): + (repo / "package.json").write_text('{"scripts": {"test": "%s"}}' % script) + cmd3, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") + assert cmd3 is not None and "npx vitest run" in cmd3, (script, cmd3) def test_symlink_to_foreign_checkout_is_refused(self, tmp_path): """A symlink whose target is itself a git checkout must not be adopted. From 521ee49f09d35d04c40444ea08eb6d5e8c90f73e Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 02:07:43 -0700 Subject: [PATCH 42/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-32=20r?= =?UTF-8?q?eview=20fixes=20=E2=80=94=20brace/glob=20template=20rejection,?= =?UTF-8?q?=20vitest=20runner=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-32 adversarial review (gpt-5.6-sol xhigh @ c18228473) — 2 findings, both fixed: - F1 (med): shell_safe_substitute did not reject brace-expansion (`{a,b}`) or pathname-expansion (`*`/`?`/`[]`/`~`) metacharacters appearing OUTSIDE a placeholder, so a template like `pre{{file},tail}` brace-expanded the command into extra words (and `shlex.quote` leaves a value's `,` unquoted, re-splitting inside a template brace). Now mask placeholders and refuse any residual `{}[]*?~`. Ordinary adjacency (`{file}.out`, `./{file}`) is unaffected; real bash -lc regression. - F2 (med): the vitest executable-position check mis-parsed two cases — `npx --package vitest echo ok` (vitest is the option's argument, real command is echo) and `echo x\;vitest` (escaped `;` mis-split by the regex clause splitter). Now tokenize with a quote/escape-aware shell lexer, split clauses on unquoted operators, and after a runner skip only known subcommands/boolean flags — an arg-taking or unknown flag fails closed. Adversarial negatives added. Re-syncs .pdd/meta/get_test_command_python.json (code+test hashes). 236 focused tests pass, E2E green, pylint 10.00. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 +-- pdd/get_run_command.py | 16 +++++ pdd/get_test_command.py | 84 +++++++++++++++----------- tests/test_get_run_command.py | 16 +++++ tests/test_get_test_command.py | 11 +++- 5 files changed, 92 insertions(+), 43 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 425cdc8e0a..3cf8f353db 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T08:20:00.000000+00:00", + "timestamp": "2026-07-13T08:35:00.000000+00:00", "command": "test", "prompt_hash": "7bd03451a23c273d0d5a74fd06e70c138b64c46e5d7796b02c0fcf5c530b9369", - "code_hash": "5b1d1a8f5f2a61cdfe7d0e6062ccbbaf1605314c002e9f8c2836d9db903fa190", + "code_hash": "5fe89c5c5d5a7308daa88f72e01ba1fdb5ced7b76367f789eaf24eced690694e", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "976c9db7f3b579748db558f344a25ec8cc0187133d8e889c683abd418a425f33", + "test_hash": "f0972611159eca1a194847309d35c939c0715e32637b04502d77d1dff0c621a9", "test_files": { - "test_get_test_command.py": "976c9db7f3b579748db558f344a25ec8cc0187133d8e889c683abd418a425f33" + "test_get_test_command.py": "f0972611159eca1a194847309d35c939c0715e32637b04502d77d1dff0c621a9" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 0b743803b2..71927074de 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -30,6 +30,10 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str 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 @@ -52,6 +56,18 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str # 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 out: list = [] i, n = 0, len(template) in_single = in_double = False diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index f766651292..f9e4d7c3a6 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -1298,53 +1298,65 @@ def _config_allowed(config_path: Path, repo_root: Optional[Path]) -> bool: _VITEST_RUNNERS = frozenset({"npx", "pnpm", "yarn", "bun", "bunx", "npm"}) -# Runner sub-commands/flags to skip while looking for the invoked binary after a runner -# (``pnpm exec vitest``, ``pnpm dlx vitest``, ``yarn run vitest``, ``npx --yes vitest``). -_RUNNER_SKIP = frozenset({"exec", "dlx", "run", "--yes", "-y", "--"}) - - -def _clause_invokes_vitest(clause: str) -> bool: - """True when a single command clause runs ``vitest`` in EXECUTABLE position — as the - command itself (basename ``vitest``) or as the binary a supported runner invokes - (``npx vitest``, ``pnpm exec vitest``, ``yarn vitest``, ``pnpm dlx vitest``). A - ``vitest`` appearing only as an ARGUMENT (``echo vitest``, ``node vitest``, - ``command -v vitest``) does NOT count.""" - try: - tokens = shlex.split(clause) - except ValueError: - tokens = clause.split() - # Drop leading ``VAR=value`` environment assignments. +# Runner sub-commands to skip while looking for the invoked binary (``pnpm exec vitest``, +# ``pnpm dlx vitest``, ``yarn run vitest``). +_RUNNER_SUBCMDS = frozenset({"exec", "dlx", "run"}) +# 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({";", "&", "&&", "|", "||"}) + + +def _clause_invokes_vitest(tokens: list) -> bool: + """True when a single command clause (already tokenized) runs ``vitest`` in + EXECUTABLE position — as the command itself (basename ``vitest``) or as the binary a + supported runner invokes (``npx vitest``, ``pnpm exec vitest``, ``yarn run vitest``). + After a runner, only known subcommands and boolean flags are skipped; an arg-taking + or unknown flag fails CLOSED, so ``npx --package vitest echo ok`` (where ``vitest`` is + the value of ``--package`` and the real command is ``echo``) is NOT a vitest + invocation, and neither is a bare ``vitest`` ARGUMENT (``echo vitest``, ``node + vitest``, ``command -v vitest``).""" idx = 0 while idx < len(tokens) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tokens[idx]): - idx += 1 + idx += 1 # drop leading VAR=value environment assignments if idx >= len(tokens): return False - cmd = tokens[idx].split("/")[-1] - if cmd == "vitest": + if tokens[idx].split("/")[-1] == "vitest": return True - if cmd in _VITEST_RUNNERS: - for tok in tokens[idx + 1:]: - base = tok.split("/")[-1] - if base == "vitest": - return True - if base in _RUNNER_SKIP or base.startswith("-"): - continue - break # first real argument that is not vitest → not a vitest invocation + if tokens[idx].split("/")[-1] not in _VITEST_RUNNERS: + return False + for tok in tokens[idx + 1:]: + if tok in _RUNNER_SUBCMDS or tok in _RUNNER_BOOL_FLAGS: + continue + if tok.startswith("-"): + return False # arg-taking/unknown flag → fail closed + return tok.split("/")[-1] == "vitest" return False 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), - rather than merely mentioning the string. The script is split into command clauses - on shell control operators (``;``, ``&&``, ``||``, ``|``, ``&``) and each clause is - checked independently, so ``echo vitest``, ``node vitest``, ``cat vitest.config.ts``, - and ``echo no-vitest-installed`` are all correctly rejected while ``vitest run`` and - ``test:unit && vitest`` are accepted.""" - for clause in re.split(r"&&|\|\||[;&|\n]", script): - if clause.strip() and _clause_invokes_vitest(clause): - return True - return False + not merely mentioning the string. The script is tokenized with a QUOTE- and + ESCAPE-aware shell lexer and split into command clauses on unquoted control operators + (``;`` ``&`` ``&&`` ``|`` ``||``), so ``echo x\\; vitest`` stays one clause (the + escaped ``;`` is a literal argument, not a boundary) and ``echo 'a; b' vitest`` is + not mis-split. Malformed (unbalanced-quote) scripts fail closed.""" + try: + lex = shlex.shlex(script, posix=True, punctuation_chars=True) + lex.whitespace_split = True + clause: list = [] + for tok in lex: + if tok in _CLAUSE_OPERATORS: + if _clause_invokes_vitest(clause): + return True + clause = [] + else: + clause.append(tok) + return _clause_invokes_vitest(clause) + except ValueError: + return False def _vitest_proven_by_manifest(manifest: dict) -> bool: diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index 3d32011c36..2a51419f6c 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -273,3 +273,19 @@ def test_empty_key_and_escaped_placeholder_are_handled(self): # 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 diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index d24921cc3c..990760b2e1 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -3,6 +3,7 @@ from unittest.mock import patch, mock_open, MagicMock import csv import io +import json import shlex import sys import os @@ -1485,11 +1486,15 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): cmd2, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") assert cmd2 is not None and "npx vitest run" in cmd2, (script, cmd2) # A script where "vitest" is not in EXECUTABLE position (a bare argument, an - # arg to echo/node/command, or just a substring) does NOT prove Vitest. + # arg to echo/node/command, the value of a runner option flag, a substring, or + # only reachable past an ESCAPED/quoted operator) does NOT prove Vitest. for script in ("echo no-vitest-installed", "cat vitest.config.ts", "echo run-vitest-later", "echo vitest", "node vitest", - "command -v vitest", "printf vitest", "pnpm run test"): - (repo / "package.json").write_text('{"scripts": {"test": "%s"}}' % script) + "command -v vitest", "printf vitest", "pnpm run test", + "npx --package vitest echo ok", "npx -p vitest echo ok", + r"echo x\; vitest", "echo 'a; b' vitest"): + (repo / "package.json").write_text( + json.dumps({"scripts": {"test": script}})) assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None, script # Executable-position invocations (incl. env prefix and a later clause) DO prove. for script in ("CI=1 vitest run", "build && vitest", "yarn run vitest"): From 4ffa6b97ee4373078320e24ad199d9a0cbc10a13 Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 02:36:51 -0700 Subject: [PATCH 43/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-33=20r?= =?UTF-8?q?eview=20fixes=20=E2=80=94=20npm=20eager=20positive-removal,=20l?= =?UTF-8?q?eading-norm=20order,=20runner-script=20grammar,=20prompt=20doct?= =?UTF-8?q?rine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-33 adversarial review (gpt-5.6-sol xhigh @ 7979a42fe) — 4 findings, all fixed: - R33-1 (med): implement npm appendNegatedPatterns' SECOND step — each surviving negation prunes any positive PATTERN whose raw pattern string it matches (`minimatch.match(patterns, negated)`). `["packages/*","!packages/?"]` now drops `packages/*` and `packages/app` is not a member (the concrete path doesn't match the single-char negation, but the pattern string does). - R33-2 (med): apply npm leading normalization (`^\.?/+`) to each raw pattern exactly once, BEFORE brace expansion, and never again to generated expansions (new `pre_normalized` flag on the matcher). `["{/packages/*,other/*}"]` now keeps the brace-generated `/packages/*` anchored, so `packages/app` is excluded while `other/app` is a member. - R33-3 (med): runner grammar — `npm run vitest` / bare `pnpm vitest` invoke a package.json SCRIPT (possibly a Vite-only shadow), not the vitest binary. Split runners into direct (npx/bunx) vs exec-subcommand (npm/pnpm/yarn/bun + exec/dlx/x); a bare command or `run ` fails closed. Script-shadowing regression added. - R33-4 (med): the get_run_command / get_test_command(R13) / agentic_fix prompts now declare rejection of non-placeholder brace/glob metacharacters (`{}*?[]~`) plus the empty-key and escaped-placeholder rules, at doctrine altitude. Re-syncs .pdd/meta/get_test_command_python.json (prompt+code+test hashes). 238 focused tests pass, E2E green, pylint 10.00. Upstream npm/pnpm parity verified against @npmcli/map-workspaces. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +- pdd/get_test_command.py | 122 +++++++++++++++------ pdd/prompts/agentic_fix_python.prompt | 2 +- pdd/prompts/get_run_command_python.prompt | 19 ++-- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_test_command.py | 57 ++++++++-- 6 files changed, 153 insertions(+), 59 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 3cf8f353db..35c2e9025d 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T08:35:00.000000+00:00", + "timestamp": "2026-07-13T08:55:00.000000+00:00", "command": "test", - "prompt_hash": "7bd03451a23c273d0d5a74fd06e70c138b64c46e5d7796b02c0fcf5c530b9369", - "code_hash": "5fe89c5c5d5a7308daa88f72e01ba1fdb5ced7b76367f789eaf24eced690694e", + "prompt_hash": "38ed6f1f9d91af9e22c755a6a559403205436b87b989e1b2dd2a436a3b7f3f2e", + "code_hash": "2009195a773bc21ceff342b93e90e21ccddb565df93d34f44d2b9f6c855a9d88", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "f0972611159eca1a194847309d35c939c0715e32637b04502d77d1dff0c621a9", + "test_hash": "6d13c7f243afa8a9c073a8a928facc40647e742afe9031270998c3f68f5b6859", "test_files": { - "test_get_test_command.py": "f0972611159eca1a194847309d35c939c0715e32637b04502d77d1dff0c621a9" + "test_get_test_command.py": "6d13c7f243afa8a9c073a8a928facc40647e742afe9031270998c3f68f5b6859" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index f9e4d7c3a6..87e78f8493 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -329,7 +329,8 @@ class TestCommand: def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, cell_budget: Optional[list] = None, - work_budget: Optional[list] = None) -> bool: + work_budget: Optional[list] = None, + pre_normalized: 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 @@ -357,8 +358,13 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, # 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. - rest = _strip_one_leading(pattern) + # ``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 ``.`` @@ -1023,7 +1029,13 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # starting with ``#`` is left to match its literal ``#`` — the safe # direction, since a spurious exclusion only removes a member.) is_comment = (not negated) and _effective_leading(body).startswith("#") - expanded = _expand_braces(body, budget, work) + # 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 @@ -1032,11 +1044,12 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, for pat in expanded: if _concrete_pattern_unsupported(pat): raise _PatternBudgetError - parsed.append((negated, body, expanded, is_comment)) + parsed.append((negated, norm_body, expanded, is_comment)) def _matches_any(pattern_list) -> bool: return any( - _relative_matches_workspace_glob(rel_parts, p, cells, work) + _relative_matches_workspace_glob( + rel_parts, p, cells, work, pre_normalized=True) for p in pattern_list ) @@ -1069,26 +1082,45 @@ def _matches_any(pattern_list) -> bool: # does NOT match ``packages/a``, so the exclusion survives and ``packages/a`` # stays excluded while ``packages/b`` is a member. Braces are expanded only for # the final concrete-path membership test. Each negation is a group (its own - # brace expansions) removed atomically. A path is a member iff it matches a - # surviving non-comment positive expansion and no surviving negation expansion. - positives = [] # concrete positive expansions for path matching (no comments) + # brace expansions) removed atomically. + # + # Upstream then runs a SECOND, symmetric step: each SURVIVING negation prunes any + # positive PATTERN whose raw 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 ``packages/?``. + # A path is a member iff it matches a surviving non-comment positive expansion + # and no surviving negation expansion. + pos_groups = [] # (raw_pos_parts, expansions) for non-comment positives neg_groups = [] # per-negation lists of concrete expansions, still in force - for negated, body, expanded, is_comment in parsed: + for negated, norm_body, expanded, is_comment in parsed: + # RAW pattern string as a literal path (braces NOT expanded), already + # leading-normalized the SAME way npm normalizes every pattern. + raw_parts = tuple(norm_body.split("/")) if negated: neg_groups.append(expanded) else: - # RAW positive string as a literal path (braces NOT expanded), leading- - # normalized the SAME way npm normalizes every pattern before comparing. - raw_pos_parts = tuple(_strip_one_leading(body).split("/")) neg_groups = [ grp for grp in neg_groups if not any( - _relative_matches_workspace_glob(raw_pos_parts, np, cells, work) + _relative_matches_workspace_glob( + raw_parts, np, cells, work, pre_normalized=True) for np in grp) ] if not is_comment: - positives.extend(expanded) + pos_groups.append((raw_parts, expanded)) neg_flat = [np for grp in neg_groups for np in grp] + # Eager positive-removal: a positive whose raw pattern string matches a surviving + # negation is dropped entirely. + positives = [] + for raw_parts, expanded in pos_groups: + if any( + _relative_matches_workspace_glob( + raw_parts, np, cells, work, pre_normalized=True) + for np in neg_flat + ): + continue + positives.extend(expanded) if not _matches_any(positives): return False return not _matches_any(neg_flat) @@ -1297,10 +1329,16 @@ def _config_allowed(config_path: Path, repo_root: Optional[Path]) -> bool: ) -_VITEST_RUNNERS = frozenset({"npx", "pnpm", "yarn", "bun", "bunx", "npm"}) -# Runner sub-commands to skip while looking for the invoked binary (``pnpm exec vitest``, -# ``pnpm dlx vitest``, ``yarn run vitest``). -_RUNNER_SUBCMDS = frozenset({"exec", "dlx", "run"}) +# 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"}) @@ -1308,30 +1346,42 @@ def _config_allowed(config_path: Path, repo_root: Optional[Path]) -> bool: _CLAUSE_OPERATORS = frozenset({";", "&", "&&", "|", "||"}) +def _binary_after_flags_is_vitest(tokens: list) -> bool: + """Return True iff the first non-boolean-flag token names the ``vitest`` binary. Any + arg-taking/unknown flag (not a known boolean flag) fails closed, so ``npx --package + vitest echo ok`` — where ``vitest`` is the option's value, not the binary — is False.""" + for tok in tokens: + if tok in _RUNNER_BOOL_FLAGS: + continue + if tok.startswith("-"): + return False + return tok.split("/")[-1] == "vitest" + return False + + def _clause_invokes_vitest(tokens: list) -> bool: - """True when a single command clause (already tokenized) runs ``vitest`` in - EXECUTABLE position — as the command itself (basename ``vitest``) or as the binary a - supported runner invokes (``npx vitest``, ``pnpm exec vitest``, ``yarn run vitest``). - After a runner, only known subcommands and boolean flags are skipped; an arg-taking - or unknown flag fails CLOSED, so ``npx --package vitest echo ok`` (where ``vitest`` is - the value of ``--package`` and the real command is ``echo``) is NOT a vitest - invocation, and neither is a bare ``vitest`` ARGUMENT (``echo vitest``, ``node - vitest``, ``command -v vitest``).""" + """True when a single command clause (already tokenized) runs the ``vitest`` binary in + EXECUTABLE position: as the command itself (basename ``vitest``), via a direct + package runner (``npx``/``bunx`` + ``vitest``), or via an explicit exec sub-command + (``pnpm exec vitest``, ``pnpm dlx vitest``, ``bun x vitest``). A bare ``pnpm vitest`` + or a ``run `` form (``npm run vitest``) invokes a package.json SCRIPT — possibly + a Vite-only shadow — and is NOT proof. Neither is a bare ``vitest`` ARGUMENT + (``echo vitest``, ``node vitest``, ``command -v vitest``).""" idx = 0 while idx < len(tokens) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tokens[idx]): idx += 1 # drop leading VAR=value environment assignments if idx >= len(tokens): return False - if tokens[idx].split("/")[-1] == "vitest": + cmd = tokens[idx].split("/")[-1] + if cmd == "vitest": return True - if tokens[idx].split("/")[-1] not in _VITEST_RUNNERS: - return False - for tok in tokens[idx + 1:]: - if tok in _RUNNER_SUBCMDS or tok in _RUNNER_BOOL_FLAGS: - continue - if tok.startswith("-"): - return False # arg-taking/unknown flag → fail closed - return tok.split("/")[-1] == "vitest" + if cmd in _DIRECT_RUNNERS: + return _binary_after_flags_is_vitest(tokens[idx + 1:]) + if cmd in _EXEC_RUNNERS: + rest = tokens[idx + 1:] + 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 diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index 71f0ea9374..18ad7f5efe 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -8,7 +8,7 @@ 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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) via a **shell-lexical-context-aware single pass** (`shell_safe_substitute`). `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 (`{test}x`, `./{test}`, `{test}.log` all substitute correctly). Safety MUST be decided from the template's actual shell-lexical context, NOT from mere whitespace bounds: the substitution is unsafe — and MUST be refused so the verification gate fails closed — ONLY where the inserted quoting could be reinterpreted: the placeholder inside the template's own single/double quotes or backticks (`pytest "{test}"`, even a space-surrounded `echo " {test} "` sitting *inside* enclosing quotes), immediately preceded by `$` (`${test}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body (`shlex.quote` single-quoting does not stop a `$(...)` in a heredoc body, and a value newline breaks out of a comment). Any multiline template (a newline — the only way to form a heredoc body) is refused outright. Substitution MUST be single-pass: an inserted value that itself contains a literal `{test}`/`{cwd}` MUST NOT be rescanned as another placeholder (a sequential `str.replace` chain would re-expand it). Resolve BOTH `{test}` and `{cwd}` to ABSOLUTE paths anchored at the supplied `cwd` (not the process CWD): a relative `{test}` targets the same file `run_agentic_fix` resolved, and an absolute `{cwd}` prevents a template like `cd {cwd} && pytest {test}` from double-joining a relative `cwd` onto the process directory. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient environment state. + - **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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) via a **shell-lexical-context-aware single pass** (`shell_safe_substitute`). `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 (`{test}x`, `./{test}`, `{test}.log` all substitute correctly). Safety MUST be decided from the template's actual shell-lexical context, NOT from mere whitespace bounds: the substitution is unsafe — and MUST be refused so the verification gate fails closed — ONLY where the inserted quoting could be reinterpreted: the placeholder inside the template's own single/double quotes or backticks (`pytest "{test}"`, even a space-surrounded `echo " {test} "` sitting *inside* enclosing quotes), immediately preceded by `$` (`${test}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body (`shlex.quote` single-quoting does not stop a `$(...)` in a heredoc body, and a value newline breaks out of a comment). Any multiline template (a newline — the only way to form a heredoc body) is refused outright, as is any template containing `$`, a backtick, or `(`/`)`, or a brace-expansion / pathname-expansion metacharacter (`{`, `}`, `*`, `?`, `[`, `]`, `~`) OUTSIDE a placeholder token (an unquoted `{a,b}` or glob would change the command's word count, and a value's `,` is left unquoted by `shlex.quote`). An empty placeholder key and an escaped placeholder (`\{test}`) are refused too. Substitution MUST be single-pass: an inserted value that itself contains a literal `{test}`/`{cwd}` MUST NOT be rescanned as another placeholder (a sequential `str.replace` chain would re-expand it). Resolve BOTH `{test}` and `{cwd}` to ABSOLUTE paths anchored at the supplied `cwd` (not the process CWD): a relative `{test}` targets the same file `run_agentic_fix` resolved, and an absolute `{cwd}` prevents a template like `cd {cwd} && pytest {test}` from double-joining a relative `cwd` onto the process directory. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient 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. diff --git a/pdd/prompts/get_run_command_python.prompt b/pdd/prompts/get_run_command_python.prompt index 8179b4f08c..3876048d7d 100644 --- a/pdd/prompts/get_run_command_python.prompt +++ b/pdd/prompts/get_run_command_python.prompt @@ -28,13 +28,18 @@ - `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 it cannot fill safely, because callers run the - result with `bash -lc` / `shell=True`: a multiline template (here-document - body), any template containing `$`, a backtick, or `(`/`)` (command-substitution, - parameter/arithmetic expansion, or subshell/process-substitution contexts), or 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). + 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). 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. diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index c2de151bf0..571ea32bfd 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -42,7 +42,7 @@ R11 (MUST): Target the test path literally. Invoke Jest with `--runTestsByPath` 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. (This is a POSIX-shell command string, matching how all pdd callers run verify commands.) -R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder via a shell-lexical-context-aware single pass (`shell_safe_substitute`) and return `cwd=None`. Callers execute with `shell=True`. `shlex.quote` yields a SELF-CONTAINED shell word, which is safe to splice in AND safe to concatenate with ordinary literal characters on either side — so a suffix or prefix template like `gfortran -o {file}.out {file}` or `fpc {file} && ./{file}` (real CSV rows) MUST still substitute and return a command, NOT be rejected as "adjacent"; rejecting them silently disables crash verification for those languages. The substitution is unsafe, and MUST return None (→ fall through to the next resolution step), ONLY where the inserted quoting could be reinterpreted: the placeholder inside the template's own single/double quotes or backticks (`mocha "{file}"` — the inserted quotes become literal and a `$(...)` in the path still executes), immediately preceded by `$` (`${file}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body — and any multiline template (a newline, the only way to form a heredoc body) is refused outright. The substitution MUST be single-pass — an inserted value that itself contains a literal `{file}` MUST NOT be rescanned as another placeholder. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. +R13 (MUST): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder via a shell-lexical-context-aware single pass (`shell_safe_substitute`) and return `cwd=None`. Callers execute with `shell=True`. `shlex.quote` yields a SELF-CONTAINED shell word, which is safe to splice in AND safe to concatenate with ordinary literal characters on either side — so a suffix or prefix template like `gfortran -o {file}.out {file}` or `fpc {file} && ./{file}` (real CSV rows) MUST still substitute and return a command, NOT be rejected as "adjacent"; rejecting them silently disables crash verification for those languages. The substitution MUST return None (→ fall through to the next resolution step) whenever non-placeholder template syntax could make `bash` expand the command or change its word count: the placeholder inside the template's own single/double quotes or backticks (`mocha "{file}"` — the inserted quotes become literal and a `$(...)` in the path still executes), immediately preceded by `$` (`${file}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body; any multiline template (a newline, the only way to form a heredoc body); any template containing `$`, a backtick, or `(`/`)`; and any 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). An empty placeholder key and an escaped placeholder (`\{file}`) are refused too. The substitution MUST be single-pass — an inserted value that itself contains a literal `{file}` MUST NOT be rescanned as another placeholder. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. # Dependencies diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 990760b2e1..720cdab0c4 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1279,6 +1279,33 @@ def test_npm_removal_compares_raw_unexpanded_positive_string(self): # 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_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.""" @@ -1479,25 +1506,37 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): cmd, cwd = _detect_ts_test_runner(repo / "src" / "a.test.ts") assert "npx vitest run" in cmd, cmd assert cwd == repo.resolve() - # A script invoking vitest also proves it (including via a runner wrapper). - for script in ("vitest run", "npx vitest", "pnpm exec vitest", - "./node_modules/.bin/vitest"): - (repo / "package.json").write_text('{"scripts": {"test": "%s"}}' % script) + # A script invoking the vitest BINARY proves it — directly, via a direct package + # runner (npx/bunx), or via an explicit exec subcommand (pnpm exec / bun x). + for script in ("vitest run", "npx vitest", "npx --yes vitest", "bunx vitest", + "pnpm exec vitest", "pnpm dlx vitest", "bun x vitest", + "yarn exec vitest", "./node_modules/.bin/vitest"): + (repo / "package.json").write_text( + json.dumps({"scripts": {"test": script}})) cmd2, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") assert cmd2 is not None and "npx vitest run" in cmd2, (script, cmd2) - # A script where "vitest" is not in EXECUTABLE position (a bare argument, an - # arg to echo/node/command, the value of a runner option flag, a substring, or - # only reachable past an ESCAPED/quoted operator) does NOT prove Vitest. + # A script where "vitest" is not the invoked BINARY — a bare argument; an arg to + # echo/node/command; the value of a runner option flag; a package.json SCRIPT + # name (`npm run vitest` / bare `pnpm vitest`, which may be a Vite-only shadow); + # a substring; or only reachable past an ESCAPED/quoted operator — does NOT prove + # Vitest. for script in ("echo no-vitest-installed", "cat vitest.config.ts", "echo run-vitest-later", "echo vitest", "node vitest", "command -v vitest", "printf vitest", "pnpm run test", "npx --package vitest echo ok", "npx -p vitest echo ok", + "npm run vitest", "pnpm run vitest", "yarn run vitest", + "pnpm vitest", "yarn vitest", "npm vitest", r"echo x\; vitest", "echo 'a; b' vitest"): (repo / "package.json").write_text( json.dumps({"scripts": {"test": script}})) assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None, script - # Executable-position invocations (incl. env prefix and a later clause) DO prove. - for script in ("CI=1 vitest run", "build && vitest", "yarn run vitest"): + # Script-shadowing: `npm run vitest` runs a "vitest" script that is actually Vite, + # so a Vite-only package with such a script must NOT be adopted as Vitest. + (repo / "package.json").write_text(json.dumps( + {"scripts": {"test": "npm run vitest", "vitest": "vite --mode test"}})) + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None + # Binary-position invocations (incl. env prefix and a later clause) DO prove. + for script in ("CI=1 vitest run", "build && vitest"): (repo / "package.json").write_text('{"scripts": {"test": "%s"}}' % script) cmd3, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") assert cmd3 is not None and "npx vitest run" in cmd3, (script, cmd3) From 68c1f26069037c3fa413d06a882aedb897f52fa4 Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 03:11:34 -0700 Subject: [PATCH 44/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-34=20r?= =?UTF-8?q?eview=20fixes=20=E2=80=94=20npm=20splice=20parity,=20vitest=20D?= =?UTF-8?q?oS/`--`/re-eval=20guards,=20prompt=20doctrine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-34 adversarial review (gpt-5.6-sol xhigh @ cfe1dd321) — 4 med + 1 low, all fixed: - R34-1 (med): reproduce npm appendNegatedPatterns' EXACT mutation order — its `splice(i)` then `++i` skips the negation adjacent to a removed one. Replaced the simultaneous list-comprehension removal with an index loop (`del` then `i += 1`), so `["packages/**","!packages/**","!packages/*","packages/app"]` leaves `!packages/*` in force and excludes `packages/app` (matching upstream). - R34-2 (med): a ~1MB no-whitespace package.json script made the shell lexer quadratic (~10.8s). Added a per-script length cap (_MAX_SCRIPT_LEN) — an oversized script fails vitest proof closed quickly; the bounded ancestor walk keeps the aggregate bounded. - R34-3 (med): recognize `--` as the options terminator after an exec/dlx/x subcommand, so `npm exec -- vitest` is proof while option-value shadows (`npx --package vitest`) stay negative. - R34-4 (med): shell_safe_substitute now rejects templates that RE-EVALUATE the value as code — `eval {file}`, `bash -c {file}`, `sh -c {file}` — where the second parse undoes shlex.quote (confirmed injecting via bash). A bare `sh {file}`/`bash {file}` (run the file as a script — shipped Shell/Bash/Zsh templates) and a non-shell `-c` option (`pytest -c cfg`) still substitute. Real bash regressions. - R34-5 (low): raised prompt doctrine altitude — R9's matcher/DP performance mechanics and the agentic_fix substitution paragraph now state the OBSERVABLE bounded/fail-closed outcomes and defer HOW (caching, tokenizer, masking) to tests. Re-syncs .pdd/meta/get_test_command_python.json. 240 focused tests pass, E2E green, pylint 10.00. npm/pnpm parity re-verified against @npmcli/map-workspaces. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 ++--- pdd/get_run_command.py | 52 +++++++++++++++++++++- pdd/get_test_command.py | 42 ++++++++++++----- pdd/prompts/agentic_fix_python.prompt | 2 +- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_run_command.py | 19 ++++++++ tests/test_get_test_command.py | 24 +++++++++- 7 files changed, 131 insertions(+), 20 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 35c2e9025d..c2ea7bb83e 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T08:55:00.000000+00:00", + "timestamp": "2026-07-13T09:20:00.000000+00:00", "command": "test", - "prompt_hash": "38ed6f1f9d91af9e22c755a6a559403205436b87b989e1b2dd2a436a3b7f3f2e", - "code_hash": "2009195a773bc21ceff342b93e90e21ccddb565df93d34f44d2b9f6c855a9d88", + "prompt_hash": "e235589fdc6f22820c6c060f148eae65f1bafbebef8eb03dd783ce5aeb7db1f8", + "code_hash": "9328f592e025e19a3d113835e84f94eccb61f02e73a377bb421d92aa7bbf3a0c", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "6d13c7f243afa8a9c073a8a928facc40647e742afe9031270998c3f68f5b6859", + "test_hash": "bac04984834dc54610d8888a7c31626d8df2a8af1b47332f2cb98f96490ceaca", "test_files": { - "test_get_test_command.py": "6d13c7f243afa8a9c073a8a928facc40647e742afe9031270998c3f68f5b6859" + "test_get_test_command.py": "bac04984834dc54610d8888a7c31626d8df2a8af1b47332f2cb98f96490ceaca" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 71927074de..7c23df609e 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -2,10 +2,52 @@ 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 _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 command clause runs ``eval`` or a shell with ``-c`` + (``bash -c {file}``, ``sh -c {file}``). A bare ``sh {file}`` / ``bash {file}`` (run + the *file* as a script — the shipped Shell/Bash/Zsh templates) is SAFE and NOT + rejected: only the ``-c`` *code* argument is. Each command clause's leading command is + inspected (quote/escape-aware split); 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 + clauses, clause = [], [] + for tok in lex: + if tok in (";", "&", "&&", "|", "||"): + clauses.append(clause) + clause = [] + else: + clause.append(tok) + clauses.append(clause) + except ValueError: + return True + for toks in clauses: + j = 0 + while j < len(toks) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[j]): + j += 1 # skip leading VAR=value environment assignments + if j >= len(toks): + continue + cmd = toks[j].split("/")[-1] + if cmd == "eval": + return True + if cmd in _REEVAL_SHELLS and "-c" in toks[j + 1:]: + 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 @@ -37,7 +79,11 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str * 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). + 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 @@ -68,6 +114,10 @@ def shell_safe_substitute(template: str, values: Dict[str, str]) -> Optional[str 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 diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 87e78f8493..3c5de809e7 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -1100,13 +1100,22 @@ def _matches_any(pattern_list) -> bool: if negated: neg_groups.append(expanded) else: - neg_groups = [ - grp for grp in neg_groups - if not any( + # Reproduce upstream's EXACT mutation order: it iterates + # ``for (i=0; i bool: _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 first non-boolean-flag token names the ``vitest`` binary. Any - arg-taking/unknown flag (not a known boolean flag) fails closed, so ``npx --package - vitest echo ok`` — where ``vitest`` is the option's value, not the binary — is False.""" - for tok in tokens: + """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("-"): @@ -1392,7 +1411,10 @@ def _script_invokes_vitest(script: str) -> bool: ESCAPE-aware shell lexer and split into command clauses on unquoted control operators (``;`` ``&`` ``&&`` ``|`` ``||``), so ``echo x\\; vitest`` stays one clause (the escaped ``;`` is a literal argument, not a boundary) and ``echo 'a; b' vitest`` is - not mis-split. Malformed (unbalanced-quote) scripts fail closed.""" + not mis-split. An oversized script (a manifest padding attack) and a malformed + (unbalanced-quote) script both fail closed.""" + if len(script) > _MAX_SCRIPT_LEN: + return False try: lex = shlex.shlex(script, posix=True, punctuation_chars=True) lex.whitespace_split = True diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index 18ad7f5efe..b32bb4554c 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -8,7 +8,7 @@ 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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) via a **shell-lexical-context-aware single pass** (`shell_safe_substitute`). `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 (`{test}x`, `./{test}`, `{test}.log` all substitute correctly). Safety MUST be decided from the template's actual shell-lexical context, NOT from mere whitespace bounds: the substitution is unsafe — and MUST be refused so the verification gate fails closed — ONLY where the inserted quoting could be reinterpreted: the placeholder inside the template's own single/double quotes or backticks (`pytest "{test}"`, even a space-surrounded `echo " {test} "` sitting *inside* enclosing quotes), immediately preceded by `$` (`${test}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body (`shlex.quote` single-quoting does not stop a `$(...)` in a heredoc body, and a value newline breaks out of a comment). Any multiline template (a newline — the only way to form a heredoc body) is refused outright, as is any template containing `$`, a backtick, or `(`/`)`, or a brace-expansion / pathname-expansion metacharacter (`{`, `}`, `*`, `?`, `[`, `]`, `~`) OUTSIDE a placeholder token (an unquoted `{a,b}` or glob would change the command's word count, and a value's `,` is left unquoted by `shlex.quote`). An empty placeholder key and an escaped placeholder (`\{test}`) are refused too. Substitution MUST be single-pass: an inserted value that itself contains a literal `{test}`/`{cwd}` MUST NOT be rescanned as another placeholder (a sequential `str.replace` chain would re-expand it). Resolve BOTH `{test}` and `{cwd}` to ABSOLUTE paths anchored at the supplied `cwd` (not the process CWD): a relative `{test}` targets the same file `run_agentic_fix` resolved, and an absolute `{cwd}` prevents a template like `cd {cwd} && pytest {test}` from double-joining a relative `cwd` onto the process directory. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient environment state. + - **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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) via a **shell-lexical-context-aware single pass** (`shell_safe_substitute`). `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 (`{test}x`, `./{test}`, `{test}.log` all substitute correctly). Safety MUST be decided from the template's actual shell-lexical context, NOT from mere whitespace bounds: the substitution is unsafe — and MUST be refused so the verification gate fails closed — ONLY where the inserted quoting could be reinterpreted: the placeholder inside the template's own single/double quotes or backticks (`pytest "{test}"`, even a space-surrounded `echo " {test} "` sitting *inside* enclosing quotes), immediately preceded by `$` (`${test}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body (`shlex.quote` single-quoting does not stop a `$(...)` in a heredoc body, and a value newline breaks out of a comment). Any multiline template (a newline — the only way to form a heredoc body) is refused outright, as is any template containing `$`, a backtick, or `(`/`)`, or a brace-expansion / pathname-expansion metacharacter (`{`, `}`, `*`, `?`, `[`, `]`, `~`) OUTSIDE a placeholder token (an unquoted `{a,b}` or glob would change the command's word count, and a value's `,` is left unquoted by `shlex.quote`). An empty placeholder key and an escaped placeholder (`\{test}`) are refused too. These are the OBSERVABLE MUST/MUST NOT outcomes; HOW the helper decides them (its tokenizer, scan, or masking strategy) is implementation detail to be pinned by tests, not mandated here. Substitution MUST be single-pass: an inserted value that itself contains a literal `{test}`/`{cwd}` MUST NOT be rescanned as another placeholder (a sequential `str.replace` chain would re-expand it). Resolve BOTH `{test}` and `{cwd}` to ABSOLUTE paths anchored at the supplied `cwd` (not the process CWD): a relative `{test}` targets the same file `run_agentic_fix` resolved, and an absolute `{cwd}` prevents a template like `cd {cwd} && pytest {test}` from double-joining a relative `cwd` onto the process directory. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient 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. diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 571ea32bfd..3511a160bd 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -32,7 +32,7 @@ R7 (MUST): Treat `pnpm-workspace.yaml` as authoritative when present — pnpm ig R8 (MUST): **Fail closed** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). For JSON (`package.json`, `lerna.json`), the non-standard constants `NaN`/`Infinity`/`-Infinity` — which Python's `json` accepts but strict JSON (npm/Node's `JSON.parse`) rejects — MUST fail the whole-document parse closed even when they sit outside the workspace field (use `parse_constant`), matching npm's rejection. A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 **core schema**, not PyYAML's default YAML 1.1 — and the 1.1 implicit-resolver table MUST be replaced wholesale, not merely patched, because the discrepancy runs both directions. Forms YAML 1.2 resolves as non-strings (`true`/`false`, `null`/`~`/**the empty scalar** — an empty list item `- ` is null, MUST reject the declaration, `[-+]?[0-9]+`/`0o…`/`0x…` ints, and exponent/`.inf`/`.nan` floats) are non-string entries that MUST be rejected (so unquoted `0o12`/`1e3` reject the entry as pnpm does). Conversely, forms YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS — `yes`/`no`/`on`/`off`, `0b10` binary, `1:20` sexagesimal, `2020-01-01` timestamps, `1_000` underscore ints — are valid string globs that MUST NOT reject the declaration (a quoted `"0o12"`, a string in both, likewise stays a valid glob). Integers MUST also be *constructed* per YAML 1.2 — an ordinary digit run is base-10 (`012` is 12, not octal 10) — so that duplicate mapping keys such as `012:` and `12:` (both integer 12) are detected. Duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed (compared by YAML tag plus canonical value, so `012`/`12` collide while a string `"12"` and int `12` stay distinct). -R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. The *segment matcher* likewise MUST bound its per-character work: a manifest of many long wildcard-heavy globs against a deep path can stay within the DP-cell cap yet burn CPU if each cell reconstructs per-character state — so precompute per-segment data once (not per DP cell), charge character comparisons against a shared budget, and fast-reject a `**`-free pattern whose segment count differs from the path (it can never match). Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. +R9 (MUST): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. The pattern matcher likewise MUST bound its per-CHARACTER work, not merely a coarse segment/cell count: a manifest of many long wildcard-heavy globs against a deep path MUST NOT burn CPU even while staying within an output/segment cap — demonstrate the worst case with a timing or work-count test. Any untrusted script text parsed for runner detection (e.g. a `package.json` script value fed to a shell tokenizer) MUST likewise be length-bounded so a padded, near-manifest-size value cannot make parsing super-linear. Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. HOW the matcher meets these bounds (segment-state caching, per-comparison work accounting, fast-rejecting a `**`-free pattern whose segment count differs) is implementation detail fixed by the timing/work-count tests, not mandated here. 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. diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index 2a51419f6c..fe41506fd0 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -289,3 +289,22 @@ def test_brace_and_glob_contexts_are_refused(self): 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}"): + assert shell_safe_substitute(tpl, {"{file}": "x"}) is None, tpl + # Safe: the shell runs the FILE (value is a filename, single-quoted). + assert shell_safe_substitute("sh {file}", {"{file}": "/a.sh"}) == "sh /a.sh" + assert shell_safe_substitute( + "pytest -c cfg {file}", {"{file}": "/t.py"}) == "pytest -c cfg /t.py" + # Prove the eval exploit the guard prevents is genuine. + with tempfile.TemporaryDirectory() as d: + naive = "eval " + shlex.quote("$(touch PWN_EVAL)") + subprocess.run(["bash", "-lc", naive], cwd=d, capture_output=True, timeout=10) + assert "PWN_EVAL" in os.listdir(d), "expected the naive eval form to inject" diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 720cdab0c4..fd885d6189 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1306,6 +1306,16 @@ def test_npm_leading_normalization_applies_once_before_brace_expansion(self): 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_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.""" @@ -1507,14 +1517,24 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): assert "npx vitest run" in cmd, cmd assert cwd == repo.resolve() # A script invoking the vitest BINARY proves it — directly, via a direct package - # runner (npx/bunx), or via an explicit exec subcommand (pnpm exec / bun x). + # runner (npx/bunx), via an explicit exec subcommand (pnpm exec / bun x), or with + # a `--` options terminator (npm exec -- vitest). for script in ("vitest run", "npx vitest", "npx --yes vitest", "bunx vitest", "pnpm exec vitest", "pnpm dlx vitest", "bun x vitest", - "yarn exec vitest", "./node_modules/.bin/vitest"): + "yarn exec vitest", "npm exec -- vitest", "pnpm dlx -- vitest", + "./node_modules/.bin/vitest"): (repo / "package.json").write_text( json.dumps({"scripts": {"test": script}})) cmd2, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") assert cmd2 is not None and "npx vitest run" in cmd2, (script, cmd2) + # DoS: an oversized (near-1MB) no-whitespace script value must fail proof closed + # QUICKLY (a quadratic shell-lexer cost is unacceptable), not be adopted. + import time as _time + (repo / "package.json").write_text( + json.dumps({"scripts": {"test": "x" * (900 * 1024)}})) + _t0 = _time.time() + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None + assert _time.time() - _t0 < 2.0, "oversized script must fail closed quickly" # A script where "vitest" is not the invoked BINARY — a bare argument; an arg to # echo/node/command; the value of a runner option flag; a package.json SCRIPT # name (`npm run vitest` / bare `pnpm vitest`, which may be a Vite-only shadow); From 00a73b74aee3ef2359a6768dd85378d2cedd80ca Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 03:40:58 -0700 Subject: [PATCH 45/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-35=20r?= =?UTF-8?q?eview=20fixes=20=E2=80=94=20segment=20matcher=20star=20preceden?= =?UTF-8?q?ce,=20re-eval=20bypass=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-35 adversarial review (gpt-5.6-sol xhigh @ 0a5b11250) — 2 med findings, both fixed: - R35-1 (med): the segment matcher tested literal equality before wildcard handling, so a pattern `*` was consumed as a literal against a name `*` (`packages/**` used as a path during npm's pattern-vs-pattern pruning). `**` therefore failed to match the glob `*`, so `["packages/**","!packages/*"]` did not prune `packages/**` and packages under it stayed members. Handle a pattern `*` as a wildcard FIRST — now that declaration excludes both `packages/app` and a deep `packages/deep/app` (with an end-to-end ancestor-config non-inheritance test), and a literal `*` in a real segment still matches. - R35-2 (med): the re-evaluation check missed three bypasses — a combined short-option group (`bash -lc`, `sh -xc`), a command wrapper (`env bash -c`, `command sh -c`), and a mid-word `#` (`echo a#b && bash -c {file}`) that shlex wrongly treated as a comment, dropping the malicious clause. Now disable shlex comment parsing, detect `c` inside a combined single-dash flag, skip command wrappers to find the effective command, and fail closed. Real bash regressions confirm bash -lc / env bash -c inject in the naive form and are refused; bare `sh {file}`/`bash {file}` and non-shell `-c` stay safe. Re-syncs .pdd/meta/get_test_command_python.json (code+test hashes). 242 focused tests pass, E2E green, pylint 10.00. npm/pnpm parity re-verified against @npmcli/map-workspaces. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 8 ++--- pdd/get_run_command.py | 49 +++++++++++++++++++++----- pdd/get_test_command.py | 12 ++++--- tests/test_get_run_command.py | 24 +++++++++---- tests/test_get_test_command.py | 38 ++++++++++++++++++++ 5 files changed, 107 insertions(+), 24 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index c2ea7bb83e..9b652166c4 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T09:20:00.000000+00:00", + "timestamp": "2026-07-13T09:45:00.000000+00:00", "command": "test", "prompt_hash": "e235589fdc6f22820c6c060f148eae65f1bafbebef8eb03dd783ce5aeb7db1f8", - "code_hash": "9328f592e025e19a3d113835e84f94eccb61f02e73a377bb421d92aa7bbf3a0c", + "code_hash": "31b647a6c2e44a26792f29b6e7dd3a0bb9cbba2670023e74662fbf6bdcc51c8e", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "bac04984834dc54610d8888a7c31626d8df2a8af1b47332f2cb98f96490ceaca", + "test_hash": "18e61b7d6d5fb7bf13dacf4127597f235b6fec31743101accc88082a2c9dfbb8", "test_files": { - "test_get_test_command.py": "bac04984834dc54610d8888a7c31626d8df2a8af1b47332f2cb98f96490ceaca" + "test_get_test_command.py": "18e61b7d6d5fb7bf13dacf4127597f235b6fec31743101accc88082a2c9dfbb8" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 7c23df609e..132db7618e 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -10,21 +10,40 @@ # 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"}) +# Command wrappers that run the FOLLOWING command — a shell hidden behind one of these +# (``env bash -c``) is still a re-evaluation. A wrapper in command position is skipped +# when locating the effective command. +_COMMAND_WRAPPERS = frozenset({ + "env", "command", "exec", "nohup", "nice", "time", "setsid", "stdbuf", + "timeout", "ionice", "xargs", "sudo", "doas", "builtin", +}) + + +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 command clause runs ``eval`` or a shell with ``-c`` - (``bash -c {file}``, ``sh -c {file}``). A bare ``sh {file}`` / ``bash {file}`` (run - the *file* as a script — the shipped Shell/Bash/Zsh templates) is SAFE and NOT - rejected: only the ``-c`` *code* argument is. Each command clause's leading command is - inspected (quote/escape-aware split); 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.""" + (``bash -c {file}``, ``bash -lc {file}``, ``env bash -c {file}``). A bare ``sh {file}`` + / ``bash {file}`` (run the *file* as a script — the shipped Shell/Bash/Zsh templates) + is SAFE and NOT rejected: only the ``-c`` *code* argument is; a non-shell ``-c`` option + (``pytest -c cfg``) is also safe. + + Comment parsing is disabled so a mid-word ``#`` (``echo a#b``, which Bash does NOT + treat as a comment) cannot hide a later ``&& bash -c`` 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 (";", "&", "&&", "|", "||"): @@ -36,15 +55,27 @@ def _feeds_value_into_reevaluation(template: str) -> bool: except ValueError: return True for toks in clauses: + # Locate the effective command: skip leading ``VAR=value`` assignments and + # command wrappers (``env``/``command``/``exec``/…). j = 0 - while j < len(toks) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[j]): - j += 1 # skip leading VAR=value environment assignments + while j < len(toks): + tok = toks[j] + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok): + j += 1 + continue + base = tok.split("/")[-1] + if base in _COMMAND_WRAPPERS: + j += 1 + continue + break if j >= len(toks): continue cmd = toks[j].split("/")[-1] if cmd == "eval": return True - if cmd in _REEVAL_SHELLS and "-c" in toks[j + 1:]: + # A shell with a ``-c``-bearing option anywhere in the clause re-evaluates its + # code argument. (Scanning the whole clause covers wrapper-passed options.) + if cmd in _REEVAL_SHELLS and any(_is_dash_c_option(t) for t in toks[j + 1:]): return True return False diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 3c5de809e7..1dc79f4d02 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -469,12 +469,16 @@ def _wildcard_units_match(name_u: list, pat_u: list, work[0] -= 1 if work[0] < 0: raise _PatternBudgetError - if p < npat and (pat_u[p] == q or pat_u[p] == name_u[s]): - s += 1 - p += 1 - elif p < npat and pat_u[p] == star: + 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 diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index fe41506fd0..baaf63057d 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -297,14 +297,24 @@ def test_reevaluation_contexts_are_refused(self): 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}"): + "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}"): assert shell_safe_substitute(tpl, {"{file}": "x"}) is None, tpl - # Safe: the shell runs the FILE (value is a filename, single-quoted). + # 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" - # Prove the eval exploit the guard prevents is genuine. - with tempfile.TemporaryDirectory() as d: - naive = "eval " + shlex.quote("$(touch PWN_EVAL)") - subprocess.run(["bash", "-lc", naive], cwd=d, capture_output=True, timeout=10) - assert "PWN_EVAL" in os.listdir(d), "expected the naive eval form to inject" + assert shell_safe_substitute("echo a#b {file}", {"{file}": "x"}) == "echo a#b x" + # 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")): + 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 fd885d6189..6913676a71 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -16,6 +16,7 @@ _workspace_globs_for, _belongs_to_ancestor_workspace, _package_matches_workspace, + _wildcard_segment_match, _expand_braces, _BraceBudgetError, _PatternBudgetError, @@ -510,6 +511,28 @@ def test_pnpm_exclusion_pattern_excludes_matching_package(self, tmp_path): 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. @@ -1316,6 +1339,21 @@ def test_npm_negation_removal_uses_splice_while_increment_semantics(self): 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.""" From b2daee025028bf26634b85446c1bf4511abb4e81 Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 04:56:16 -0700 Subject: [PATCH 46/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-36=20r?= =?UTF-8?q?eview=20fixes=20=E2=80=94=20wrapper=20re-eval,=20npm=20dot:true?= =?UTF-8?q?=20ignores,=20#-comment=20split,=20empty=20segments,=20vitest?= =?UTF-8?q?=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-36 adversarial review (gpt-5.6-sol xhigh @ e38ee45e2) — 3 med + 3 low, all fixed: - R36-1 (med): re-eval detection missed option-bearing wrappers (`timeout 5 bash -c`, `env -i bash -c`, `nice -n 5 bash -c`) whose operands hid the shell. Replaced the wrapper-skip/command-position logic with a conservative holistic per-clause scan: refuse a clause containing both a shell name and a `-c`-bearing option, or an `eval`. Real bash regressions. - R36-2 (med): npm applies surviving negations as glob's dot:true IGNORE set, so `!packages/*` excludes `packages/.shadow`; the matcher used dot:false everywhere. Added a `dot` flag threaded through the matcher — final negations match dot:true, positives + pattern-vs-pattern stay dot:false. - R36-3 (med): npm's appendNegatedPatterns preprocessing uses default minimatch, where a leading-`#` NEGATION is a comment matching nothing (never removed/pruning), but the final glob is nocomment (literal). `["**","!#foo","#foo"]` now keeps `#foo` excluded; `#` positives match literally in final. Split the semantics; updated prompt + tests. - R36-4 (low): collapse empty/trailing-slash segments in the raw pattern-string used for pattern-vs-pattern pruning (minimatch collapses `//`), so `packages//app` prunes `!packages/*`. - R36-5 (low): vitest script lexer is now word-boundary aware for `#` — a mid-word `#` (`echo a#b && npx vitest`) is literal (proves), a leading-`#` comment does not. - R36-6 (low): a Vitest dependency value must be a STRING spec; `"vitest": false/null/` number no longer proves Vitest. Re-syncs .pdd/meta/get_test_command_python.json (prompt+code+test). 244 focused tests pass, E2E green, pylint 10.00. npm/minimatch parity re-verified. Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +- pdd/get_run_command.py | 42 ++--- pdd/get_test_command.py | 181 +++++++++++---------- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_run_command.py | 9 +- tests/test_get_test_command.py | 71 ++++++-- 6 files changed, 179 insertions(+), 136 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 9b652166c4..2d32f4eae1 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T09:45:00.000000+00:00", + "timestamp": "2026-07-13T10:15:00.000000+00:00", "command": "test", - "prompt_hash": "e235589fdc6f22820c6c060f148eae65f1bafbebef8eb03dd783ce5aeb7db1f8", - "code_hash": "31b647a6c2e44a26792f29b6e7dd3a0bb9cbba2670023e74662fbf6bdcc51c8e", + "prompt_hash": "d187cac7614d117b45e8c528d970ade01eee0b07963328aff57a2af6b2d1c169", + "code_hash": "2c87fe19428afb6b5d195f5d56f6b0d5c585361a6636421187a601f6ab2f6e70", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "18e61b7d6d5fb7bf13dacf4127597f235b6fec31743101accc88082a2c9dfbb8", + "test_hash": "cd0a9f2c46edc27c730c07f31c8f76b33b26045636369cd481b1809dc9badbcb", "test_files": { - "test_get_test_command.py": "18e61b7d6d5fb7bf13dacf4127597f235b6fec31743101accc88082a2c9dfbb8" + "test_get_test_command.py": "cd0a9f2c46edc27c730c07f31c8f76b33b26045636369cd481b1809dc9badbcb" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 132db7618e..271c0d1869 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -10,13 +10,6 @@ # 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"}) -# Command wrappers that run the FOLLOWING command — a shell hidden behind one of these -# (``env bash -c``) is still a re-evaluation. A wrapper in command position is skipped -# when locating the effective command. -_COMMAND_WRAPPERS = frozenset({ - "env", "command", "exec", "nohup", "nice", "time", "setsid", "stdbuf", - "timeout", "ionice", "xargs", "sudo", "doas", "builtin", -}) def _is_dash_c_option(tok: str) -> bool: @@ -31,10 +24,13 @@ 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 command clause runs ``eval`` or a shell with ``-c`` - (``bash -c {file}``, ``bash -lc {file}``, ``env bash -c {file}``). A bare ``sh {file}`` - / ``bash {file}`` (run the *file* as a script — the shipped Shell/Bash/Zsh templates) - is SAFE and NOT rejected: only the ``-c`` *code* argument is; a non-shell ``-c`` option - (``pytest -c cfg``) is also safe. + (``bash -c {file}``, ``bash -lc {file}``). To stay robust against option-bearing + command WRAPPERS whose operands make the effective-command position ambiguous + (``timeout 5 bash -c``, ``env -i bash -c``, ``nice -n 5 bash -c``), a clause is + refused CONSERVATIVELY when it contains BOTH a shell name and a ``-c``-bearing option, + or an ``eval`` token — anywhere in the clause. A bare ``sh {file}`` / ``bash {file}`` + (run the *file* as a script — the shipped Shell/Bash/Zsh templates) has no ``-c`` and + 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``, which Bash does NOT treat as a comment) cannot hide a later ``&& bash -c`` clause. An unparseable template @@ -55,27 +51,11 @@ def _feeds_value_into_reevaluation(template: str) -> bool: except ValueError: return True for toks in clauses: - # Locate the effective command: skip leading ``VAR=value`` assignments and - # command wrappers (``env``/``command``/``exec``/…). - j = 0 - while j < len(toks): - tok = toks[j] - if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok): - j += 1 - continue - base = tok.split("/")[-1] - if base in _COMMAND_WRAPPERS: - j += 1 - continue - break - if j >= len(toks): - continue - cmd = toks[j].split("/")[-1] - if cmd == "eval": + bases = [t.split("/")[-1] for t in toks] + if "eval" in bases: return True - # A shell with a ``-c``-bearing option anywhere in the clause re-evaluates its - # code argument. (Scanning the whole clause covers wrapper-passed options.) - if cmd in _REEVAL_SHELLS and any(_is_dash_c_option(t) for t in toks[j + 1:]): + if any(b in _REEVAL_SHELLS for b in bases) \ + and any(_is_dash_c_option(t) for t in toks): return True return False diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 1dc79f4d02..5344ca6047 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -330,7 +330,8 @@ class TestCommand: 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) -> bool: + 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 @@ -389,15 +390,23 @@ def _relative_matches_workspace_glob(rel_parts: Tuple[str, ...], pattern: str, # 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) + return _segments_dp_match(rel, pat_parts, work, dot=dot) -def _segments_dp_match(rel: list, pat_parts: list, work: list) -> bool: +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, ``dot:false``). 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.""" + ``**`` 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] @@ -415,8 +424,9 @@ def _segments_dp_match(rel: list, pat_parts: list, work: list) -> bool: # ``**`` 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 (not rel_is_dot[i] and dp[i + 1][j]) - elif rel_is_dot[i] and not pat_is_dot[j]: + 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 @@ -1021,18 +1031,14 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, # mis-classified (e.g. treating ``!!!packages/foo`` as a literal and # falsely proving membership). raise _PatternBudgetError - # A *positive* pattern whose effective form is a minimatch comment (a - # leading ``#``) matches NO concrete path, so it must never be matched - # literally into a member. But it is still a real pattern STRING: under - # npm's ``appendNegatedPatterns`` a later positive — comment or not — whose - # pattern string is matched by an earlier negation REMOVES that negation - # (so ``["packages/**", "!**", "#noop"]`` re-includes ``packages/**``). - # Record the comment flag (classified after the SAME leading normalization - # the matcher applies, so ``/#*``/``.//#*`` count) and resolve it per-source - # below rather than dropping the pattern here. (A ``!``-exclusion body - # starting with ``#`` is left to match its literal ``#`` — the safe - # direction, since a spurious exclusion only removes a member.) - is_comment = (not negated) and _effective_leading(body).startswith("#") + # 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 @@ -1050,10 +1056,10 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, raise _PatternBudgetError parsed.append((negated, norm_body, expanded, is_comment)) - def _matches_any(pattern_list) -> bool: + def _matches_any(pattern_list, dot: bool = False) -> bool: return any( _relative_matches_workspace_glob( - rel_parts, p, cells, work, pre_normalized=True) + rel_parts, p, cells, work, pre_normalized=True, dot=dot) for p in pattern_list ) @@ -1063,80 +1069,71 @@ def _matches_any(pattern_list) -> bool: # earlier ``!`` excluded, per-path. A positive comment matches no path. member = False for negated, _body, expanded, is_comment in parsed: - if is_comment: + if is_comment and not negated: continue 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 - # entirely (``minimatch(pattern, negatedPattern)`` upstream), so - # ``["packages/**", "!packages/legacy/**", "packages/legacy/app"]`` drops the - # ``!packages/legacy/**`` exclusion and every ``packages/**`` path — including - # ``packages/legacy/app`` — is a member again. A positive COMMENT pattern - # (``#noop``) also participates in this removal (its pattern string can match an - # earlier negation) even though it never matches a concrete path. - # - # The removal MUST compare the RAW positive pattern *string* (braces literal, as - # upstream ``minimatch(pattern, negatedPattern)`` treats the pattern arg as a - # path) against each negation glob — NOT its brace expansions: for - # ``["packages/**", "!packages/a", "packages/{a,b}"]`` the raw ``packages/{a,b}`` - # does NOT match ``packages/a``, so the exclusion survives and ``packages/a`` - # stays excluded while ``packages/b`` is a member. Braces are expanded only for - # the final concrete-path membership test. Each negation is a group (its own - # brace expansions) removed atomically. - # - # Upstream then runs a SECOND, symmetric step: each SURVIVING negation prunes any - # positive PATTERN whose raw 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 ``packages/?``. - # A path is a member iff it matches a surviving non-comment positive expansion - # and no surviving negation expansion. - pos_groups = [] # (raw_pos_parts, expansions) for non-comment positives - neg_groups = [] # per-negation lists of concrete expansions, still in force + # 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 pattern string as a literal path (braces NOT expanded), already - # leading-normalized the SAME way npm normalizes every pattern. - raw_parts = tuple(norm_body.split("/")) + raw_parts = _raw_parts(norm_body) if negated: - neg_groups.append(expanded) + neg_groups.append((is_comment, expanded)) else: - # Reproduce upstream's EXACT mutation order: it iterates - # ``for (i=0; i bool: ESCAPE-aware shell lexer and split into command clauses on unquoted control operators (``;`` ``&`` ``&&`` ``|`` ``||``), so ``echo x\\; vitest`` stays one clause (the escaped ``;`` is a literal argument, not a boundary) and ``echo 'a; b' vitest`` is - not mis-split. An oversized script (a manifest padding attack) and a malformed - (unbalanced-quote) script both fail closed.""" + not mis-split. Comment handling is word-boundary aware, matching Bash: a token that + BEGINS with ``#`` starts a comment to end of line (``echo hi # vitest`` does NOT + prove), while a mid-word ``#`` is literal (``echo a#b && npx vitest`` DOES prove). An + oversized script (a manifest padding attack) and a malformed (unbalanced-quote) script + both fail closed.""" if len(script) > _MAX_SCRIPT_LEN: return False try: lex = shlex.shlex(script, posix=True, punctuation_chars=True) lex.whitespace_split = True + lex.commenters = "" # handle '#' by word boundary below (mid-word '#' is literal) clause: list = [] for tok in lex: + if tok.startswith("#"): + break # a word-starting '#' comments the rest of the line if tok in _CLAUSE_OPERATORS: if _clause_invokes_vitest(clause): return True @@ -1438,14 +1441,16 @@ def _script_invokes_vitest(script: str) -> bool: 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, 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.""" + ``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 "vitest" in deps: + if isinstance(deps, dict) and isinstance(deps.get("vitest"), str): return True scripts = manifest.get("scripts") if isinstance(scripts, dict): diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 3511a160bd..31cc80a021 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics whose *combination rule depends on the declaring source*, because the two ecosystems' matchers genuinely differ — BOTH honor re-inclusion of a path an earlier `!` exclusion removed, but by different algorithms, so the difference is only visible on the *sibling* of a re-included path. For **pnpm** (a `pnpm-workspace.yaml`, `@pnpm/matcher`) evaluation is **order-dependent, last matching pattern wins PER PATH**: a positive includes, a `!` exclusion (e.g. `!**/test/**`) excludes, and a *later* positive re-includes only the specific paths it matches. For **npm/yarn/lerna** (`@npmcli/map-workspaces`) apply npm's actual **`appendNegatedPatterns` preprocessing**: walking the patterns in order, a later POSITIVE pattern whose *pattern string* is matched by an earlier negation's glob (`minimatch(pattern, negatedPattern)`) REMOVES that negation wholesale; a path is then a member iff it matches a surviving positive and no surviving negation. So for `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` BOTH tools make `packages/legacy/app` a member, but the SIBLING `packages/legacy/other` is a member under **npm** (dropping `!packages/legacy/**` un-excludes the whole subtree) yet NOT under **pnpm** (its last matching pattern is still the exclusion). Likewise a later positive equal to an earlier exclusion (`["packages/*", "!packages/app", "packages/app"]`) re-includes `packages/app` under both. Choose the rule from the source that actually governs the ancestor — apply pnpm's per-path last-match-wins ONLY when the declaring file is a `pnpm-workspace.yaml`, npm's negation-preprocessing otherwise; do NOT model either as a simple terminal "any-exclude-wins" (that denies npm's and pnpm's legitimate re-inclusions) nor collapse the two (npm re-includes the sibling, pnpm does not). **Built-in exclusion (both tools):** a path *inside* any `node_modules/` directory is NEVER a member — npm appends `**/node_modules/**` to its ignore set and pnpm/yarn skip `node_modules` during discovery — so `["**"]` MUST NOT make `node_modules/dep` a member (a leaf directory literally *named* `node_modules` with nothing under it is not itself excluded). npm's leading normalization (below) is applied to every pattern, positive and negation, before these comparisons. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A positive glob whose effective form begins with `#` is a minimatch comment: it matches nothing and MUST NOT be matched literally into a member — and the leading-prefix normalization (strip leading `/` and at most one `./`) MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are recognized as comments too (else `/#*` would falsely match a package named `#…`). A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE, as the single regex `^\.?/+` — an optional leading `.` followed by the ENTIRE immediately-following run of `/`, stripped in one pass (NOT a `/` run and a `./` removed independently). So `./packages/*`, `/packages/*`, `//packages/*`, `///packages/*`, and — because the dot plus its whole trailing slash run go together — `.//packages/*` and `.///packages/*` all normalize to `packages/*` and DO match `packages/app`. Use the identical normalized value for matching and `#`-comment classification. A prefix left over AFTER that one pass is significant: the leading `.` is consumed only when a slash run immediately follows it, so `././packages/*` strips just its first `./` and keeps a second literal `.` segment, and `/./packages/*` normalizes to `./packages/*` (a literal `.` segment) — NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse `/./packages/*`). Apply minimatch `dot:false` semantics — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so `packages/*` excludes `packages/.shadow`, but `packages/.*` includes it). +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics whose *combination rule depends on the declaring source*, because the two ecosystems' matchers genuinely differ — BOTH honor re-inclusion of a path an earlier `!` exclusion removed, but by different algorithms, so the difference is only visible on the *sibling* of a re-included path. For **pnpm** (a `pnpm-workspace.yaml`, `@pnpm/matcher`) evaluation is **order-dependent, last matching pattern wins PER PATH**: a positive includes, a `!` exclusion (e.g. `!**/test/**`) excludes, and a *later* positive re-includes only the specific paths it matches. For **npm/yarn/lerna** (`@npmcli/map-workspaces`) apply npm's actual **`appendNegatedPatterns` preprocessing**: walking the patterns in order, a later POSITIVE pattern whose *pattern string* is matched by an earlier negation's glob (`minimatch(pattern, negatedPattern)`) REMOVES that negation wholesale; a path is then a member iff it matches a surviving positive and no surviving negation. So for `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` BOTH tools make `packages/legacy/app` a member, but the SIBLING `packages/legacy/other` is a member under **npm** (dropping `!packages/legacy/**` un-excludes the whole subtree) yet NOT under **pnpm** (its last matching pattern is still the exclusion). Likewise a later positive equal to an earlier exclusion (`["packages/*", "!packages/app", "packages/app"]`) re-includes `packages/app` under both. Choose the rule from the source that actually governs the ancestor — apply pnpm's per-path last-match-wins ONLY when the declaring file is a `pnpm-workspace.yaml`, npm's negation-preprocessing otherwise; do NOT model either as a simple terminal "any-exclude-wins" (that denies npm's and pnpm's legitimate re-inclusions) nor collapse the two (npm re-includes the sibling, pnpm does not). **Built-in exclusion (both tools):** a path *inside* any `node_modules/` directory is NEVER a member — npm appends `**/node_modules/**` to its ignore set and pnpm/yarn skip `node_modules` during discovery — so `["**"]` MUST NOT make `node_modules/dep` a member (a leaf directory literally *named* `node_modules` with nothing under it is not itself excluded). npm's leading normalization (below) is applied to every pattern, positive and negation, before these comparisons. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A glob whose effective form begins with `#` is a minimatch comment ONLY under DEFAULT minimatch — which is exactly the option set npm's `appendNegatedPatterns` PREPROCESSING uses: there a `#`-leading NEGATION matches nothing (so a positive can neither remove it nor be pruned by it — `["**","!#foo","#foo"]` keeps `#foo` excluded). But npm's FINAL `glob` step matches `#` LITERALLY (nocomment), so a positive `#foo` DOES match a package directory literally named `#foo` and a negation `#foo` excludes it. Do NOT treat a `#` positive as "matches nothing" in final path matching. The leading-prefix normalization MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are classified after normalization too. A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE, as the single regex `^\.?/+` — an optional leading `.` followed by the ENTIRE immediately-following run of `/`, stripped in one pass (NOT a `/` run and a `./` removed independently). So `./packages/*`, `/packages/*`, `//packages/*`, `///packages/*`, and — because the dot plus its whole trailing slash run go together — `.//packages/*` and `.///packages/*` all normalize to `packages/*` and DO match `packages/app`. Use the identical normalized value for matching and `#`-comment classification. A prefix left over AFTER that one pass is significant: the leading `.` is consumed only when a slash run immediately follows it, so `././packages/*` strips just its first `./` and keeps a second literal `.` segment, and `/./packages/*` normalizes to `./packages/*` (a literal `.` segment) — NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse `/./packages/*`). Apply minimatch dot semantics per role. POSITIVE membership (and the pattern-vs-pattern preprocessing) use `dot:false` — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so a positive `packages/*` does not match `packages/.shadow`, but `packages/.*` does). npm applies its SURVIVING NEGATIONS as `glob`'s `dot:true` IGNORE set, however, so a wildcard NEGATION DOES exclude a leading-dot directory: under npm `["packages/.*", "!packages/*"]` EXCLUDES `packages/.shadow` even though the positive `packages/.*` matched it. Use `dot:true` for final negation matching, `dot:false` for positives and preprocessing. 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. diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index baaf63057d..06ee3726fa 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -302,7 +302,11 @@ def test_reevaluation_contexts_are_refused(self): # 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}"): + "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}"): 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. @@ -311,6 +315,9 @@ def test_reevaluation_contexts_are_refused(self): 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" # 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")): diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 6913676a71..8b466f52cb 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1270,18 +1270,54 @@ def test_node_modules_is_never_a_workspace_member(self): assert _package_matches_workspace( ("packages", "node_modules"), ["packages/*"], ordered=False) is True - def test_npm_positive_comment_participates_in_negation_removal(self): - """Under npm's ``appendNegatedPatterns`` a positive pattern — INCLUDING a - ``#`` comment — whose pattern string is matched by an earlier negation removes - that negation, even though a comment never matches a concrete path itself. So - ``["packages/**", "!**", "#noop"]`` re-includes ``packages/app`` under npm. - pnpm ignores the comment (its last matching pattern is still ``!**``).""" + 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 positive comment never matches a concrete path on its own. - assert _package_matches_workspace(("#noop",), ["#noop"], ordered=False) is False - assert _package_matches_workspace(("#noop",), ["#noop"], 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_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_npm_removal_compares_raw_unexpanded_positive_string(self): """npm's ``appendNegatedPatterns`` compares the RAW positive pattern STRING @@ -1549,11 +1585,26 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): # 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 → adopt vite.config.ts as Vitest config. + # 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 + # A mid-word '#' is literal (Bash), so a later real vitest clause still proves; + # a leading-'#' comment clause does not. + (repo / "package.json").write_text( + json.dumps({"scripts": {"test": "echo a#b && npx vitest"}})) + cmd4, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") + assert cmd4 is not None and "npx vitest run" in cmd4, cmd4 + (repo / "package.json").write_text( + json.dumps({"scripts": {"test": "echo hi # npx vitest"}})) + assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None # A script invoking the vitest BINARY proves it — directly, via a direct package # runner (npx/bunx), via an explicit exec subcommand (pnpm exec / bun x), or with # a `--` options terminator (npm exec -- vitest). From 207d481811890735410f521dddd2b8b5a1e4d8ab Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 05:58:14 -0700 Subject: [PATCH 47/56] =?UTF-8?q?fix(get=5Ftest=5Fcommand):=20round-37=20r?= =?UTF-8?q?eview=20fixes=20=E2=80=94=20pipe/here-string=20re-eval,=20termi?= =?UTF-8?q?nal-star=20budget,=20dot/dotdot,=20#-comment=20provenance,=20pn?= =?UTF-8?q?pm-#,=20empty-positive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-37 adversarial review (gpt-5.6-sol xhigh @ 2447ac211) — 7 findings; 6 fixed, 1 rejected as a false positive: - R37-1 (high): shell_safe_substitute now refuses a value PIPED (`printf %s {file} | bash`) or here-string'd (`bash <<< {file}`) into a re-evaluating shell — the `-c` check missed both. A re-eval shell name + a `|`/`<` in the template → refused. Real bash regressions; a pipe into a non-shell (grep) stays safe. - R37-2 (med): charge the trailing-`*` run in the segment matcher against the shared work budget — a long `*`-wall plus brace expansion and a `**` suffix could otherwise reach ~10^8 unbudgeted iterations under the cell cap. Aggregate adversarial test. - R37-3 (med): a `.`/`..` path segment (arising when a raw pattern string like `packages/./x` is matched as a path during npm pruning) is matched ONLY by an identical literal, never a wildcard/`**` (minimatch parity). - R37-4: REJECTED — the reviewer claimed Jest's JEST_CONFIG_EXT_ORDER excludes `.mts`, but Jest's current source lists `['.js','.ts','.mjs','.mts','.cjs','.cts','.json']` (includes `.mts`/`.cts`). Removing them would regress real jest.config.mts detection, so they are kept. - R37-5 (low): the Vitest script comment stripper is now quote/escape/newline aware — a quoted (`"# x"`), escaped (`\#`), or mid-word `#` is literal; an unquoted comment ends only its own line. - R37-6 (low): pnpm treats `#` LITERALLY (only `!` is special) — `['#app']` matches a `#app` dir and `['!#app','#app']` re-includes it; the npm-preprocessing comment rule no longer applies to pnpm. - R37-7 (low): an EMPTY positive pattern is preserved through npm appendNegatedPatterns and can remove a prior negation (`["packages/**","!**",""]` re-includes packages/**). Updates get_test_command R6 + get_run_command prompt; re-syncs the fingerprint. 248 focused tests pass, E2E green, pylint 10.00. npm/pnpm/minimatch parity verified against upstream (map-workspaces, @pnpm/matcher, jest constants). Co-Authored-By: Claude Opus 4.8 --- .pdd/meta/get_test_command_python.json | 10 +- pdd/get_run_command.py | 40 ++++--- pdd/get_test_command.py | 120 +++++++++++++++------ pdd/prompts/get_run_command_python.prompt | 9 +- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_run_command.py | 11 +- tests/test_get_test_command.py | 92 +++++++++++++--- 7 files changed, 211 insertions(+), 73 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 2d32f4eae1..837ffd6eb4 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T10:15:00.000000+00:00", + "timestamp": "2026-07-13T13:05:00.000000+00:00", "command": "test", - "prompt_hash": "d187cac7614d117b45e8c528d970ade01eee0b07963328aff57a2af6b2d1c169", - "code_hash": "2c87fe19428afb6b5d195f5d56f6b0d5c585361a6636421187a601f6ab2f6e70", + "prompt_hash": "12c03ff6bab9564cf6b17844c0ad87b5c75d07f50e3b40903c41422118f36ce2", + "code_hash": "2ff07e2c35845368cd7f0044ac35bdacf3d7084271fcdd9a261b5ad6b918711c", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "cd0a9f2c46edc27c730c07f31c8f76b33b26045636369cd481b1809dc9badbcb", + "test_hash": "a8eb44cdaea31b3dc5b24d98481ec9593268d725b1014e7e25b9b208056bfb7d", "test_files": { - "test_get_test_command.py": "cd0a9f2c46edc27c730c07f31c8f76b33b26045636369cd481b1809dc9badbcb" + "test_get_test_command.py": "a8eb44cdaea31b3dc5b24d98481ec9593268d725b1014e7e25b9b208056bfb7d" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 271c0d1869..0a2c14d592 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -23,19 +23,23 @@ def _is_dash_c_option(tok: str) -> bool: 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 command clause runs ``eval`` or a shell with ``-c`` - (``bash -c {file}``, ``bash -lc {file}``). To stay robust against option-bearing - command WRAPPERS whose operands make the effective-command position ambiguous - (``timeout 5 bash -c``, ``env -i bash -c``, ``nice -n 5 bash -c``), a clause is - refused CONSERVATIVELY when it contains BOTH a shell name and a ``-c``-bearing option, - or an ``eval`` token — anywhere in the clause. A bare ``sh {file}`` / ``bash {file}`` - (run the *file* as a script — the shipped Shell/Bash/Zsh templates) has no ``-c`` and - 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``, which Bash does NOT - treat as a comment) cannot hide a later ``&& bash -c`` 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.""" + 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 @@ -50,10 +54,16 @@ def _feeds_value_into_reevaluation(template: str) -> bool: 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 + 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 "eval" in bases: - return True if any(b in _REEVAL_SHELLS for b in bases) \ and any(_is_dash_c_option(t) for t in toks): return True diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 5344ca6047..10bdbf62b2 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -410,6 +410,10 @@ def _segments_dp_match(rel: list, pat_parts: list, work: list, 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:]. @@ -420,7 +424,10 @@ def _segments_dp_match(rel: list, pat_parts: list, work: list, 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 pat_parts[j] == "**": + 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 ``**``. @@ -496,6 +503,9 @@ def _wildcard_units_match(name_u: list, pat_u: list, 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 @@ -1010,11 +1020,12 @@ def _package_matches_workspace(rel_parts: Tuple[str, ...], globs: list, 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/*`. Normalizing it - # would falsely prove membership. Skip only an exactly-empty entry. + # 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) - if not raw: - continue # 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: @@ -1066,11 +1077,12 @@ def _matches_any(pattern_list, dot: bool = False) -> bool: 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. A positive comment matches no path. + # 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 is_comment and not negated: - continue + for negated, _body, expanded, _is_comment in parsed: if _matches_any(expanded): member = not negated return member @@ -1405,35 +1417,79 @@ def _clause_invokes_vitest(tokens: list) -> bool: 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. The script is tokenized with a QUOTE- and - ESCAPE-aware shell lexer and split into command clauses on unquoted control operators - (``;`` ``&`` ``&&`` ``|`` ``||``), so ``echo x\\; vitest`` stays one clause (the - escaped ``;`` is a literal argument, not a boundary) and ``echo 'a; b' vitest`` is - not mis-split. Comment handling is word-boundary aware, matching Bash: a token that - BEGINS with ``#`` starts a comment to end of line (``echo hi # vitest`` does NOT - prove), while a mid-word ``#`` is literal (``echo a#b && npx vitest`` DOES prove). An - oversized script (a manifest padding attack) and a malformed (unbalanced-quote) script - both fail closed.""" + 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.""" if len(script) > _MAX_SCRIPT_LEN: return False try: - lex = shlex.shlex(script, posix=True, punctuation_chars=True) - lex.whitespace_split = True - lex.commenters = "" # handle '#' by word boundary below (mid-word '#' is literal) - clause: list = [] - for tok in lex: - if tok.startswith("#"): - break # a word-starting '#' comments the rest of the line - if tok in _CLAUSE_OPERATORS: - if _clause_invokes_vitest(clause): - return True - clause = [] - else: - clause.append(tok) - return _clause_invokes_vitest(clause) + 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 diff --git a/pdd/prompts/get_run_command_python.prompt b/pdd/prompts/get_run_command_python.prompt index 3876048d7d..a0328ca132 100644 --- a/pdd/prompts/get_run_command_python.prompt +++ b/pdd/prompts/get_run_command_python.prompt @@ -38,8 +38,13 @@ 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). It also rejects an empty - placeholder key and an escaped placeholder (`\{file}`). + 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`, or a + shell behind an option-bearing wrapper like `timeout 5 bash -c`); 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. diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 31cc80a021..d230aa7149 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -26,7 +26,7 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics whose *combination rule depends on the declaring source*, because the two ecosystems' matchers genuinely differ — BOTH honor re-inclusion of a path an earlier `!` exclusion removed, but by different algorithms, so the difference is only visible on the *sibling* of a re-included path. For **pnpm** (a `pnpm-workspace.yaml`, `@pnpm/matcher`) evaluation is **order-dependent, last matching pattern wins PER PATH**: a positive includes, a `!` exclusion (e.g. `!**/test/**`) excludes, and a *later* positive re-includes only the specific paths it matches. For **npm/yarn/lerna** (`@npmcli/map-workspaces`) apply npm's actual **`appendNegatedPatterns` preprocessing**: walking the patterns in order, a later POSITIVE pattern whose *pattern string* is matched by an earlier negation's glob (`minimatch(pattern, negatedPattern)`) REMOVES that negation wholesale; a path is then a member iff it matches a surviving positive and no surviving negation. So for `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` BOTH tools make `packages/legacy/app` a member, but the SIBLING `packages/legacy/other` is a member under **npm** (dropping `!packages/legacy/**` un-excludes the whole subtree) yet NOT under **pnpm** (its last matching pattern is still the exclusion). Likewise a later positive equal to an earlier exclusion (`["packages/*", "!packages/app", "packages/app"]`) re-includes `packages/app` under both. Choose the rule from the source that actually governs the ancestor — apply pnpm's per-path last-match-wins ONLY when the declaring file is a `pnpm-workspace.yaml`, npm's negation-preprocessing otherwise; do NOT model either as a simple terminal "any-exclude-wins" (that denies npm's and pnpm's legitimate re-inclusions) nor collapse the two (npm re-includes the sibling, pnpm does not). **Built-in exclusion (both tools):** a path *inside* any `node_modules/` directory is NEVER a member — npm appends `**/node_modules/**` to its ignore set and pnpm/yarn skip `node_modules` during discovery — so `["**"]` MUST NOT make `node_modules/dep` a member (a leaf directory literally *named* `node_modules` with nothing under it is not itself excluded). npm's leading normalization (below) is applied to every pattern, positive and negation, before these comparisons. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A glob whose effective form begins with `#` is a minimatch comment ONLY under DEFAULT minimatch — which is exactly the option set npm's `appendNegatedPatterns` PREPROCESSING uses: there a `#`-leading NEGATION matches nothing (so a positive can neither remove it nor be pruned by it — `["**","!#foo","#foo"]` keeps `#foo` excluded). But npm's FINAL `glob` step matches `#` LITERALLY (nocomment), so a positive `#foo` DOES match a package directory literally named `#foo` and a negation `#foo` excludes it. Do NOT treat a `#` positive as "matches nothing" in final path matching. The leading-prefix normalization MUST be applied consistently, the SAME way for comment classification as for matching, so `/#*` and `.//#*` are classified after normalization too. A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE, as the single regex `^\.?/+` — an optional leading `.` followed by the ENTIRE immediately-following run of `/`, stripped in one pass (NOT a `/` run and a `./` removed independently). So `./packages/*`, `/packages/*`, `//packages/*`, `///packages/*`, and — because the dot plus its whole trailing slash run go together — `.//packages/*` and `.///packages/*` all normalize to `packages/*` and DO match `packages/app`. Use the identical normalized value for matching and `#`-comment classification. A prefix left over AFTER that one pass is significant: the leading `.` is consumed only when a slash run immediately follows it, so `././packages/*` strips just its first `./` and keeps a second literal `.` segment, and `/./packages/*` normalizes to `./packages/*` (a literal `.` segment) — NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse `/./packages/*`). Apply minimatch dot semantics per role. POSITIVE membership (and the pattern-vs-pattern preprocessing) use `dot:false` — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so a positive `packages/*` does not match `packages/.shadow`, but `packages/.*` does). npm applies its SURVIVING NEGATIONS as `glob`'s `dot:true` IGNORE set, however, so a wildcard NEGATION DOES exclude a leading-dot directory: under npm `["packages/.*", "!packages/*"]` EXCLUDES `packages/.shadow` even though the positive `packages/.*` matched it. Use `dot:true` for final negation matching, `dot:false` for positives and preprocessing. +R6 (MUST): Determine **workspace member** status by matching the package's relative path against declared globs with faithful include/exclude semantics whose *combination rule depends on the declaring source*, because the two ecosystems' matchers genuinely differ — BOTH honor re-inclusion of a path an earlier `!` exclusion removed, but by different algorithms, so the difference is only visible on the *sibling* of a re-included path. For **pnpm** (a `pnpm-workspace.yaml`, `@pnpm/matcher`) evaluation is **order-dependent, last matching pattern wins PER PATH**: a positive includes, a `!` exclusion (e.g. `!**/test/**`) excludes, and a *later* positive re-includes only the specific paths it matches. For **npm/yarn/lerna** (`@npmcli/map-workspaces`) apply npm's actual **`appendNegatedPatterns` preprocessing**: walking the patterns in order, a later POSITIVE pattern whose *pattern string* is matched by an earlier negation's glob (`minimatch(pattern, negatedPattern)`) REMOVES that negation wholesale; a path is then a member iff it matches a surviving positive and no surviving negation. So for `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` BOTH tools make `packages/legacy/app` a member, but the SIBLING `packages/legacy/other` is a member under **npm** (dropping `!packages/legacy/**` un-excludes the whole subtree) yet NOT under **pnpm** (its last matching pattern is still the exclusion). Likewise a later positive equal to an earlier exclusion (`["packages/*", "!packages/app", "packages/app"]`) re-includes `packages/app` under both. Choose the rule from the source that actually governs the ancestor — apply pnpm's per-path last-match-wins ONLY when the declaring file is a `pnpm-workspace.yaml`, npm's negation-preprocessing otherwise; do NOT model either as a simple terminal "any-exclude-wins" (that denies npm's and pnpm's legitimate re-inclusions) nor collapse the two (npm re-includes the sibling, pnpm does not). **Built-in exclusion (both tools):** a path *inside* any `node_modules/` directory is NEVER a member — npm appends `**/node_modules/**` to its ignore set and pnpm/yarn skip `node_modules` during discovery — so `["**"]` MUST NOT make `node_modules/dep` a member (a leaf directory literally *named* `node_modules` with nothing under it is not itself excluded). npm's leading normalization (below) is applied to every pattern, positive and negation, before these comparisons. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A glob whose effective form begins with `#` is a minimatch comment ONLY under DEFAULT minimatch — which is exactly the option set npm's `appendNegatedPatterns` PREPROCESSING uses: there a `#`-leading NEGATION matches nothing (so a positive can neither remove it nor be pruned by it — `["**","!#foo","#foo"]` keeps `#foo` excluded). But npm's FINAL `glob` step matches `#` LITERALLY (nocomment), so a positive `#foo` DOES match a package directory literally named `#foo` and a negation `#foo` excludes it. Do NOT treat a `#` positive as "matches nothing" in final path matching. pnpm (`@pnpm/matcher`) likewise treats `#` LITERALLY — only a leading `!` is special — so the comment rule applies ONLY to npm's preprocessing, not to ordered pnpm matching (`['#app']` matches a dir `#app`; `['!#app','#app']` re-includes it). The leading-prefix normalization MUST be applied consistently, the SAME way for classification as for matching. Also: an EMPTY positive pattern-string is PRESERVED through npm preprocessing (a `""` entry can still match and remove a prior negation — `["packages/**","!**",""]` re-includes `packages/**`) even though it matches no non-empty leaf; do not drop it. A `.` or `..` path SEGMENT (which arises when a raw pattern string such as `packages/./x` is matched as a path during preprocessing) is special in minimatch: it is matched ONLY by an identical literal `.`/`..` segment, never by a wildcard or `**`. A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE, as the single regex `^\.?/+` — an optional leading `.` followed by the ENTIRE immediately-following run of `/`, stripped in one pass (NOT a `/` run and a `./` removed independently). So `./packages/*`, `/packages/*`, `//packages/*`, `///packages/*`, and — because the dot plus its whole trailing slash run go together — `.//packages/*` and `.///packages/*` all normalize to `packages/*` and DO match `packages/app`. Use the identical normalized value for matching and `#`-comment classification. A prefix left over AFTER that one pass is significant: the leading `.` is consumed only when a slash run immediately follows it, so `././packages/*` strips just its first `./` and keeps a second literal `.` segment, and `/./packages/*` normalizes to `./packages/*` (a literal `.` segment) — NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse `/./packages/*`). Apply minimatch dot semantics per role. POSITIVE membership (and the pattern-vs-pattern preprocessing) use `dot:false` — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so a positive `packages/*` does not match `packages/.shadow`, but `packages/.*` does). npm applies its SURVIVING NEGATIONS as `glob`'s `dot:true` IGNORE set, however, so a wildcard NEGATION DOES exclude a leading-dot directory: under npm `["packages/.*", "!packages/*"]` EXCLUDES `packages/.shadow` even though the positive `packages/.*` matched it. Use `dot:true` for final negation matching, `dot:false` for positives and preprocessing. 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. diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index 06ee3726fa..7332e099e5 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -306,7 +306,10 @@ def test_reevaluation_contexts_are_refused(self): # 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}"): + "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}"): 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. @@ -318,9 +321,13 @@ def test_reevaluation_contexts_are_refused(self): # 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" # 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")): + ("env bash -c {V}", "PWN_ENV"), + ('printf "%s" {V} | bash', "PWN_PIPE")): 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) diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 8b466f52cb..5f8325a613 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1308,6 +1308,36 @@ def test_npm_final_negation_uses_dot_true_semantics(self): 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 @@ -1319,6 +1349,25 @@ def test_npm_pruning_collapses_empty_and_trailing_slash_segments(self): ("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 @@ -1436,14 +1485,18 @@ def test_length_guard_precedes_syntax_scan(self): # 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_comment_is_normalized_before_classification(self): - """A leading ``/`` (or ``//`` / ``.//``) is normalized the SAME way for - comment classification as for matching, so ``/#*`` is recognized as a - minimatch comment (matches nothing) instead of being matched literally into a - false member. A leading ``/`` on a real glob is still just normalized away.""" + 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 - assert _package_matches_workspace(("#evil",), ["//#*"]) is False - assert _package_matches_workspace(("#evil",), [".//#*"]) 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): @@ -1596,15 +1649,22 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): (repo / "package.json").write_text( '{"devDependencies": {"vite": "^5", "vitest": %s}}' % bad) assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None, bad - # A mid-word '#' is literal (Bash), so a later real vitest clause still proves; - # a leading-'#' comment clause does not. - (repo / "package.json").write_text( - json.dumps({"scripts": {"test": "echo a#b && npx vitest"}})) - cmd4, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") - assert cmd4 is not None and "npx vitest run" in cmd4, cmd4 - (repo / "package.json").write_text( - json.dumps({"scripts": {"test": "echo hi # npx vitest"}})) - assert _detect_ts_test_runner(repo / "src" / "a.test.ts") is None + # '#' 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 script invoking the vitest BINARY proves it — directly, via a direct package # runner (npx/bunx), via an explicit exec subcommand (pnpm exec / bun x), or with # a `--` options terminator (npm exec -- vitest). From 2e24cf6eed1234ccd8335b9a4d26a98c96a95bac Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 08:24:10 -0700 Subject: [PATCH 48/56] =?UTF-8?q?fix(get=5Frun=5Fcommand,get=5Ftest=5Fcomm?= =?UTF-8?q?and):=20harvested=20R38=20defects=20=E2=80=94=20env=20-S=20hidd?= =?UTF-8?q?en=20shell,=20heredoc-body=20vitest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R38 exited on a DNS/transport error (invalid, not counted); harvested exact-head probes established two legitimate defects, now fixed: - HIGH: shell_safe_substitute accepted `env -S 'bash -c' {file}` — `env -S` (--split-string) re-parses its string argument into a command line, hiding a shell + `-c` beyond the top-level token scan, so bash second-parses the substituted path and a `$(...)` executes. Now refuse `env -S`/`--split-string`, and re-tokenize each quoted token to catch a shell+`-c` (or eval) hidden inside a wrapper's re-parsed argument. `env` without `-S` stays safe. Real bash injection regressions. - MEDIUM: _script_invokes_vitest treated here-document / here-string BODY text as an executed command, so `cat < --- .pdd/meta/get_test_command_python.json | 10 +++++----- pdd/get_run_command.py | 22 ++++++++++++++++++++++ pdd/get_test_command.py | 6 +++++- pdd/prompts/get_run_command_python.prompt | 5 +++-- pdd/prompts/get_test_command_python.prompt | 2 +- tests/test_get_run_command.py | 13 +++++++++++-- tests/test_get_test_command.py | 7 +++++++ 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 837ffd6eb4..9204383a43 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T13:05:00.000000+00:00", + "timestamp": "2026-07-13T14:45:00.000000+00:00", "command": "test", - "prompt_hash": "12c03ff6bab9564cf6b17844c0ad87b5c75d07f50e3b40903c41422118f36ce2", - "code_hash": "2ff07e2c35845368cd7f0044ac35bdacf3d7084271fcdd9a261b5ad6b918711c", + "prompt_hash": "d370736d0861cb161ffcdeb6ca57f19825a264b94342dde230b5e0ecfb97375f", + "code_hash": "9d7131d2fad2c2e5ccdbecf7fb111a5b4b48b1806d85327d2f1c84cc3f4517ba", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "a8eb44cdaea31b3dc5b24d98481ec9593268d725b1014e7e25b9b208056bfb7d", + "test_hash": "9ff6c959d3fd398013a8606a48f48b0493d8c77e01654bf9bed8d1d2c79ef179", "test_files": { - "test_get_test_command.py": "a8eb44cdaea31b3dc5b24d98481ec9593268d725b1014e7e25b9b208056bfb7d" + "test_get_test_command.py": "9ff6c959d3fd398013a8606a48f48b0493d8c77e01654bf9bed8d1d2c79ef179" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/pdd/get_run_command.py b/pdd/get_run_command.py index 0a2c14d592..bdd1f51306 100644 --- a/pdd/get_run_command.py +++ b/pdd/get_run_command.py @@ -57,6 +57,15 @@ def _feeds_value_into_reevaluation(template: str) -> bool: 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. @@ -67,6 +76,19 @@ def _feeds_value_into_reevaluation(template: str) -> bool: 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 diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 10bdbf62b2..90488f6ba1 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -1471,9 +1471,13 @@ def _script_invokes_vitest(script: str) -> bool: 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.""" + 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) diff --git a/pdd/prompts/get_run_command_python.prompt b/pdd/prompts/get_run_command_python.prompt index a0328ca132..75c8b6cded 100644 --- a/pdd/prompts/get_run_command_python.prompt +++ b/pdd/prompts/get_run_command_python.prompt @@ -39,8 +39,9 @@ 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`, or a - shell behind an option-bearing wrapper like `timeout 5 bash -c`); or 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 diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index d230aa7149..f9acbd2923 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -7,7 +7,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul - `_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. # Vocabulary -- **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, or a script that invokes `vitest`) — 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. +- **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, judged with Bash-accurate quoting/comment/here-document semantics: a `vitest` token that is only an argument, a package-script name, 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. diff --git a/tests/test_get_run_command.py b/tests/test_get_run_command.py index 7332e099e5..ba04b068a1 100644 --- a/tests/test_get_run_command.py +++ b/tests/test_get_run_command.py @@ -309,7 +309,12 @@ def test_reevaluation_contexts_are_refused(self): "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}"): + "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. @@ -324,10 +329,14 @@ def test_reevaluation_contexts_are_refused(self): # 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")): + ('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) diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 5f8325a613..be169015eb 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1665,6 +1665,13 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): (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 < Date: Mon, 13 Jul 2026 16:44:21 -0700 Subject: [PATCH 49/56] fix(prompts): align runner shell contract governance --- .pdd/meta/get_test_command_python.json | 4 +- .pdd/verification-profile-rotations.json | 45 ++++++++++++++++++++++ .pdd/verification-profiles.json | 20 +++++----- pdd/prompts/agentic_fix_python.prompt | 9 ++++- pdd/prompts/get_run_command_python.prompt | 3 ++ pdd/prompts/get_test_command_python.prompt | 2 +- 6 files changed, 68 insertions(+), 15 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 9204383a43..d2f3ec4ffb 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,8 +1,8 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T14:45:00.000000+00:00", + "timestamp": "2026-07-13T23:31:07.000000+00:00", "command": "test", - "prompt_hash": "d370736d0861cb161ffcdeb6ca57f19825a264b94342dde230b5e0ecfb97375f", + "prompt_hash": "1e0d87648481ccf7b03dab0a7b741eb477fd7c3a407bb900b087b85ed795cb0f", "code_hash": "9d7131d2fad2c2e5ccdbecf7fb111a5b4b48b1806d85327d2f1c84cc3f4517ba", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", "test_hash": "9ff6c959d3fd398013a8606a48f48b0493d8c77e01654bf9bed8d1d2c79ef179", diff --git a/.pdd/verification-profile-rotations.json b/.pdd/verification-profile-rotations.json index 2eb7e2f5dd..20a4bc1e64 100644 --- a/.pdd/verification-profile-rotations.json +++ b/.pdd/verification-profile-rotations.json @@ -17,6 +17,51 @@ "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" } ] } diff --git a/.pdd/verification-profiles.json b/.pdd/verification-profiles.json index 0720f5bd37..547f0af5ff 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:78e4d685c29cac5f8b6155c53495e2bfefb6ac066850d53c7ec865a61b752457" ], "obligations": [ { @@ -1884,7 +1884,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:04356924ac0c4dc03a76aedb1bc165d72d4bb85a040da93d5d1251ad215dbb11" + "CONTRACT-SHA256:78e4d685c29cac5f8b6155c53495e2bfefb6ac066850d53c7ec865a61b752457" ], "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:1ada3c0ba9faaa60875f4f311d8d120a6804b2bbca9a2591f6d4439712fb3b3c" ], "obligations": [ { @@ -1928,7 +1928,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:84ff51a86adeffa37ba3a860315933037e7edf9029303166a5fe3a76caaca252" + "CONTRACT-SHA256:1ada3c0ba9faaa60875f4f311d8d120a6804b2bbca9a2591f6d4439712fb3b3c" ], "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:d3df2e3b68eb9b322582ebaec7788a18aec53bf34a57e308c86b1e946fa73830" ], "obligations": [ { @@ -5786,7 +5786,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:afffd825b4495819b853fec9a86b0be7644f6fe0468d40548d8b9b2803d183ce" + "CONTRACT-SHA256:d3df2e3b68eb9b322582ebaec7788a18aec53bf34a57e308c86b1e946fa73830" ], "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:883fd53ab20b3c6d2daeb55390c37ea9e31437d5056c58565f5c25ccd51db274" ], "obligations": [ { @@ -7590,7 +7590,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:3f79d0e73ad298c71002614e953cd9b4749a72cbf28842c5dc90f988742b6ce9" + "CONTRACT-SHA256:883fd53ab20b3c6d2daeb55390c37ea9e31437d5056c58565f5c25ccd51db274" ], "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:27e54b54a8f191560d725c84836f043244769d452484bbfbc4e837c53dcee5d2" ], "obligations": [ { @@ -7612,7 +7612,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:ef559f5558fb627aa53f078cba0eaae221a7af9a2c6bdadf580a4cb12bf217b7" + "CONTRACT-SHA256:27e54b54a8f191560d725c84836f043244769d452484bbfbc4e837c53dcee5d2" ], "artifact_paths": [ "pdd/prompts/get_test_command_python.prompt" diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index b32bb4554c..745f583665 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -8,7 +8,12 @@ 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. Verify commands run under `bash -lc` / `shell=True`, so command provenance MUST be tracked explicitly and handled safely: a *template* command (an explicit `verify_cmd` argument or `PDD_AGENTIC_VERIFY_CMD`) has its `{test}`/`{cwd}` placeholders substituted with **shell-quoted** values (`shlex.quote`) via a **shell-lexical-context-aware single pass** (`shell_safe_substitute`). `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 (`{test}x`, `./{test}`, `{test}.log` all substitute correctly). Safety MUST be decided from the template's actual shell-lexical context, NOT from mere whitespace bounds: the substitution is unsafe — and MUST be refused so the verification gate fails closed — ONLY where the inserted quoting could be reinterpreted: the placeholder inside the template's own single/double quotes or backticks (`pytest "{test}"`, even a space-surrounded `echo " {test} "` sitting *inside* enclosing quotes), immediately preceded by `$` (`${test}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body (`shlex.quote` single-quoting does not stop a `$(...)` in a heredoc body, and a value newline breaks out of a comment). Any multiline template (a newline — the only way to form a heredoc body) is refused outright, as is any template containing `$`, a backtick, or `(`/`)`, or a brace-expansion / pathname-expansion metacharacter (`{`, `}`, `*`, `?`, `[`, `]`, `~`) OUTSIDE a placeholder token (an unquoted `{a,b}` or glob would change the command's word count, and a value's `,` is left unquoted by `shlex.quote`). An empty placeholder key and an escaped placeholder (`\{test}`) are refused too. These are the OBSERVABLE MUST/MUST NOT outcomes; HOW the helper decides them (its tokenizer, scan, or masking strategy) is implementation detail to be pinned by tests, not mandated here. Substitution MUST be single-pass: an inserted value that itself contains a literal `{test}`/`{cwd}` MUST NOT be rescanned as another placeholder (a sequential `str.replace` chain would re-expand it). Resolve BOTH `{test}` and `{cwd}` to ABSOLUTE paths anchored at the supplied `cwd` (not the process CWD): a relative `{test}` targets the same file `run_agentic_fix` resolved, and an absolute `{cwd}` prevents a template like `cd {cwd} && pytest {test}` from double-joining a relative `cwd` onto the process directory. A *finalized* command (e.g. from `default_verify_cmd_for`) is executed as-is and MUST NOT have placeholders re-substituted — re-substituting would splice into its existing quoting and inject when the resolved path contains a literal `{test}`/`{cwd}` plus shell metacharacters. Do NOT infer template-vs-finalized from ambient environment state. + - **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. @@ -57,4 +62,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/get_run_command_python.prompt b/pdd/prompts/get_run_command_python.prompt index 75c8b6cded..66c9c83a05 100644 --- a/pdd/prompts/get_run_command_python.prompt +++ b/pdd/prompts/get_run_command_python.prompt @@ -22,6 +22,9 @@ - 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). diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index f9acbd2923..9cab5a54b8 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -40,7 +40,7 @@ R14 (MUST NOT): Re-run placeholder (`{file}`/`{test}`) substitution on a command 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. (This is a POSIX-shell command string, matching how all pdd callers run verify commands.) +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): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder via a shell-lexical-context-aware single pass (`shell_safe_substitute`) and return `cwd=None`. Callers execute with `shell=True`. `shlex.quote` yields a SELF-CONTAINED shell word, which is safe to splice in AND safe to concatenate with ordinary literal characters on either side — so a suffix or prefix template like `gfortran -o {file}.out {file}` or `fpc {file} && ./{file}` (real CSV rows) MUST still substitute and return a command, NOT be rejected as "adjacent"; rejecting them silently disables crash verification for those languages. The substitution MUST return None (→ fall through to the next resolution step) whenever non-placeholder template syntax could make `bash` expand the command or change its word count: the placeholder inside the template's own single/double quotes or backticks (`mocha "{file}"` — the inserted quotes become literal and a `$(...)` in the path still executes), immediately preceded by `$` (`${file}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body; any multiline template (a newline, the only way to form a heredoc body); any template containing `$`, a backtick, or `(`/`)`; and any 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). An empty placeholder key and an escaped placeholder (`\{file}`) are refused too. The substitution MUST be single-pass — an inserted value that itself contains a literal `{file}` MUST NOT be rescanned as another placeholder. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. From 022ef1cb67873089e20e61eb6334c119ce199991 Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 17:34:12 -0700 Subject: [PATCH 50/56] docs(pdd): make runner contract coverage explicit --- .pdd/meta/get_test_command_python.json | 8 +-- .pdd/verification-profile-rotations.json | 18 ++++++ .pdd/verification-profiles.json | 8 +-- pdd/prompts/agentic_fix_python.prompt | 6 +- pdd/prompts/get_test_command_python.prompt | 35 +++++++++--- .../failed_receipt_python.prompt | 4 +- .../fake_tests/test_receipt_failed.py | 2 +- tests/test_coverage_contracts.py | 2 +- tests/test_get_test_command.py | 3 +- .../story__pdd_nested_ts_runner_resolution.md | 56 +++++++++++++++++++ 10 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 user_stories/story__pdd_nested_ts_runner_resolution.md diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index d2f3ec4ffb..94ff5f7a19 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-13T23:31:07.000000+00:00", + "timestamp": "2026-07-14T00:27:01.000000+00:00", "command": "test", - "prompt_hash": "1e0d87648481ccf7b03dab0a7b741eb477fd7c3a407bb900b087b85ed795cb0f", + "prompt_hash": "858ca20048acc4ef7013c220aeac8e8b2db0102056f477945f3cb06b8d7a8a88", "code_hash": "9d7131d2fad2c2e5ccdbecf7fb111a5b4b48b1806d85327d2f1c84cc3f4517ba", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "9ff6c959d3fd398013a8606a48f48b0493d8c77e01654bf9bed8d1d2c79ef179", + "test_hash": "bada38a33f0dd9c75058ab1ffa1719d7c6dfb972b7d5ef5b8a79d177722bc9d7", "test_files": { - "test_get_test_command.py": "9ff6c959d3fd398013a8606a48f48b0493d8c77e01654bf9bed8d1d2c79ef179" + "test_get_test_command.py": "bada38a33f0dd9c75058ab1ffa1719d7c6dfb972b7d5ef5b8a79d177722bc9d7" }, "include_deps": { "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", diff --git a/.pdd/verification-profile-rotations.json b/.pdd/verification-profile-rotations.json index 20a4bc1e64..797a534fb2 100644 --- a/.pdd/verification-profile-rotations.json +++ b/.pdd/verification-profile-rotations.json @@ -62,6 +62,24 @@ "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" } ] } diff --git a/.pdd/verification-profiles.json b/.pdd/verification-profiles.json index 547f0af5ff..e9bebeeb17 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:78e4d685c29cac5f8b6155c53495e2bfefb6ac066850d53c7ec865a61b752457" + "CONTRACT-SHA256:2258b83cdd6a0ca474b418e51a6c4dc9b7557b706dc88defaf69e3237df9fa84" ], "obligations": [ { @@ -1884,7 +1884,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:78e4d685c29cac5f8b6155c53495e2bfefb6ac066850d53c7ec865a61b752457" + "CONTRACT-SHA256:2258b83cdd6a0ca474b418e51a6c4dc9b7557b706dc88defaf69e3237df9fa84" ], "artifact_paths": [ "pdd/prompts/agentic_fix_python.prompt" @@ -7603,7 +7603,7 @@ "prompt_path": "pdd/prompts/get_test_command_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:27e54b54a8f191560d725c84836f043244769d452484bbfbc4e837c53dcee5d2" + "CONTRACT-SHA256:858ca20048acc4ef7013c220aeac8e8b2db0102056f477945f3cb06b8d7a8a88" ], "obligations": [ { @@ -7612,7 +7612,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:27e54b54a8f191560d725c84836f043244769d452484bbfbc4e837c53dcee5d2" + "CONTRACT-SHA256:858ca20048acc4ef7013c220aeac8e8b2db0102056f477945f3cb06b8d7a8a88" ], "artifact_paths": [ "pdd/prompts/get_test_command_python.prompt" diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index 745f583665..62c1787f22 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**: @@ -17,7 +21,7 @@ You are an expert Python developer responsible for implementing the `agentic_fix - **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. diff --git a/pdd/prompts/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 9cab5a54b8..81884cca5a 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -6,14 +6,15 @@ You are an expert software engineer implementing the `get_test_command.py` modul - `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. -# Vocabulary + - **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, judged with Bash-accurate quoting/comment/here-document semantics: a `vitest` token that is only an argument, a package-script name, 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. + -# Contract + 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. @@ -26,23 +27,41 @@ R4 (MUST): Stop the upward search at the nearest `package.json` (the JS project 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 member** status by matching the package's relative path against declared globs with faithful include/exclude semantics whose *combination rule depends on the declaring source*, because the two ecosystems' matchers genuinely differ — BOTH honor re-inclusion of a path an earlier `!` exclusion removed, but by different algorithms, so the difference is only visible on the *sibling* of a re-included path. For **pnpm** (a `pnpm-workspace.yaml`, `@pnpm/matcher`) evaluation is **order-dependent, last matching pattern wins PER PATH**: a positive includes, a `!` exclusion (e.g. `!**/test/**`) excludes, and a *later* positive re-includes only the specific paths it matches. For **npm/yarn/lerna** (`@npmcli/map-workspaces`) apply npm's actual **`appendNegatedPatterns` preprocessing**: walking the patterns in order, a later POSITIVE pattern whose *pattern string* is matched by an earlier negation's glob (`minimatch(pattern, negatedPattern)`) REMOVES that negation wholesale; a path is then a member iff it matches a surviving positive and no surviving negation. So for `["packages/**", "!packages/legacy/**", "packages/legacy/app"]` BOTH tools make `packages/legacy/app` a member, but the SIBLING `packages/legacy/other` is a member under **npm** (dropping `!packages/legacy/**` un-excludes the whole subtree) yet NOT under **pnpm** (its last matching pattern is still the exclusion). Likewise a later positive equal to an earlier exclusion (`["packages/*", "!packages/app", "packages/app"]`) re-includes `packages/app` under both. Choose the rule from the source that actually governs the ancestor — apply pnpm's per-path last-match-wins ONLY when the declaring file is a `pnpm-workspace.yaml`, npm's negation-preprocessing otherwise; do NOT model either as a simple terminal "any-exclude-wins" (that denies npm's and pnpm's legitimate re-inclusions) nor collapse the two (npm re-includes the sibling, pnpm does not). **Built-in exclusion (both tools):** a path *inside* any `node_modules/` directory is NEVER a member — npm appends `**/node_modules/**` to its ignore set and pnpm/yarn skip `node_modules` during discovery — so `["**"]` MUST NOT make `node_modules/dep` a member (a leaf directory literally *named* `node_modules` with nothing under it is not itself excluded). npm's leading normalization (below) is applied to every pattern, positive and negation, before these comparisons. Expand `{a,b}` brace alternations before matching. Brace expansion MUST find a real (two-or-more-option) alternation wherever it appears in a pattern — including *after* a single-option group (`{foo}/{a,b}` expands its `{a,b}`) and *nested inside* one (`{a{b,c}}` expands its inner `{b,c}`). A single-option or unbalanced brace is literal but MUST NOT short-circuit expansion of the rest of the pattern; only a pattern with no real alternation anywhere is emitted whole. This applies to an unbalanced `${` too: it is a literal `${` (not an opaque group), so a later balanced `{a,b}` still expands (`!packages/${foo/{a,b}` must exclude `packages/${foo/a`). Backslash escaping of brace metacharacters (e.g. `{foo\,bar,baz}`, where `\,` is a literal comma and thus two options, not three) MUST be honored — or, if escape-awareness is not implemented, any glob containing a backslash MUST fail membership closed (never over-expand a positive glob into a spurious member, nor misparse an escaped exclusion into a false member). Likewise, any minimatch construct the matcher does not implement with faithful parity MUST fail membership closed rather than be approximated: in particular *closed, non-empty* bracket character classes (`[abc]`, `[a-z]`, and especially minimatch's `[^…]` negation and POSIX `[[:alpha:]]` classes, which the matcher does not implement), *complete* extglob groups (`?(…)`, `*(…)`, `+(…)`, `@(…)`, `!(…)` WITH a matching `)`, which minimatch expands but a literal matcher would not — an *incomplete* marker like `foo?(bar` with no `)` is just the `?` wildcard plus a literal `(` and MUST NOT be rejected), brace ranges — a `..` *inside a comma-less brace group* whose endpoints follow minimatch range grammar (both endpoints ASCII integers with an optional leading `-` (a leading `+` is NOT a range), or both single ASCII letters, plus an optional ASCII-integer step: `{1..3}`, `{a..c}`, `{01..03}`, `{1..9..2}`, `{-2..2}`; but NOT multi-character `{foo..bar}`, non-integer `{1.0..3.0}`, plus-prefixed `{+1..+3}`, Unicode-digit/letter, or empty `{..}` forms, which are literal), and two-or-more leading `!` (which toggle negation, `!!x` positive / `!!!x` negated) MUST NOT silently over-match a positive or under-match an exclusion (e.g. `!packages/@(foo|bar)` must not fail to exclude `packages/foo`; `!packages/{1..3}` must exclude `packages/1`). A glob whose effective form begins with `#` is a minimatch comment ONLY under DEFAULT minimatch — which is exactly the option set npm's `appendNegatedPatterns` PREPROCESSING uses: there a `#`-leading NEGATION matches nothing (so a positive can neither remove it nor be pruned by it — `["**","!#foo","#foo"]` keeps `#foo` excluded). But npm's FINAL `glob` step matches `#` LITERALLY (nocomment), so a positive `#foo` DOES match a package directory literally named `#foo` and a negation `#foo` excludes it. Do NOT treat a `#` positive as "matches nothing" in final path matching. pnpm (`@pnpm/matcher`) likewise treats `#` LITERALLY — only a leading `!` is special — so the comment rule applies ONLY to npm's preprocessing, not to ordered pnpm matching (`['#app']` matches a dir `#app`; `['!#app','#app']` re-includes it). The leading-prefix normalization MUST be applied consistently, the SAME way for classification as for matching. Also: an EMPTY positive pattern-string is PRESERVED through npm preprocessing (a `""` entry can still match and remove a prior negation — `["packages/**","!**",""]` re-includes `packages/**`) even though it matches no non-empty leaf; do not drop it. A `.` or `..` path SEGMENT (which arises when a raw pattern string such as `packages/./x` is matched as a path during preprocessing) is special in minimatch: it is matched ONLY by an identical literal `.`/`..` segment, never by a wildcard or `**`. A balanced `${…}` (a `{` immediately preceded by `$`) is opaque in minimatch's brace-expansion — its entire body, nested braces included, is literal and MUST NOT be expanded (so `packages/${foo,bar}` does not falsely prove `packages/$foo`, and `packages/${foo,{bar,baz}}` does not expand its inner `{bar,baz}`); a `..` inside such a group is likewise literal (`packages/${1..3}` is a literal dir name, not a range). This opacity MUST be determined from the ORIGINAL raw pattern before expansion (e.g. by masking `${…}` spans out first), not inferred from `$`+`{` adjacency in a partially-expanded worklist string — otherwise expanding `{$,x}{a,b}` generates a spurious `${a,b}` whose `{a,b}` is NOT a shell group and MUST still expand to `$a`/`$b` (so `!packages/{$,x}{a,b}` excludes `packages/$a`). Because brace expansion can *create* an unsupported construct out of separate alternatives (`{?,x}(foo)` expands to the extglob `?(foo)`) or *dissolve* an apparent one (`{[,x}]` expands to the supported literals `[]` and `x]`), the closed-bracket-class and extglob rejections MUST be evaluated on each fully-expanded concrete pattern, not on the raw glob; and because a bracket class or extglob cannot cross `/` (each is confined to one path segment), they MUST be checked per `/`-delimited segment, so a `[` in one segment and `]` in another (`foo[/bar]`, literal in minimatch) is NOT misread as a class. A cheap length bound MUST be enforced before any O(len) syntax scan, and that scan MUST be linear (no quadratic rescanning of a hostile unmatched-bracket wall). The single-segment matcher MUST implement the supported language directly — literal characters plus `*` (any run within the segment) and `?` (exactly one UTF-16 code unit, so `?` matches one unit and an astral character needs `??`, exact minimatch parity) — and MUST NOT delegate to Python `fnmatch`, whose `[…]`/`[^…]` reinterpretation and OS case-folding diverge from minimatch on the literal bracket forms this matcher intentionally permits (so a dir named `[^]` matches the glob `[^]`, not the character `^`). Conversely, fail-closed rejection MUST be precise, not over-broad — these are literal in minimatch and a dir so named MUST stay matchable: an *unmatched* `[` (no closing `]`) or an *empty* class `[]`/`[!]`/`[^]` (but NOT `[]]`/`[^]]`/`[!]]`, where a `]` in first-member position makes it a real, non-empty class that MUST fail closed); a literal `..` *outside* any brace, inside a brace group that also has a top-level comma (`{foo..bar,baz}`), or inside a comma-less brace whose endpoints are not valid range grammar (`{foo..bar}`, `{1.0..3.0}`, `{..}`) — only a *real* comma-less range fails closed. Because `?` matches over UTF-16 code units, astral characters need no special-case rejection at all: `packages/*/a??` matches `packages/😀/app` and include/exclude semantics are order-independent. Note a bracket, paren, or dot appearing in a *path segment being matched* (e.g. a Next.js dynamic-route dir `[eventId]`) is literal data, not a glob metacharacter, and MUST still match an ordinary `*`/`**` glob. Match segment-wise: `*` matches exactly one path segment, `**` matches any depth. Do not collapse an *internal* `.` path segment in a glob (`packages/./x` is not `packages/x` and must not falsely match `packages/x`). Apply npm's leading normalization exactly ONCE, as the single regex `^\.?/+` — an optional leading `.` followed by the ENTIRE immediately-following run of `/`, stripped in one pass (NOT a `/` run and a `./` removed independently). So `./packages/*`, `/packages/*`, `//packages/*`, `///packages/*`, and — because the dot plus its whole trailing slash run go together — `.//packages/*` and `.///packages/*` all normalize to `packages/*` and DO match `packages/app`. Use the identical normalized value for matching and `#`-comment classification. A prefix left over AFTER that one pass is significant: the leading `.` is consumed only when a slash run immediately follows it, so `././packages/*` strips just its first `./` and keeps a second literal `.` segment, and `/./packages/*` normalizes to `./packages/*` (a literal `.` segment) — NEITHER matches `packages/app` (independently stripping `/` and then `./` would wrongly collapse `/./packages/*`). Apply minimatch dot semantics per role. POSITIVE membership (and the pattern-vs-pattern preprocessing) use `dot:false` — a wildcard segment MUST NOT match a path segment beginning with `.` unless the pattern segment also begins with `.` (so a positive `packages/*` does not match `packages/.shadow`, but `packages/.*` does). npm applies its SURVIVING NEGATIONS as `glob`'s `dot:true` IGNORE set, however, so a wildcard NEGATION DOES exclude a leading-dot directory: under npm `["packages/.*", "!packages/*"]` EXCLUDES `packages/.shadow` even though the positive `packages/.*` matched it. Use `dot:true` for final negation matching, `dot:false` for positives and preprocessing. +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** on every malformed or unsafe declaration, for every source (`package.json`, `lerna.json`, `pnpm-workspace.yaml`): a parsed top level that is not an object; a package list containing any non-string entry (a JSON/YAML `true`/number MUST NOT be coerced into a glob such as `"True"`); a missing parser, parse error, invalid encoding, pathological nesting (deep JSON/YAML MUST NOT raise `RecursionError`), or an oversized file (bounded before it is fully read/parsed). For JSON (`package.json`, `lerna.json`), the non-standard constants `NaN`/`Infinity`/`-Infinity` — which Python's `json` accepts but strict JSON (npm/Node's `JSON.parse`) rejects — MUST fail the whole-document parse closed even when they sit outside the workspace field (use `parse_constant`), matching npm's rejection. A parse failure MUST NOT fall through to a tool's default glob (e.g. lerna's `packages/*`); only a successfully-parsed declaration that omits the key gets its documented default. `pnpm-workspace.yaml` MUST be parsed with pnpm's YAML 1.2 **core schema**, not PyYAML's default YAML 1.1 — and the 1.1 implicit-resolver table MUST be replaced wholesale, not merely patched, because the discrepancy runs both directions. Forms YAML 1.2 resolves as non-strings (`true`/`false`, `null`/`~`/**the empty scalar** — an empty list item `- ` is null, MUST reject the declaration, `[-+]?[0-9]+`/`0o…`/`0x…` ints, and exponent/`.inf`/`.nan` floats) are non-string entries that MUST be rejected (so unquoted `0o12`/`1e3` reject the entry as pnpm does). Conversely, forms YAML 1.1 resolves as non-strings but YAML 1.2 keeps as STRINGS — `yes`/`no`/`on`/`off`, `0b10` binary, `1:20` sexagesimal, `2020-01-01` timestamps, `1_000` underscore ints — are valid string globs that MUST NOT reject the declaration (a quoted `"0o12"`, a string in both, likewise stays a valid glob). Integers MUST also be *constructed* per YAML 1.2 — an ordinary digit run is base-10 (`012` is 12, not octal 10) — so that duplicate mapping keys such as `012:` and `12:` (both integer 12) are detected. Duplicate mapping keys — which pnpm rejects but PyYAML silently keeps — MUST fail the parse closed (compared by YAML tag plus canonical value, so `012`/`12` collide while a string `"12"` and int `12` stay distinct). +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): Evaluate every untrusted pattern in bounded time and space with no unbounded recursion. Brace expansion, comma-option splitting, the character-scanning work of locating alternations, and glob matching (including many `**` segments) MUST all be bounded so a brace bomb, comma bomb, slash-wall, or deeply nested manifest fails membership closed instead of hanging, exhausting memory, or raising `RecursionError`. Note a deeply nested singleton brace (e.g. `{`×N … `}`×N followed by real alternations) can stay within byte, expansion-count, and segment budgets yet cost quadratic re-scanning; a bound on scanning work (not just output count) is therefore required. The pattern matcher likewise MUST bound its per-CHARACTER work, not merely a coarse segment/cell count: a manifest of many long wildcard-heavy globs against a deep path MUST NOT burn CPU even while staying within an output/segment cap — demonstrate the worst case with a timing or work-count test. Any untrusted script text parsed for runner detection (e.g. a `package.json` script value fed to a shell tokenizer) MUST likewise be length-bounded so a padded, near-manifest-size value cannot make parsing super-linear. Budgets are aggregate across one membership check — and, where a discovery walk re-evaluates globs at many ancestors, across the whole walk — not merely per glob. HOW the matcher meets these bounds (segment-state caching, per-comparison work accounting, fast-rejecting a `**`-free pattern whose segment count differs) is implementation detail fixed by the timing/work-count tests, not mandated here. +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. -R14 (MUST NOT): Re-run placeholder (`{file}`/`{test}`) substitution on a command returned by `get_test_command_for_file`. The returned command is already complete — CSV placeholders resolved and the runner path embedded and shell-quoted — so a caller that blindly re-substitutes would splice an unbalanced quoted string into an already-quoted argument when the resolved path itself contains that literal token, breaking shell quoting (a command-injection vector). Callers MUST treat the command as final. (Enforced at the `fix_error_loop` boundary.) - 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): For a detected TS runner, return a `TestCommand` whose `cwd` is the config directory. For CSV commands, substitute the `{file}` placeholder via a shell-lexical-context-aware single pass (`shell_safe_substitute`) and return `cwd=None`. Callers execute with `shell=True`. `shlex.quote` yields a SELF-CONTAINED shell word, which is safe to splice in AND safe to concatenate with ordinary literal characters on either side — so a suffix or prefix template like `gfortran -o {file}.out {file}` or `fpc {file} && ./{file}` (real CSV rows) MUST still substitute and return a command, NOT be rejected as "adjacent"; rejecting them silently disables crash verification for those languages. The substitution MUST return None (→ fall through to the next resolution step) whenever non-placeholder template syntax could make `bash` expand the command or change its word count: the placeholder inside the template's own single/double quotes or backticks (`mocha "{file}"` — the inserted quotes become literal and a `$(...)` in the path still executes), immediately preceded by `$` (`${file}` → `$'…'` ANSI-C) or a backslash, or in a shell comment or here-document body; any multiline template (a newline, the only way to form a heredoc body); any template containing `$`, a backtick, or `(`/`)`; and any 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). An empty placeholder key and an escaped placeholder (`\{file}`) are refused too. The substitution MUST be single-pass — an inserted value that itself contains a literal `{file}` MUST NOT be rescanned as another placeholder. Infer the language via `get_language` when not provided. Gracefully handle a missing CSV by proceeding to the next resolution step. +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. + + + +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 + # Dependencies 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_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_test_command.py b/tests/test_get_test_command.py index be169015eb..38d9e8db78 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -331,6 +331,7 @@ 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. @@ -2966,4 +2967,4 @@ 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 \ No newline at end of file + assert "/tmp/foo_test.go" in result.command 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..14fdf06ae9 --- /dev/null +++ b/user_stories/story__pdd_nested_ts_runner_resolution.md @@ -0,0 +1,56 @@ + + +# User Story: Nested TypeScript tests use the correct runner safely + +## Story + +As a developer working in a nested JavaScript workspace, I want PDD to execute a test with the runner and project that actually own it, so a verification cannot silently run zero tests, cross a repository boundary, or reinterpret an untrusted path as shell syntax. + +## Covers + +- R1: Command resolution follows the documented priority and falls back safely. +- R2: TypeScript tests select the nearest applicable Playwright, Jest, or Vitest runner. +- R3: Deeply nested tests can inherit a runner without a fixed shallow walk limit. +- R4: Workspace members inherit only through their declared workspace boundary. +- R5: Unrelated or excluded packages never borrow an ancestor runner. +- R6: Workspace include, exclude, and re-inclusion decisions match the declaring ecosystem. +- R7: A pnpm workspace declaration remains authoritative over stale npm metadata. +- R8: Malformed, unsafe, or oversized workspace declarations fail closed. +- R9: Hostile manifests and patterns are evaluated within deterministic resource bounds. +- R10: Symlinks and path traversal cannot escape the repository boundary. +- R11: Jest and Playwright receive literal-equivalent test targets. +- R12: Appended test paths remain one shell-safe argument. +- R13: Runner working directories and CSV placeholder substitution remain safe and deterministic. +- R14: A completed command is never subjected to a second placeholder substitution pass. + +## Context / Fixtures + +The regression suite creates temporary standalone repositories, npm and pnpm workspaces, nested Next.js-style dynamic routes, runner configurations, hostile manifests, symlinks, and shell-metacharacter-bearing paths. No external service is required. + +## Acceptance Criteria + +1. Given a deeply nested TypeScript test, when PDD resolves its command, then it selects only the nearest runner that the owning package may inherit and returns that runner's configuration directory as the working directory. +2. Given npm/yarn/lerna or pnpm include and exclude patterns, when membership is evaluated, then the result matches that ecosystem's re-inclusion behavior and never grants membership to an excluded, unrelated, or `node_modules` package. +3. Given malformed, oversized, recursively nested, or computationally hostile workspace input, when discovery runs, then it terminates within the configured budgets and declines unsafe inheritance without crashing. +4. Given a dynamic-route or shell-metacharacter-bearing test path, when the command is returned, then the intended runner receives one literal-equivalent, shell-safe path and the command is not re-substituted. +5. Given a path or configuration symlink that escapes the repository, when discovery runs, then no foreign runner configuration is adopted; an in-repository symlink continues to work. + +## Oracle + +- The selected runner, test target, and working directory identify the owning project. +- Excluded or foreign packages receive no inherited runner. +- Literal dynamic-route tests are collected and shell metacharacters remain data. +- Hostile inputs terminate deterministically and fail closed. + +## Non-Oracle + +- Private helper names and matcher implementation strategy. +- Cache layout and internal traversal order when observable selection is unchanged. +- Exact diagnostic wording. + +## Forbidden Outcomes + +- Reporting success after Jest matched zero tests because route brackets were treated as regex. +- Executing under an unrelated ancestor or outside the repository through a symlink. +- Re-evaluating a substituted path as active shell syntax. +- Hanging or exhausting memory on repository-controlled workspace metadata. From 7bcbb46ec9ef58dce45e2e7527ac4b320b778492 Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 17:41:31 -0700 Subject: [PATCH 51/56] chore(pdd): register nested runner story ownership --- .pdd/sync-ownership.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.pdd/sync-ownership.json b/.pdd/sync-ownership.json index 8010ee6c36..243b646e9a 100644 --- a/.pdd/sync-ownership.json +++ b/.pdd/sync-ownership.json @@ -13789,6 +13789,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", From 6b07adc4d7f843fab75bb03235e1ec83338d0225 Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 18:02:05 -0700 Subject: [PATCH 52/56] chore(pdd): converge runner story fingerprints --- .pdd/meta/fix_error_loop_python.json | 34 +++++++++++++------ .pdd/meta/get_test_command_python.json | 6 ++-- .../story__pdd_nested_ts_runner_resolution.md | 7 ++-- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/.pdd/meta/fix_error_loop_python.json b/.pdd/meta/fix_error_loop_python.json index 1cd791ee0e..38d32e9f27 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 -} \ No newline at end of file + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T00:58:40.000000+00:00", + "command": "test", + "prompt_hash": "eb00d68df6fd849be51f64b0c3ee87ed458974f9deeae64ba4bd7aa96bee5c48", + "code_hash": "18a12faeed802501abd18328edbb8faff3b3f533e6a229b7f7f21161fbd634b5", + "example_hash": "a86435e82357bc671ac6dadee6df22c163d27a30cbdba102e9cbe3bc3121fc43", + "test_hash": "6190aeff914edf5b7929874866480d44a2805956a2917afaa1fd14975c5606ae", + "test_files": { + "test_fix_error_loop.py": "6190aeff914edf5b7929874866480d44a2805956a2917afaa1fd14975c5606ae" + }, + "include_deps": { + "context/agentic_fix_example.py": "407a024fcba9402e00b401477725ae72b1de718ab59bea29b69509a0b15f621f", + "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", + "context/core/cloud_example.py": "8a14ae9e69dbccfbbfffd9bde1ea11192334a10aaf9d5b5e6dba5ac23ba0dd90", + "context/fix_errors_from_unit_tests_example.py": "df6c93b3ae585af13827cf3edb398e7c0da75648b34ff0477044b659df85a117", + "context/get_language_example.py": "8b09af25b96fa49e2dcf5ff307bbeb0bc83e84b64618971d393397772a2fafab", + "context/pytest_example.py": "f13c02d911d386ac5bab8e3d4aae64a768e4a7e960803c80fcac14e9ba5975cb", + "context/python_env_detector_example.py": "65619b3e8bad0509f1ccf54d81b5c3760f423b6fc1925c932fc7968da78a172e", + "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", + "pdd/failure_classification.py": "0675857d23f52e447f15ace22ec37c2f003a61d3c810254c8547129c29d697ff", + "pdd/fix_focus.py": "e341e6bda871ddc5ba92cf05e9d554d49bc5e4afebf5b4f1482971edf557063d", + "pdd/test_context_packer.py": "27c7d14747d38df8edf24c8ef2ce1b7ca21d8f2eea38aec02ba06042ee144c0b" + } +} diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index 94ff5f7a19..7105f4510d 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,8 +1,8 @@ { - "pdd_version": "0.0.302.dev0", - "timestamp": "2026-07-14T00:27:01.000000+00:00", + "pdd_version": "0.0.304.dev0", + "timestamp": "2026-07-14T00:45:36.000000+00:00", "command": "test", - "prompt_hash": "858ca20048acc4ef7013c220aeac8e8b2db0102056f477945f3cb06b8d7a8a88", + "prompt_hash": "804ba26b573c7e8a5a14f2dba2bec390f4fd3aab0f47c6d9df091d54a4145af9", "code_hash": "9d7131d2fad2c2e5ccdbecf7fb111a5b4b48b1806d85327d2f1c84cc3f4517ba", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", "test_hash": "bada38a33f0dd9c75058ab1ffa1719d7c6dfb972b7d5ef5b8a79d177722bc9d7", diff --git a/user_stories/story__pdd_nested_ts_runner_resolution.md b/user_stories/story__pdd_nested_ts_runner_resolution.md index 14fdf06ae9..116e0377a3 100644 --- a/user_stories/story__pdd_nested_ts_runner_resolution.md +++ b/user_stories/story__pdd_nested_ts_runner_resolution.md @@ -1,4 +1,5 @@ - + + # User Story: Nested TypeScript tests use the correct runner safely @@ -31,8 +32,8 @@ The regression suite creates temporary standalone repositories, npm and pnpm wor 1. Given a deeply nested TypeScript test, when PDD resolves its command, then it selects only the nearest runner that the owning package may inherit and returns that runner's configuration directory as the working directory. 2. Given npm/yarn/lerna or pnpm include and exclude patterns, when membership is evaluated, then the result matches that ecosystem's re-inclusion behavior and never grants membership to an excluded, unrelated, or `node_modules` package. -3. Given malformed, oversized, recursively nested, or computationally hostile workspace input, when discovery runs, then it terminates within the configured budgets and declines unsafe inheritance without crashing. -4. Given a dynamic-route or shell-metacharacter-bearing test path, when the command is returned, then the intended runner receives one literal-equivalent, shell-safe path and the command is not re-substituted. +3. Given malformed, oversized, recursively nested, or computationally hostile workspace input, when discovery runs, then it terminates within the configured budgets, returns no inherited runner, and raises no exception. +4. Given a dynamic-route or shell-metacharacter-bearing test path, when the command is returned, then the intended runner receives exactly one path argument, its metacharacters remain literal characters, and the command is not re-substituted. 5. Given a path or configuration symlink that escapes the repository, when discovery runs, then no foreign runner configuration is adopted; an in-repository symlink continues to work. ## Oracle From 5e2ed4f33a8244cd76f140df8a1ae4d7be3f2931 Mon Sep 17 00:00:00 2001 From: Serhan Date: Mon, 13 Jul 2026 18:15:45 -0700 Subject: [PATCH 53/56] chore(pdd): align runner dependency governance --- .../agentic_checkup_orchestrator_python.json | 24 +++++------ .../agentic_e2e_fix_orchestrator_python.json | 29 +++++++------- ...entic_e2e_fix_orchestrator_python_run.json | 12 ------ .pdd/meta/agentic_sync_runner_python.json | 24 +++++------ .pdd/meta/agentic_sync_runner_python_run.json | 11 ----- .pdd/meta/ci_drift_heal_python.json | 27 ++++++------- .pdd/meta/ci_drift_heal_python_run.json | 12 ------ .pdd/meta/code_generator_main_python.json | 38 +++++++++--------- .pdd/meta/fix_code_loop_python.json | 14 +++---- .pdd/meta/fix_error_loop_python.json | 12 +++--- .pdd/meta/get_test_command_python.json | 11 ++--- .../meta/sync_determine_operation_python.json | 18 ++++----- .../sync_determine_operation_python_run.json | 11 ----- .pdd/meta/sync_orchestration_python.json | 35 ++++++++-------- .pdd/meta/sync_orchestration_python_run.json | 11 ----- .pdd/verification-profile-rotations.json | 18 +++++++++ .pdd/verification-profiles.json | 8 ++-- architecture.json | 8 +++- context/get_run_command_example.py | 40 +++++++++++++++++-- pdd/prompts/agentic_langtest_python.prompt | 4 ++ pdd/prompts/get_test_command_python.prompt | 4 ++ 21 files changed, 188 insertions(+), 183 deletions(-) delete mode 100644 .pdd/meta/agentic_e2e_fix_orchestrator_python_run.json delete mode 100644 .pdd/meta/agentic_sync_runner_python_run.json delete mode 100644 .pdd/meta/ci_drift_heal_python_run.json delete mode 100644 .pdd/meta/sync_determine_operation_python_run.json delete mode 100644 .pdd/meta/sync_orchestration_python_run.json 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 38d32e9f27..40b5b4106f 100644 --- a/.pdd/meta/fix_error_loop_python.json +++ b/.pdd/meta/fix_error_loop_python.json @@ -1,6 +1,6 @@ { "pdd_version": "0.0.304.dev0", - "timestamp": "2026-07-14T00:58:40.000000+00:00", + "timestamp": "2026-07-14T01:11:43.215505+00:00", "command": "test", "prompt_hash": "eb00d68df6fd849be51f64b0c3ee87ed458974f9deeae64ba4bd7aa96bee5c48", "code_hash": "18a12faeed802501abd18328edbb8faff3b3f533e6a229b7f7f21161fbd634b5", @@ -10,16 +10,16 @@ "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/core/cloud_example.py": "8a14ae9e69dbccfbbfffd9bde1ea11192334a10aaf9d5b5e6dba5ac23ba0dd90", - "context/fix_errors_from_unit_tests_example.py": "df6c93b3ae585af13827cf3edb398e7c0da75648b34ff0477044b659df85a117", - "context/get_language_example.py": "8b09af25b96fa49e2dcf5ff307bbeb0bc83e84b64618971d393397772a2fafab", "context/pytest_example.py": "f13c02d911d386ac5bab8e3d4aae64a768e4a7e960803c80fcac14e9ba5975cb", "context/python_env_detector_example.py": "65619b3e8bad0509f1ccf54d81b5c3760f423b6fc1925c932fc7968da78a172e", - "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", "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 7105f4510d..d8cf65dcab 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,8 +1,8 @@ { "pdd_version": "0.0.304.dev0", - "timestamp": "2026-07-14T00:45:36.000000+00:00", + "timestamp": "2026-07-14T01:11:43.472349+00:00", "command": "test", - "prompt_hash": "804ba26b573c7e8a5a14f2dba2bec390f4fd3aab0f47c6d9df091d54a4145af9", + "prompt_hash": "f67f9499c9c721e9444314f31a4fcc46010fc31ea9ad1158d3752d3dd9b94a11", "code_hash": "9d7131d2fad2c2e5ccdbecf7fb111a5b4b48b1806d85327d2f1c84cc3f4517ba", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", "test_hash": "bada38a33f0dd9c75058ab1ffa1719d7c6dfb972b7d5ef5b8a79d177722bc9d7", @@ -10,9 +10,10 @@ "test_get_test_command.py": "bada38a33f0dd9c75058ab1ffa1719d7c6dfb972b7d5ef5b8a79d177722bc9d7" }, "include_deps": { - "context/agentic_langtest_example.py": "fb2d8d77407461d32794efeb09d121b53d031b7c7791ac585ba7ba084d392fdd", "pdd/agentic_langtest.py": "441f56ac5f74f56b67ee01d66f0f2cea38eaa0648461513ab45d6be872432372", + "pdd/get_language.py": "1490ea5281a086896be3a3f20cc5b216cd79c7974bcf8a6bbd13129656dd8080", "pdd/data/language_format.csv": "691a4e14b76c176269e34dbbf03167c8fa17b76be9212e4546750b0a80315ff9", - "pdd/get_language.py": "1490ea5281a086896be3a3f20cc5b216cd79c7974bcf8a6bbd13129656dd8080" + "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/verification-profile-rotations.json b/.pdd/verification-profile-rotations.json index 797a534fb2..cd0b0fa0ea 100644 --- a/.pdd/verification-profile-rotations.json +++ b/.pdd/verification-profile-rotations.json @@ -80,6 +80,24 @@ "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" } ] } diff --git a/.pdd/verification-profiles.json b/.pdd/verification-profiles.json index e9bebeeb17..24a9f780c2 100644 --- a/.pdd/verification-profiles.json +++ b/.pdd/verification-profiles.json @@ -1919,7 +1919,7 @@ "prompt_path": "pdd/prompts/agentic_langtest_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:1ada3c0ba9faaa60875f4f311d8d120a6804b2bbca9a2591f6d4439712fb3b3c" + "CONTRACT-SHA256:4e4e83bfb6a6fd4911bd83a75423a869e111faf52ca340c30497904ba23e1049" ], "obligations": [ { @@ -1928,7 +1928,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:1ada3c0ba9faaa60875f4f311d8d120a6804b2bbca9a2591f6d4439712fb3b3c" + "CONTRACT-SHA256:4e4e83bfb6a6fd4911bd83a75423a869e111faf52ca340c30497904ba23e1049" ], "artifact_paths": [ "pdd/prompts/agentic_langtest_python.prompt" @@ -7603,7 +7603,7 @@ "prompt_path": "pdd/prompts/get_test_command_python.prompt", "language_id": "python", "required_requirement_ids": [ - "CONTRACT-SHA256:858ca20048acc4ef7013c220aeac8e8b2db0102056f477945f3cb06b8d7a8a88" + "CONTRACT-SHA256:24fb1ab5a7a492f0d295d3b752ed4b4e045cf68081c9d1b4417b4b9554b3db9a" ], "obligations": [ { @@ -7612,7 +7612,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:858ca20048acc4ef7013c220aeac8e8b2db0102056f477945f3cb06b8d7a8a88" + "CONTRACT-SHA256:24fb1ab5a7a492f0d295d3b752ed4b4e045cf68081c9d1b4417b4b9554b3db9a" ], "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/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/prompts/agentic_langtest_python.prompt b/pdd/prompts/agentic_langtest_python.prompt index 8bae145c6c..5bcd6c99e0 100644 --- a/pdd/prompts/agentic_langtest_python.prompt +++ b/pdd/prompts/agentic_langtest_python.prompt @@ -1,4 +1,5 @@ context/python_preamble.prompt +get_run_command_python.prompt % Goal Write the pdd/agentic_langtest.py module. @@ -57,6 +58,9 @@ Returns guidance if required tools are missing. `{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/get_test_command_python.prompt b/pdd/prompts/get_test_command_python.prompt index 81884cca5a..a38bd415fd 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -1,4 +1,5 @@ # Role and Scope +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 @@ -76,6 +77,9 @@ R14: story__pdd_nested_ts_runner_resolution.md, test_no_reinjection_via_maliciou context/agentic_langtest_example.py + + context/get_run_command_example.py + # Deliverables 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`. From 5278c331203a01df0c2ed8998336cc857584fa14 Mon Sep 17 00:00:00 2001 From: Serhan Date: Tue, 14 Jul 2026 12:56:26 -0700 Subject: [PATCH 54/56] fix: complete nested runner verification contract --- .pdd/meta/fix_error_loop_python.json | 4 +- .pdd/meta/get_test_command_python.json | 10 +- pdd/get_test_command.py | 111 +++++++++++++++--- pdd/prompts/agentic_fix_python.prompt | 4 + pdd/prompts/agentic_langtest_python.prompt | 4 + pdd/prompts/fix_error_loop_python.prompt | 4 + pdd/prompts/get_run_command_python.prompt | 4 + pdd/prompts/get_test_command_python.prompt | 5 +- tests/test_get_test_command.py | 9 +- ...dd_nested_ts_runner_resolution.contract.md | 90 ++++++++++++++ .../story__pdd_nested_ts_runner_resolution.md | 53 +-------- 11 files changed, 223 insertions(+), 75 deletions(-) create mode 100644 user_stories/contracts/pdd_nested_ts_runner_resolution.contract.md diff --git a/.pdd/meta/fix_error_loop_python.json b/.pdd/meta/fix_error_loop_python.json index 40b5b4106f..135e12e207 100644 --- a/.pdd/meta/fix_error_loop_python.json +++ b/.pdd/meta/fix_error_loop_python.json @@ -1,8 +1,8 @@ { "pdd_version": "0.0.304.dev0", - "timestamp": "2026-07-14T01:11:43.215505+00:00", + "timestamp": "2026-07-14T19:55:34.170582+00:00", "command": "test", - "prompt_hash": "eb00d68df6fd849be51f64b0c3ee87ed458974f9deeae64ba4bd7aa96bee5c48", + "prompt_hash": "92a4afb36317d4ef367b810cd43c2a031c49ce0ac2ea15199e5fc052d16192ac", "code_hash": "18a12faeed802501abd18328edbb8faff3b3f533e6a229b7f7f21161fbd634b5", "example_hash": "a86435e82357bc671ac6dadee6df22c163d27a30cbdba102e9cbe3bc3121fc43", "test_hash": "6190aeff914edf5b7929874866480d44a2805956a2917afaa1fd14975c5606ae", diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index d8cf65dcab..a29a895db8 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.304.dev0", - "timestamp": "2026-07-14T01:11:43.472349+00:00", + "timestamp": "2026-07-14T19:55:34.743361+00:00", "command": "test", - "prompt_hash": "f67f9499c9c721e9444314f31a4fcc46010fc31ea9ad1158d3752d3dd9b94a11", - "code_hash": "9d7131d2fad2c2e5ccdbecf7fb111a5b4b48b1806d85327d2f1c84cc3f4517ba", + "prompt_hash": "7a85c70f02ee81d2911d8868adb32d2f0f344102db5ec14d0e1cf286946259f2", + "code_hash": "83f03598807a935b01f5520af7543a175fd7b4b0dfc0a9416a7fa8c1376fc714", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "bada38a33f0dd9c75058ab1ffa1719d7c6dfb972b7d5ef5b8a79d177722bc9d7", + "test_hash": "bf1535b8a06e8a7a3eae528c86dd7c50a36fe852ccfc221e90f4d9a8655c1143", "test_files": { - "test_get_test_command.py": "bada38a33f0dd9c75058ab1ffa1719d7c6dfb972b7d5ef5b8a79d177722bc9d7" + "test_get_test_command.py": "bf1535b8a06e8a7a3eae528c86dd7c50a36fe852ccfc221e90f4d9a8655c1143" }, "include_deps": { "pdd/agentic_langtest.py": "441f56ac5f74f56b67ee01d66f0f2cea38eaa0648461513ab45d6be872432372", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 90488f6ba1..5582ab06b6 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -1391,26 +1391,109 @@ def _binary_after_flags_is_vitest(tokens: list) -> bool: 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", "-0", "--null"): + j += 1 + continue + if tok in ("-u", "--unset", "-C", "--chdir", "-S", "--split-string"): + if j + 1 >= len(rest): + valid = False + else: + j += 2 + continue + if tok.startswith(("--unset=", "--chdir=", "--split-string=")): + 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 + while j < len(rest): + 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): + return None + j += 2 + continue + if tok.startswith("-"): + return None + break + if j < len(rest) and rest[j].split("/")[-1] == "exec": + return None + return rest[j:] + + +_SHELL_WRAPPER_UNWRAPPERS = { + "env": _unwrap_env, + "command": _unwrap_command, + "exec": _unwrap_exec, +} + + def _clause_invokes_vitest(tokens: list) -> bool: - """True when a single command clause (already tokenized) runs the ``vitest`` binary in - EXECUTABLE position: as the command itself (basename ``vitest``), via a direct - package runner (``npx``/``bunx`` + ``vitest``), or via an explicit exec sub-command - (``pnpm exec vitest``, ``pnpm dlx vitest``, ``bun x vitest``). A bare ``pnpm vitest`` - or a ``run `` form (``npm run vitest``) invokes a package.json SCRIPT — possibly - a Vite-only shadow — and is NOT proof. Neither is a bare ``vitest`` ARGUMENT - (``echo vitest``, ``node vitest``, ``command -v vitest``).""" - idx = 0 - while idx < len(tokens) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tokens[idx]): - idx += 1 # drop leading VAR=value environment assignments - if idx >= len(tokens): + """True when a command clause invokes Vitest in executable position.""" + if not tokens: return False - cmd = tokens[idx].split("/")[-1] + 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(tokens[idx + 1:]) + return _binary_after_flags_is_vitest(rest) if cmd in _EXEC_RUNNERS: - rest = tokens[idx + 1:] 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 diff --git a/pdd/prompts/agentic_fix_python.prompt b/pdd/prompts/agentic_fix_python.prompt index 62c1787f22..8efd90eb9f 100644 --- a/pdd/prompts/agentic_fix_python.prompt +++ b/pdd/prompts/agentic_fix_python.prompt @@ -28,6 +28,10 @@ You are an expert Python developer responsible for implementing the `agentic_fix 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 diff --git a/pdd/prompts/agentic_langtest_python.prompt b/pdd/prompts/agentic_langtest_python.prompt index 5bcd6c99e0..07b0e085f1 100644 --- a/pdd/prompts/agentic_langtest_python.prompt +++ b/pdd/prompts/agentic_langtest_python.prompt @@ -45,6 +45,10 @@ Returns a test command for the given language and test file. 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. diff --git a/pdd/prompts/fix_error_loop_python.prompt b/pdd/prompts/fix_error_loop_python.prompt index fcd166a04d..e3f8f12b00 100644 --- a/pdd/prompts/fix_error_loop_python.prompt +++ b/pdd/prompts/fix_error_loop_python.prompt @@ -44,6 +44,10 @@ The module supports both local and cloud execution modes: * *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 66c9c83a05..8e18b642b4 100644 --- a/pdd/prompts/get_run_command_python.prompt +++ b/pdd/prompts/get_run_command_python.prompt @@ -62,6 +62,10 @@ 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 a38bd415fd..f657286f0a 100644 --- a/pdd/prompts/get_test_command_python.prompt +++ b/pdd/prompts/get_test_command_python.prompt @@ -8,7 +8,7 @@ You are an expert software engineer implementing the `get_test_command.py` modul - `_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, judged with Bash-accurate quoting/comment/here-document semantics: a `vitest` token that is only an argument, a package-script name, 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. +- **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. @@ -45,6 +45,8 @@ R12 (MUST): Shell-quote the appended absolute test path for every runner, becaus 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. @@ -62,6 +64,7 @@ R11: story__pdd_nested_ts_runner_resolution.md, test_dynamic_route_bracket_path_ 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 diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 38d9e8db78..2471376eeb 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1679,7 +1679,10 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): for script in ("vitest run", "npx vitest", "npx --yes vitest", "bunx vitest", "pnpm exec vitest", "pnpm dlx vitest", "bun x vitest", "yarn exec vitest", "npm exec -- vitest", "pnpm dlx -- vitest", - "./node_modules/.bin/vitest"): + "./node_modules/.bin/vitest", "env CI=1 vitest", "env -i vitest", + "command vitest", "command -p vitest", "exec vitest", + "exec -a test-runner vitest", "env command -- vitest", + "command exec vitest", "exec command vitest"): (repo / "package.json").write_text( json.dumps({"scripts": {"test": script}})) cmd2, _ = _detect_ts_test_runner(repo / "src" / "a.test.ts") @@ -1699,7 +1702,9 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): # Vitest. for script in ("echo no-vitest-installed", "cat vitest.config.ts", "echo run-vitest-later", "echo vitest", "node vitest", - "command -v vitest", "printf vitest", "pnpm run test", + "command -v vitest", "command -V vitest", "printf vitest", + "env CI=1 echo vitest", "env exec vitest", + "exec exec vitest", "exec echo vitest", "pnpm run test", "npx --package vitest echo ok", "npx -p vitest echo ok", "npm run vitest", "pnpm run vitest", "yarn run vitest", "pnpm vitest", "yarn vitest", "npm vitest", 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 index 116e0377a3..9b8a808bdc 100644 --- a/user_stories/story__pdd_nested_ts_runner_resolution.md +++ b/user_stories/story__pdd_nested_ts_runner_resolution.md @@ -1,57 +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 execute a test with the runner and project that actually own it, so a verification cannot silently run zero tests, cross a repository boundary, or reinterpret an untrusted path as shell syntax. - -## Covers - -- R1: Command resolution follows the documented priority and falls back safely. -- R2: TypeScript tests select the nearest applicable Playwright, Jest, or Vitest runner. -- R3: Deeply nested tests can inherit a runner without a fixed shallow walk limit. -- R4: Workspace members inherit only through their declared workspace boundary. -- R5: Unrelated or excluded packages never borrow an ancestor runner. -- R6: Workspace include, exclude, and re-inclusion decisions match the declaring ecosystem. -- R7: A pnpm workspace declaration remains authoritative over stale npm metadata. -- R8: Malformed, unsafe, or oversized workspace declarations fail closed. -- R9: Hostile manifests and patterns are evaluated within deterministic resource bounds. -- R10: Symlinks and path traversal cannot escape the repository boundary. -- R11: Jest and Playwright receive literal-equivalent test targets. -- R12: Appended test paths remain one shell-safe argument. -- R13: Runner working directories and CSV placeholder substitution remain safe and deterministic. -- R14: A completed command is never subjected to a second placeholder substitution pass. - -## Context / Fixtures - -The regression suite creates temporary standalone repositories, npm and pnpm workspaces, nested Next.js-style dynamic routes, runner configurations, hostile manifests, symlinks, and shell-metacharacter-bearing paths. No external service is required. - -## Acceptance Criteria - -1. Given a deeply nested TypeScript test, when PDD resolves its command, then it selects only the nearest runner that the owning package may inherit and returns that runner's configuration directory as the working directory. -2. Given npm/yarn/lerna or pnpm include and exclude patterns, when membership is evaluated, then the result matches that ecosystem's re-inclusion behavior and never grants membership to an excluded, unrelated, or `node_modules` package. -3. Given malformed, oversized, recursively nested, or computationally hostile workspace input, when discovery runs, then it terminates within the configured budgets, returns no inherited runner, and raises no exception. -4. Given a dynamic-route or shell-metacharacter-bearing test path, when the command is returned, then the intended runner receives exactly one path argument, its metacharacters remain literal characters, and the command is not re-substituted. -5. Given a path or configuration symlink that escapes the repository, when discovery runs, then no foreign runner configuration is adopted; an in-repository symlink continues to work. - -## Oracle - -- The selected runner, test target, and working directory identify the owning project. -- Excluded or foreign packages receive no inherited runner. -- Literal dynamic-route tests are collected and shell metacharacters remain data. -- Hostile inputs terminate deterministically and fail closed. - -## Non-Oracle - -- Private helper names and matcher implementation strategy. -- Cache layout and internal traversal order when observable selection is unchanged. -- Exact diagnostic wording. - -## Forbidden Outcomes - -- Reporting success after Jest matched zero tests because route brackets were treated as regex. -- Executing under an unrelated ancestor or outside the repository through a symlink. -- Re-evaluating a substituted path as active shell syntax. -- Hanging or exhausting memory on repository-controlled workspace metadata. +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. From b57f221c49ab5e9de7448584ad86f1ac72362299 Mon Sep 17 00:00:00 2001 From: Serhan Date: Tue, 14 Jul 2026 13:00:56 -0700 Subject: [PATCH 55/56] fix: reject ambiguous env vitest wrappers --- .pdd/meta/get_test_command_python.json | 8 +++---- pdd/get_test_command.py | 31 +++++++++++++++++--------- tests/test_get_test_command.py | 2 ++ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.pdd/meta/get_test_command_python.json b/.pdd/meta/get_test_command_python.json index a29a895db8..8fc4434219 100644 --- a/.pdd/meta/get_test_command_python.json +++ b/.pdd/meta/get_test_command_python.json @@ -1,13 +1,13 @@ { "pdd_version": "0.0.304.dev0", - "timestamp": "2026-07-14T19:55:34.743361+00:00", + "timestamp": "2026-07-14T20:00:39.902385+00:00", "command": "test", "prompt_hash": "7a85c70f02ee81d2911d8868adb32d2f0f344102db5ec14d0e1cf286946259f2", - "code_hash": "83f03598807a935b01f5520af7543a175fd7b4b0dfc0a9416a7fa8c1376fc714", + "code_hash": "b5e38e9fc9597df7b8cd7502603b58ea8b90821a25067688f0499014c6fa01ab", "example_hash": "33b16476536f8c9424d3222ffc4a5cd5ecee6ad0589b2c66a4f3c7f1e265106c", - "test_hash": "bf1535b8a06e8a7a3eae528c86dd7c50a36fe852ccfc221e90f4d9a8655c1143", + "test_hash": "207d679a82b06b1b28b0c19cebd9dabe81a21ca58c007486d5d29448c587574f", "test_files": { - "test_get_test_command.py": "bf1535b8a06e8a7a3eae528c86dd7c50a36fe852ccfc221e90f4d9a8655c1143" + "test_get_test_command.py": "207d679a82b06b1b28b0c19cebd9dabe81a21ca58c007486d5d29448c587574f" }, "include_deps": { "pdd/agentic_langtest.py": "441f56ac5f74f56b67ee01d66f0f2cea38eaa0648461513ab45d6be872432372", diff --git a/pdd/get_test_command.py b/pdd/get_test_command.py index 5582ab06b6..03609fea98 100644 --- a/pdd/get_test_command.py +++ b/pdd/get_test_command.py @@ -1400,16 +1400,24 @@ def _unwrap_env(rest: list) -> Optional[list]: if tok == "--": j += 1 break - if tok in ("-i", "--ignore-environment", "-0", "--null"): + if tok in ("-i", "--ignore-environment"): j += 1 continue - if tok in ("-u", "--unset", "-C", "--chdir", "-S", "--split-string"): + # 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=", "--split-string=")): + if tok.startswith(("--unset=", "--chdir=")): j += 1 continue if tok.startswith("-"): @@ -1441,7 +1449,8 @@ def _unwrap_command(rest: list) -> Optional[list]: def _unwrap_exec(rest: list) -> Optional[list]: """Return command operands for an understood Bash ``exec`` invocation.""" j = 0 - while j < len(rest): + valid = True + while j < len(rest) and valid: tok = rest[j] if tok == "--": j += 1 @@ -1451,15 +1460,17 @@ def _unwrap_exec(rest: list) -> Optional[list]: continue if tok == "-a": if j + 1 >= len(rest): - return None - j += 2 + valid = False + else: + j += 2 continue if tok.startswith("-"): - return None + valid = False + continue break - if j < len(rest) and rest[j].split("/")[-1] == "exec": - return None - return rest[j:] + if valid and j < len(rest) and rest[j].split("/")[-1] == "exec": + valid = False + return rest[j:] if valid else None _SHELL_WRAPPER_UNWRAPPERS = { diff --git a/tests/test_get_test_command.py b/tests/test_get_test_command.py index 2471376eeb..9457a12226 100644 --- a/tests/test_get_test_command.py +++ b/tests/test_get_test_command.py @@ -1704,6 +1704,8 @@ def test_vite_config_adopted_only_when_vitest_is_proven(self, tmp_path): "echo run-vitest-later", "echo vitest", "node vitest", "command -v vitest", "command -V vitest", "printf vitest", "env CI=1 echo vitest", "env exec vitest", + "env -S echo vitest", "env --split-string=echo vitest", + "env -0 vitest", "env --null vitest", "exec exec vitest", "exec echo vitest", "pnpm run test", "npx --package vitest echo ok", "npx -p vitest echo ok", "npm run vitest", "pnpm run vitest", "yarn run vitest", From fc704170c53a350a6fffbd84057cfc5437394a47 Mon Sep 17 00:00:00 2001 From: Serhan Date: Tue, 14 Jul 2026 13:46:28 -0700 Subject: [PATCH 56/56] chore(pdd): align nested runner governance --- .pdd/sync-ownership.json | 6 ++++ .pdd/verification-profile-rotations.json | 45 ++++++++++++++++++++++++ .pdd/verification-profiles.json | 20 +++++------ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/.pdd/sync-ownership.json b/.pdd/sync-ownership.json index 243b646e9a..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", diff --git a/.pdd/verification-profile-rotations.json b/.pdd/verification-profile-rotations.json index cd0b0fa0ea..6b8341dd57 100644 --- a/.pdd/verification-profile-rotations.json +++ b/.pdd/verification-profile-rotations.json @@ -98,6 +98,51 @@ "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 24a9f780c2..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:2258b83cdd6a0ca474b418e51a6c4dc9b7557b706dc88defaf69e3237df9fa84" + "CONTRACT-SHA256:b8bd677c8754f0922de48076cc42b06d4d57d5f78e5cdbd3ecdb008df899cc94" ], "obligations": [ { @@ -1884,7 +1884,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:2258b83cdd6a0ca474b418e51a6c4dc9b7557b706dc88defaf69e3237df9fa84" + "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:4e4e83bfb6a6fd4911bd83a75423a869e111faf52ca340c30497904ba23e1049" + "CONTRACT-SHA256:663ed3b48635d512296716fc2722ab1ad21b465f4ac5d0c756ff5bba7a961210" ], "obligations": [ { @@ -1928,7 +1928,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:4e4e83bfb6a6fd4911bd83a75423a869e111faf52ca340c30497904ba23e1049" + "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:d3df2e3b68eb9b322582ebaec7788a18aec53bf34a57e308c86b1e946fa73830" + "CONTRACT-SHA256:0f19c51548cbbd0c226c0692251360abaab9c6f726d605660dd0b37617c68cb6" ], "obligations": [ { @@ -5786,7 +5786,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:d3df2e3b68eb9b322582ebaec7788a18aec53bf34a57e308c86b1e946fa73830" + "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:883fd53ab20b3c6d2daeb55390c37ea9e31437d5056c58565f5c25ccd51db274" + "CONTRACT-SHA256:86024b1b169dfa721ba340f7113f78349bccdeb7286cae902ced4db8f48c988c" ], "obligations": [ { @@ -7590,7 +7590,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:883fd53ab20b3c6d2daeb55390c37ea9e31437d5056c58565f5c25ccd51db274" + "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:24fb1ab5a7a492f0d295d3b752ed4b4e045cf68081c9d1b4417b4b9554b3db9a" + "CONTRACT-SHA256:fb787e51f41432bb356b62dc4eb99212876c1491f912d7a6ea5bfc5b3bb82816" ], "obligations": [ { @@ -7612,7 +7612,7 @@ "validator_id": "threshold-ed25519", "validator_config_digest": "threshold-ed25519-v1", "requirement_ids": [ - "CONTRACT-SHA256:24fb1ab5a7a492f0d295d3b752ed4b4e045cf68081c9d1b4417b4b9554b3db9a" + "CONTRACT-SHA256:fb787e51f41432bb356b62dc4eb99212876c1491f912d7a6ea5bfc5b3bb82816" ], "artifact_paths": [ "pdd/prompts/get_test_command_python.prompt"