diff --git a/.pdd/meta/metadata_sync_python.json b/.pdd/meta/metadata_sync_python.json index bba992e110..4a0975216b 100644 --- a/.pdd/meta/metadata_sync_python.json +++ b/.pdd/meta/metadata_sync_python.json @@ -1,19 +1,19 @@ { - "pdd_version": "0.0.237", - "timestamp": "2026-05-16T20:50:00.397700+00:00", + "pdd_version": "0.0.301.dev0", + "timestamp": "2026-07-12T06:04:22.782006+00:00", "command": "fix", - "prompt_hash": "18e8e073748217aa9c224bea7c7e8425ea36de28f769b30684cd21033018bfab", - "code_hash": "739ace489ae1bf00a9aa696332dabce993b934047228fe0d10a1ef9145130fb2", + "prompt_hash": "9253b24eba244502746fb3881e8b83f1eff2ffad15782477209922b4c8611643", + "code_hash": "3afaa5a409e500b3f91aab33dacee33fae47447fba80a1702d65a33761261782", "example_hash": "704453b3386724eef225b61c48f2fe922e1a973ac211c494e1d955dd44bcca11", - "test_hash": "a1bf7b5a8ce6c671aee77fca08edd29eea86e93cce2d02871d0bd25b31591dc3", + "test_hash": "82af9e0bff7550d1f9bf46f4c6e7f4792143565e4e228d1507976f6b9fbac5f6", "test_files": { - "test_metadata_sync.py": "a1bf7b5a8ce6c671aee77fca08edd29eea86e93cce2d02871d0bd25b31591dc3" + "test_metadata_sync.py": "82af9e0bff7550d1f9bf46f4c6e7f4792143565e4e228d1507976f6b9fbac5f6" }, "include_deps": { "context/python_preamble.prompt": "0388ed131bf986f8752e1bc4c81e4da0460cfe2908ec8c60b1314edbab768254", - "pdd/architecture_registry.py": "f822c4ef9d66da1dc6e6104ad3ca99d822c277423e71bb807966e4fc80f779ed", - "pdd/architecture_sync.py": "89329e32dd4d13e7b095f1b96843e383936bbc5f329e50daf006dbb12af0d54b", - "pdd/operation_log.py": "78b05aadd7656dbabb7cdd113fa84d39d32c64e4cb4322aa2ac2570d390d94ef", - "pdd/sync_determine_operation.py": "dff5e2b0628122c49305106d255e91d57446d7c768d4ae78fa4a09a619383e23" + "pdd/architecture_sync.py": "0e0ff74978f49d629f92365723ebbea0e7c70e6d5234ccfdafb1c8e09c110c11", + "pdd/architecture_registry.py": "5722225846b9cc8189b410a78e5714b4f2916d5748271d27f6f738692429c309", + "pdd/sync_determine_operation.py": "3d8138625892a6cda6bea2a9a79168baf14dddf7c5d634123a147e0bea379529", + "pdd/operation_log.py": "c0cb5d5c44e0fc84026780ff2e1082aea7eba7790e3f085ee33f36614758e51a" } } diff --git a/.pdd/meta/metadata_sync_python_run.json b/.pdd/meta/metadata_sync_python_run.json index d7d4e6e49b..597e3d85e5 100644 --- a/.pdd/meta/metadata_sync_python_run.json +++ b/.pdd/meta/metadata_sync_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-05-15T18:09:53.425787+00:00", + "timestamp": "2026-07-10T21:13:29.829501+00:00", "exit_code": 0, "tests_passed": 45, "tests_failed": 0, "coverage": 100.0, - "test_hash": "a1bf7b5a8ce6c671aee77fca08edd29eea86e93cce2d02871d0bd25b31591dc3", + "test_hash": "82af9e0bff7550d1f9bf46f4c6e7f4792143565e4e228d1507976f6b9fbac5f6", "test_files": { - "test_metadata_sync.py": "a1bf7b5a8ce6c671aee77fca08edd29eea86e93cce2d02871d0bd25b31591dc3" + "test_metadata_sync.py": "82af9e0bff7550d1f9bf46f4c6e7f4792143565e4e228d1507976f6b9fbac5f6" } } diff --git a/.pdd/meta/sync_determine_operation_python.json b/.pdd/meta/sync_determine_operation_python.json index 7445a7bb1d..e61adce287 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.301.dev0", + "timestamp": "2026-07-12T06:04:22.704801+00:00", "command": "fix", - "prompt_hash": "67066038dc835de0304bdf437277088994f8293b858343af8a8c1ba00b7c86a2", - "code_hash": "e616ac746674a9f1e8be84bf3093d5e1f45ff0175bc3767b758f2d17b2c54132", + "prompt_hash": "ed4b81828713987826e233db86fafde2e20cd372a615d3103d08be77f8afe17e", + "code_hash": "3d8138625892a6cda6bea2a9a79168baf14dddf7c5d634123a147e0bea379529", "example_hash": "225d20ea0210bbf52650a9f424879ff495e574dd2411eb4b86592a6d06cf59a5", - "test_hash": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d", + "test_hash": "62f31e5f78b3584bccda5347ac3c01692924750f2ec935ac84a8e07c1ab4f157", "test_files": { - "test_sync_determine_operation.py": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d" + "test_sync_determine_operation.py": "62f31e5f78b3584bccda5347ac3c01692924750f2ec935ac84a8e07c1ab4f157" }, "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": "dbc5c87dc1b75d3299dbfb67b17df1838f9ba2c9bdb457097df0832cea112c3f" } -} \ 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 index bb380c3d84..e704421944 100644 --- a/.pdd/meta/sync_determine_operation_python_run.json +++ b/.pdd/meta/sync_determine_operation_python_run.json @@ -1,11 +1,11 @@ { - "timestamp": "2026-07-09T09:54:54.515165+00:00", + "timestamp": "2026-07-12T06:04:30.627336+00:00", "exit_code": 0, - "tests_passed": 1, + "tests_passed": 356, "tests_failed": 0, "coverage": 100.0, - "test_hash": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d", + "test_hash": "62f31e5f78b3584bccda5347ac3c01692924750f2ec935ac84a8e07c1ab4f157", "test_files": { - "test_sync_determine_operation.py": "0e896d21fdf4a2974a3fc0ae3332308e2f2d3d30f4fdb13d39b075c3fb8ec04d" + "test_sync_determine_operation.py": "62f31e5f78b3584bccda5347ac3c01692924750f2ec935ac84a8e07c1ab4f157" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ffd7ed268..5469512989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,160 @@ ### Fix +- **sync**: preserve safe `architecture.json.filepath` precedence across nested or + custom `.pddrc` prompt roots, honor exact configured example/test templates, + reject architecture filenames/outputs that escape their roots, and prevent + cross-context or differently named prompt modules from aliasing each other (#1976). + Additionally: a stale sibling-context entry whose prompt was deleted can no longer + be borrowed by a same-leaf prompt (heuristic leaf/filepath-stem matches are now + restricted to the resolving prompt's context territory), Windows drive-qualified + architecture filenames/filepaths (`D:/x`, `D:x`) are rejected alongside absolute + and `..` paths, and the agentic architecture repair prompt no longer instructs + agents to flatten the Issue #617 path-mirroring layout this change relies on. + Further hardening: every prompt returned by a recursive `prompts_root` scan must + now resolve inside the root, so a same-leaf file symlink can no longer be followed + out of the repository and written through by an update; and the context-territory + guard anchors its `.pddrc` lookup at the project (the `architecture.json` + directory) instead of the process CWD, so context isolation still holds when sync + is driven from a parent/sibling working directory. The governing + `sync_determine_operation` prompt documents all three invariants so a future + regeneration preserves them. + Final hardening: containment now gates the Step 1 direct/case-insensitive fast + path too (not only recursive candidates), so the exact expected prompt being a + symlink to an external file can no longer be returned and truncated by an update + (in-root aliases are preserved); same-leaf context tie-breaking loads its + `context_prefix` from a `.pddrc` anchored at the prompt root rather than the CWD, + so a parent/sibling-CWD run with a broad prompt root no longer picks the + wrong-context prompt; and `architecture.json` is parsed once per resolution and + the immutable snapshot is shared across ambiguity, prompt, and code-path phases so + a concurrent rewrite cannot produce a torn prompt/code pair. + Metadata robustness: the architecture HINT used for prompt discovery now applies the + same context-territory ownership as code-path selection, so a broad-root bare-leaf + lookup no longer borrows a sibling context's row and returns its prompt/code; an + unsafe same-leaf architecture filename (absolute, `..`, backslash, Windows drive) is + excluded from bare-basename ambiguity detection so it can no longer raise a spurious + `AmbiguousModuleError` that blocks a valid module; and a `null`/non-string + architecture `filename` is treated as absent (coerced before any `.lower()`) so the + module resolves by filepath stem instead of hitting an `AttributeError` that the + broad fallback swallowed into a cwd-relative path. + Deeper hardening: context-territory ownership now covers EVERY heuristic architecture + borrow — including the basename+language and flat-filename match loops — so a flat + legacy same-leaf row (`credits_Python.prompt` -> `frontend/credits.py`) can no longer + be borrowed by a backend resolution; `_get_filepath_from_architecture` prefers the + caller's resolved context and anchors all context-prefix `.pddrc` lookups at the + architecture project root, so a path-qualified basename resolved from a parent/sibling + CWD aligns with its canonical `backend/services/foo.py` target instead of falling back + to the default output; and an unsafe OUTPUT filepath (absolute, `..`, backslash, + Windows drive, symlink escape) is excluded from bare-basename ambiguity counting so a + same-filename unsafe row can no longer block a valid module. + CWD-independence completed: the project/prompts-root `.pddrc` anchor now reaches EVERY + context lookup on the resolution path — context inference for a path-qualified basename + with NO explicit override, `_find_prompt_file`'s own candidate building and + path-qualified alignment (so a custom prompt root finds the existing prompt instead of + creating a duplicate), and the `construct_paths_basename` used for exact example/test + template expansion (so a `{category}` template no longer duplicates the context prefix, + e.g. `backend/examples/backend/foo_example.py`) — all resolved correctly from a + parent/sibling CWD. The unsafe-output-path containment check in ambiguity detection is + now deferred until after the cheap filename/stem match, so only matching rows are + filesystem-resolved (fixes an ~8x slowdown on large architectures). + Output-side anchoring and proven-ownership: a NEW module resolved from a parent/sibling + CWD now writes under the subproject — the missing-prompt `construct_paths` call receives + the prompt path as an anchoring hint and project-relative template outputs are anchored + at the project root — and the non-architecture `{category}` template branch no longer + duplicates the context prefix. Context-territory ownership is now applied only to + UNRESOLVED heuristic borrows: an architecture row whose physical prompt owner IS the + resolved prompt (a proven, explicit mapping) keeps its authoritative code target even + when it lies outside the context's own globs — as long as that target is not owned by a + sibling context — so an intentionally shared code path is honored while a stale + cross-context row is still rejected. + Output anchoring, absolute-path territory, and flat-hint context: the existing-prompt + template branch now anchors project-relative outputs at the subproject too (previously + only the missing-prompt branch did), re-anchoring only when the project root differs + from the CWD; context-territory matching re-expresses absolute `.pddrc` `paths`/output + values relative to the project before comparison, so a sibling context with an absolute + `generate_output_path` still owns its code and a stale flat row targeting it is rejected; + and a legacy FLAT architecture filename that matches the same leaf in two context + subdirectories now resolves the prompt in the REQUESTED context (not the + shallowest/lexicographic-first), keeping the prompt and code in the same context. + Exact-match and configured-root follow-up: exact/case-insensitive architecture + filename matches now enforce the same sibling-territory ownership check as every + other match branch; context prompt selection compares path components, so `backend` + cannot match `a-backend`; and contained absolute sibling `prompts_dir` roots now + participate in physical prompt ownership discovery. + Root-output and prompt-containment follow-up: sibling-context ownership now wins + even when the resolving context declares a repo-root (`./`) output; custom context + prefixes are derived relative to the active prompt root; and an escaping direct + prompt symlink rejected during discovery now raises a hard path-resolution error + instead of being reconstructed by the missing-prompt fallback. + Deeper custom-root and ownership handling: path-qualified new modules under a + broad custom root keep their configured context prefix; a sibling context's `./` + output no longer falsely claims every path and vetoes a valid current-context + architecture target; unsafe nested symlink-only prompt matches fail closed instead + of becoming new modules; and architecture rows share one parsed context-territory + snapshot per lookup, avoiding repeated `.pddrc` parsing on duplicate matches. + Final lexical/context isolation: user basenames are rejected lexically before any + filesystem path expression when absolute, traversal-bearing, backslash, or Windows + drive-qualified; nested missing modules compose both the custom context prefix and + remaining basename directories; unsafe recursive matches are filtered to the active + context before hard failure; and duplicate architecture rows memoize their ownership + decision while sibling-owned targets short-circuit before physical-owner walks. + Language/context alignment: caller language values must be one safe filename + component; explicit context selection is strict for both safe and unsafe recursive + candidates (a sibling prompt cannot be borrowed or mask an active-context escape); + and flat architecture hints must align with path-qualified basenames before a + recursive prompt can be paired with the canonical code target. + Direct/nested completion: the direct prompt fast path now obeys strict context + scope; an architecture helper never returns a direct join it already rejected for + path-qualified basename misalignment; and legacy missing-module artifacts defer + nested-directory insertion to the shared one-time reanchor, avoiding duplicated + paths such as `utils/utils/test_foo.py`. + Alignment/input completion: nested exact architecture filenames now retain the + path-qualified filepath alignment gate; nested prompt directory suffix comparison + is case-insensitive so Linux reuses existing casing; and path validators reject + NUL/control characters plus leading/trailing whitespace instead of validating a + stripped copy while callers continue with the original invalid value. + Direct/input finalization: explicit-context scope is checked lexically before a + direct candidate's containment walk, so an unsafe root-level sibling cannot block + backend creation; raw basename/language are logged only after validation; and + degenerate/noncanonical basenames (`.`, `foo/`, `foo/.`, duplicate separators) + are rejected rather than normalized into hidden or empty artifact names. + Portable-root determinism: `prompts_dir` is control/format-character validated + before resolution and logged with escaping; basename components reject NTFS ADS, + Windows device identities, and Unicode control/format characters; and case-folded + directory-index buckets are stably sorted so Linux case collisions do not depend + on raw `os.scandir` order. + Portable output parity: all Windows-invalid component characters (`<>:"|?*`) and + Unicode line/paragraph separators are rejected for caller prompt identities, and + architecture code filepaths now use the same portable component/device/control + checks before containment, so deceptive or device/ADS outputs cannot remain + authoritative. + Duplicate/log/device completion: unsafe output rows are rejected during candidate + eligibility so they cannot shadow a later valid duplicate; raw architecture + filepaths are INFO-logged only after validation and with escaped rendering; and + Windows superscript device aliases (`COM¹`–`COM³`, `LPT¹`–`LPT³`) are rejected. + Prompt hygiene: the governing `sync_determine_operation` prompt now states these + path-resolution obligations as stable observable `` (R1–R15) with a + `` map to the accumulated resolver tests, replacing the transcribed + implementation walkthrough per the prompting guide; the `metadata_sync` and + `sync_determine_operation` `.pdd/meta` fingerprints were refreshed so `pdd sync` + reports no drift. + Configured-output containment (R16): the same project-boundary containment applied + to `architecture.json` code filepaths now also gates every `.pddrc`-derived output + destination — `generate_output_path`, `example_output_path`/`test_output_path`, and + `outputs..path` templates. A configured code/example/test path that + resolves outside the project (parent traversal, escaping symlink, or an away-pointing + absolute path) fails closed with a path-resolution error, and path discovery no longer + creates a temporary probe file outside the tree — closing a gap where a `.pddrc` + could make `pdd sync` (even `--dry-run`) write outside the project while the + architecture filepath itself stayed contained. Configured `.pddrc` output values + now also get the same portable/canonical validation as architecture filepaths + (rejecting parent traversal, reserved device names, NTFS ADS colons, drive + markers, and control characters) on the RAW value before it is resolved, so a + normalized-away `..` or a non-portable destination fails closed independent of the + process CWD. Separately, `SyncLock` now builds its lock filename from a sanitized, + separator-free token of the basename/language — the lock is acquired before those + inputs are validated, so a traversal-bearing `language` can no longer create or + truncate a `.lock` file outside the locks directory. - **preprocess/content_selector**: parse comma-separated `select=` values without splitting `pytest:test_one,test_two`; index `async def` tests in `pytest_slicer`. - **checkup-gates**: close three additional runner-level vectors in the iter-39 deterministic-gate layer for `pdd checkup --pr --review-loop` (#1095, iter-40), each repro-confirmed. (1) Sanitize `PATH` in the gate subprocess env (drop empty entries, `.`, relative entries, and any entry that resolves inside the worktree) AND resolve every tool binary (`mypy` / `ruff` / `black` / `npm` / `pnpm` / `yarn` / `bun` / `npx`) to an absolute path via `shutil.which(tool, path=)` at discovery time. Stores the absolute path in `Gate.cmd` so `subprocess.run` cannot re-consult `PATH` for the top-level binary either; the env-side `PATH` sanitization remains as defence in depth for nested tool spawns by node/npm (e.g. `npm run` → shell → `prettier`). An operator with `PATH=.:$PATH` (or any worktree-resolving entry) could otherwise let a PR-shipped `./mypy` / `./npm` / `./npx` shim become the gate binary. (2) Skip all npm-family gates when the PR diff modifies `package.json` (root or any nested `*/package.json`). Corepack (default in Node 16+) reads the top-level `packageManager` field and fetches+execs the PR-selected version on first invocation — a PR-supplied `"packageManager": "pnpm@10.12.4+sha512.SOMEPRCONTROLLEDHASH"` turns the local gate into a registry download+exec of PR-chosen code. (3) Drop `exc_info=True` from the five DEBUG-level log calls in `pdd/checkup_review_loop.py` (two on the gate discover/run-gates crash paths, three on the optional list-drift-detection fallback paths). `traceback.format_exception` re-renders the raw exception text at DEBUG, bypassing the WARNING-line scrub above and leaking any token/path that surfaced in the exception message into DEBUG-captured log streams. - **checkup-gates**: close three additional runner-level vectors in the iter-38 deterministic-gate layer for `pdd checkup --pr --review-loop` (#1095, iter-39), each repro-confirmed. (1) Inject `COREPACK_ENABLE_AUTO_PIN=0` into the gate subprocess env so Corepack-managed yarn/pnpm cannot mutate `package.json` by auto-pinning a `packageManager` field on first run (Corepack 0.31 default behaviour; verified against `pnpm@latest`). (2) Extend the iter-38 mypy "pure package" plugin worktree-resolution probe to the `src/` layout — `worktree/src//` (with `__init__.py`, as a namespace dir, or as `.py`) also disables the gate, because editable installs of src-layout projects add `src/` to `sys.path`. (3) Strip the npm/Node config + import-path family from the gate subprocess env: every `NPM_CONFIG_*` / `npm_config_*` key (npm reads both case forms as independent overrides), `NODE_OPTIONS`, and `NODE_PATH`. Confirmed against npm 10.x: `NPM_CONFIG_SCRIPT_SHELL=./evil-sh npm run format:check` executes `./evil-sh -c "prettier --check ."` even when `.npmrc` is safe; `NODE_OPTIONS=--require=./evil.js` injects arbitrary JS into the `npx --no-install tsc` gate. diff --git a/pdd/prompts/agentic_arch_step13_fix_LLM.prompt b/pdd/prompts/agentic_arch_step13_fix_LLM.prompt index 9a6b472dec..0b38204738 100644 --- a/pdd/prompts/agentic_arch_step13_fix_LLM.prompt +++ b/pdd/prompts/agentic_arch_step13_fix_LLM.prompt @@ -236,13 +236,19 @@ This includes BOTH files AND metadata (architecture.json, .pddrc). Run these commands to identify problems: ```bash -# 1. Find prompt files in nested directories (WRONG - should be flat) -echo "=== Nested prompt files (WRONG) ===" +# 1. List prompt files in nested directories. +# Issue #617: nested prompts (e.g. prompts/app/, prompts/backend/) are CORRECT +# under Strategy B or a .pddrc `prompts_dir` context. Do NOT blanket-delete them; +# only relocate a prompt that does not match the project's chosen strategy. +echo "=== Nested prompt files (expected under Strategy B / a .pddrc prompts_dir) ===" find prompts -mindepth 2 -name "*.prompt" 2>/dev/null -# 2. Check architecture.json for slashes in filename field (WRONG) -echo "=== Filenames with slashes in architecture.json (WRONG) ===" -cat architecture.json | jq -r '.[].filename' | grep '/' || echo "None found" +# 2. Check architecture.json filenames MIRROR their filepath (Issue #617). +# A slash in `filename` is CORRECT when the `filepath` is nested. The problem is a +# FLATTENED filename (underscores, no slash) whose filepath has directories. +echo "=== architecture.json filename vs filepath (must mirror per Issue #617) ===" +# Handle BOTH formats: {"modules": [...]} and a bare [...] array via (.modules // .)[]. +cat architecture.json | jq -r '(.modules // .)[] | "\(.filename)\tfilepath=\(.filepath)"' 2>/dev/null || echo "None found" # 3. Check for orphan code files at wrong paths echo "=== Potential orphan code files ===" @@ -275,9 +281,12 @@ ls -la .pdd/backups/ 2>/dev/null **1. Delete prompt files at incorrect paths:** - **Strategy A (per-module):** Prompts should be FLAT in `prompts/`. Delete nested ones: + **Strategy A (per-module):** Prompts are FLAT in `prompts/`. Delete nested ones ONLY + if the project uses Strategy A. Issue #617/#1971: nested prompts under a `.pddrc` + `prompts_dir` context (Strategy B) are CORRECT — never delete those. ```bash - # Remove incorrectly nested prompt files (Strategy A only) + # Remove incorrectly nested prompt files (Strategy A ONLY — never run if any + # .pddrc context defines a prompts_dir subdirectory that legitimately nests prompts) find prompts -mindepth 2 -name "*.prompt" -delete 2>/dev/null # Remove empty subdirectories @@ -447,7 +456,8 @@ ls -la .pdd/backups/ 2>/dev/null rm -f *_preprocessed.prompt 2>/dev/null rm -f pdd/*_preprocessed.prompt 2>/dev/null - # If using nested prompt directories (WRONG), remove preprocessed files there too + # Remove stale preprocessed files under nested prompt directories too + # (the nested prompts themselves are fine — only *_preprocessed.prompt are removed) find prompts -name "*_preprocessed.prompt" -delete 2>/dev/null ``` @@ -481,9 +491,9 @@ Before applying validation fixes, verify: 3. [ ] **Preprocessed files**: No stale `*_preprocessed.prompt` files with old basenames **Metadata - architecture.json:** -4. [ ] **Filenames**: ALL use underscores, NO SLASHES -5. [ ] **Filenames**: ALL follow `{{basename}}_{{Language}}.prompt` pattern -6. [ ] **Dependencies**: ALL reference corrected filenames (no slashes) +4. [ ] **Filenames mirror filepath (Issue #617)**: each `filename` shares its `filepath` directory structure using forward slashes (e.g. `app/page_TypeScriptReact.prompt` for `app/page.tsx`) — do NOT flatten a nested filepath to an underscore name +5. [ ] **Filenames**: leaf follows `{{basename}}_{{Language}}.prompt` pattern +6. [ ] **Dependencies**: ALL reference the corrected path-mirroring filenames **Metadata - .pddrc (Strategy A — per-module exact paths):** 7. [ ] **Contexts**: Each module has dedicated context with `paths: ["*basename*"]` @@ -517,7 +527,7 @@ Before fixing the validation error, clean up ALL incorrectly placed artifacts: # A. Find and list problems echo "=== Checking for flattened filenames in architecture.json (WRONG per Issue #617) ===" # Filenames should mirror filepath (contain /). Flat names like prisma_schema_Prisma.prompt are wrong. -cat architecture.json | jq -r '.[] | "\(.filename) | \(.filepath)"' | while IFS='|' read -r fn fp; do +cat architecture.json | jq -r '(.modules // .)[] | "\(.filename) | \(.filepath)"' | while IFS='|' read -r fn fp; do fn="${fn%% *}"; fp="${fp%% *}"; # If filepath has / but filename does not, it's flattened (wrong) if [[ "$fp" == */* && "$fn" != */* ]]; then echo "FLATTENED (fix): $fn -> should mirror $fp"; fi; diff --git a/pdd/prompts/sync_determine_operation_python.prompt b/pdd/prompts/sync_determine_operation_python.prompt index 4e4d10de72..0689c7dac2 100644 --- a/pdd/prompts/sync_determine_operation_python.prompt +++ b/pdd/prompts/sync_determine_operation_python.prompt @@ -22,7 +22,7 @@ You are an expert Python developer. Your task is to implement the core decision- ### Requirements: 1. **Authoritative Fingerprints**: Compare current file SHA256 hashes against `.pdd/meta/{safe_basename}_{lang}.json`. Sanitize basenames containing `/` (e.g., `core/cloud` → `core_cloud`) for metadata filenames. When using sanitized basenames in glob patterns (e.g., in `_check_example_success_history`), apply `glob.escape()` so that special characters like `[`, `]`, `(`, `)` in basenames are treated as literals, not glob metacharacters. `get_meta_dir(project_root=None, paths=None)` resolves the meta directory in this order (issue #1211): explicit `project_root` → upward `.pddrc` from any file in `paths` (handles subproject .pddrc BELOW run CWD) → nearest `.pddrc` walking up from CWD → run CWD. `read_fingerprint(basename, language, paths=None)` and `read_run_report(basename, language, paths=None)` accept the same `paths` hint and forward it. `_perform_sync_analysis` resolves `_initial_paths = get_pdd_file_paths(...)` once at the top and threads them into the early `read_fingerprint` / `read_run_report` reads plus `_check_example_success_history`; `_is_workflow_complete` threads its own `paths` argument into every read it makes. The prompt-and-derived-files-both-changed conflict branch resolves the deletion target via `get_meta_dir(paths=paths)` — NOT a bare `get_meta_dir()` — so the unlink happens in the same `.pdd/meta` the reads came from. Without that, the read sees a subproject fingerprint while the delete targets the parent CWD orphan, and the recursive `_perform_sync_analysis` call re-enters with the same stale state (infinite loop / repeated decisions). This ensures sync decisions read from, and reset against, the subproject's `.pdd/meta` rather than an orphan one under a parent CWD. -2. **Robust Locking**: `SyncLock` context manager using `fcntl`/`msvcrt` file-descriptor locking. Handle re-entrancy (same PID), stale locks (`psutil.pid_exists`), and cleanup on failure. `log_mode=True` bypasses locking entirely and implies read-only analysis. +2. **Robust Locking**: `SyncLock` context manager using `fcntl`/`msvcrt` file-descriptor locking. Handle re-entrancy (same PID), stale locks (`psutil.pid_exists`), and cleanup on failure. `log_mode=True` bypasses locking entirely and implies read-only analysis. The lock is acquired from raw `basename`/`language` BEFORE `get_pdd_file_paths` validates them; the observable confinement obligation is R17. 3. **Deterministic Decision Priority** (strict ordering): 1. Auto-deps completion → always `generate` after `auto-deps` (prevents infinite loop). 2. Prompt changes → always take priority over runtime signals; return `auto-deps` (if dependency tags found) or `generate`. @@ -37,7 +37,7 @@ You are an expert Python developer. Your task is to implement the core decision- 7. Conflict: prompt + derived files both changed → return `fail_and_request_manual_merge` with an explicit conflict reason and details that identify the changed artifacts. Preserve the existing fingerprint and run-report in both mutable and read-only/log analysis; do not delete metadata, re-run fresh analysis, or choose either the prompt or derived artifact as the winner. Derived-only changes → `verify` or `test` (not a conflict). 4. **Skip Flags**: `skip_tests` and `skip_verify` parameters flow through `sync_determine_operation`, `_is_workflow_complete`, and `_handle_missing_expected_files`. When `skip_tests=True`, low coverage returns `all_synced` instead of `test`/`test_extend`. When both `skip_tests=True` and `skip_verify=True`, workflow completion requires only the prompt/code/example file set and must not require run reports, test reports, or verification fingerprints. Stale or failing cached test-results from `run_report` must be ignored (do not recommend `fix` or `crash`) when `skip_tests` is active. 5. **Context Override**: All path-resolving functions accept `context_override` to select a specific `.pddrc` context. `_relative_basename_for_context` strips context-specific prefixes from basenames for template expansion. For prompts_dir values, extract the prefix after the 'prompts/' segment wherever it appears in the path (e.g., 'prompts/frontend' → 'frontend', 'extensions/app/prompts/frontend' → 'frontend'). Use `_extract_prefix_from_prompts_dir()` from `construct_paths` for this. -6. **Path Resolution (Issue #237, #1169, #1303)**: Use `construct_paths` for configuration-aware pathing. If `.pddrc` has an `outputs` template section, use `_generate_paths_from_templates`. `_resolve_prompts_root` resolves prompts_dir relative to `.pddrc` location. Case-insensitive prompt file lookups must return the actual on-disk filename casing even when a lowercased candidate path reports `exists()` on case-insensitive filesystems. When the constructed prompt path does not exist, `_find_prompt_file` searches recursively through `prompts_root` subdirectories for a case-insensitive filename match (both basename and language suffix are case-insensitive). When multiple nested files share the basename, `context_override` scopes to the correct `.pddrc` context subdirectory, then basename directory hints disambiguate, then ties are broken by shallowest path-part count then lexicographic order. This ensures prompts in nested directories (e.g., `prompts/src/clients/firestore_client_Python.prompt`) are found even when `get_pdd_file_paths` is called with a flat basename and lowercase language. `_resolve_prompt_path_from_architecture` also performs a recursive case-insensitive search when its naively-joined path does not exist on disk. When architecture.json supplies a `filepath`, use the basename-resolved `.pddrc` context for example/test directories rather than cwd/default context, so repos with context-specific examples (e.g., `context/`, `context/commands/`) do not drift to `examples/`. When the prompt file does not exist yet (new module creation), `.pddrc` context's `prompts_dir` prefix is applied to the fallback path. +6. **Path Resolution (Issue #237, #1169, #1303, #1976)**: The observable safety and context-isolation obligations for resolving a unit's prompt/code/example/test paths — across `.pddrc` contexts, `architecture.json`, nested/custom prompt roots, and template `outputs` — are the stable contract rules R1-R17 in ``, enforced by the tests in ``. Implementation strategy is unconstrained beyond satisfying those rules. 7. **Dependency Awareness (Issue #522)**: Detect `` tags — including attributed forms emitted by `auto_include` (e.g. `file`, `file`) — and backtick-wrapped `` tags. The include-extraction regex must match any attribute permutation on the opening tag (use a pattern equivalent to `]*>(.*?)`); a bare-only regex would silently drop attributed deps from the fingerprint and let real dependency changes go undetected. Prompt hash is composite of prompt + dependency content. When auto-deps strips tags, use `stored_deps` from fingerprint to maintain hash continuity. 8. **Multi-file Test Support (Bug #156)**: `test_files` dict in `Fingerprint` and `RunReport` tracks `{filename: hash}` for all matching `test_{name}*.{ext}` files. Staleness checks compare all test file sets. 9. **Runtime Signal Validation**: @@ -50,6 +50,57 @@ You are an expert Python developer. Your task is to implement the core decision- 11. **Cost Estimation**: `estimate_operation_cost` returns dollar estimates per operation for budget tracking. 12. **Stale Run Report Handling**: Run reports without a fingerprint are ignored (orphaned). After `auto-deps`, existing run reports are stale and ignored (the early auto-deps check fires before run_report processing). + +- Resolved prompt: the on-disk prompt file that get_pdd_file_paths selects for the requested basename and language under the active .pddrc context. +- Context territory: the locations a .pddrc context claims — its `paths` globs plus its configured output paths. A target is "in a context's territory" when it matches one of them. A repo-root ("./") output claims no territory (it is non-owning). +- Sibling context: any .pddrc context other than the resolved prompt's own, EXCEPT non-owning contexts — the catch-all `default` context and any context whose only claim is a repo-root ("./") output. A non-owning context claims no territory, so it can neither veto nor redirect another context's target. +- Proven-owner row: an architecture.json row whose filename names EXACTLY ONE physical prompt and that prompt is the resolved prompt (an unambiguous explicit prompt→code mapping). A row matched only by filename leaf or by code-filepath stem, or a flat/same-leaf filename that matches distinct prompts in more than one context root, is a heuristic row, not a proven owner. +- Row that names this module: an architecture row whose filename equals the requested module's prompt filename (exact, case-insensitive), as opposed to a foreign row matched only by filename leaf or code-filepath stem. +- Unowned / shared target: an output filepath that lies in NO named (non-`default`) context's territory — a repo-root or deliberately cross-cutting path that no context claims. +- Valid output / unsafe output: an architecture output filepath is valid when it passes the checks that apply to output filepaths — R7 (relative and contained: no absolute, parent-traversal, or resolved-symlink escape), R9 (portable components, which is where backslash / Windows drive-colon / device / control rejection lives), and R10 (canonical spelling); a filepath that fails any of them is unsafe. (R8 governs prompt paths, not outputs.) +- Project boundary (R16): the single trusted directory a resolved code/example/test output must live under — the governing .pddrc directory (else the architecture.json directory). Authority comes from PROVENANCE, never the process CWD: a parent/sibling-CWD run does not widen the boundary; CWD-anchored outputs are re-anchored under this root before the check, and only a tree with neither .pddrc nor architecture.json falls back to the config/CWD anchor. An output is "inside the boundary" when it resolves within this root AND every component below it is portable (R9); the raw configured value is additionally rejected for parent traversal and non-canonical spelling before it is resolved. + + + +R1 (MUST): A SUPPORTED architecture.json is a JSON array of module objects OR a JSON object whose optional `modules` key is such an array (an object without `modules` is a legitimately empty registry); anything else present at the path — a top-level scalar, a non-array `modules`, or unreadable/invalid JSON — is malformed. When architecture.json maps the resolved module to a code filepath, that filepath determines the returned code filename; when it includes a directory that directory is authoritative too, and when it is a bare filename (no directory) the directory comes from the configured .pddrc generate_output_path (or context default). The .pddrc outputs.example.path / outputs.test.path templates remain authoritative for the example and test paths. A discovered architecture.json that is present but unreadable or malformed fails closed with a path-resolution error rather than being silently downgraded to an empty registry and resolved at convention fallback paths. +R2 (MUST): The architecture hint that discovers the prompt MUST select a prompt aligned with the resolving context and the (possibly path-qualified) basename, so the prompt and code resolve under the SAME context; a hint that would return a wrong-context or basename-misaligned prompt is rejected in favor of context-scoped discovery. (Sibling-territory ownership of the code target itself is R5/R6.) +R3 (MUST): Resolve .pddrc contexts and architecture.json relative to the project root (the directory containing architecture.json), independent of the process working directory. +R4 (MUST): Prompt discovery matches basename and language case-insensitively, returns the actual on-disk casing, and is deterministic regardless of directory scan order. +R5 (MUST NOT): Borrow a heuristic row when its target lies in a sibling context's territory, OR when that territory cannot be determined — the governing .pddrc is present but unparseable, or the resolving context cannot be established (no override and the basename does not encode one) and the target lies in some named context. A heuristic borrow that cannot be confined is denied (fail closed); a row that names this module, a proven explicit mapping, and a genuinely unowned/shared target are still honored. +R6 (MUST): A proven-owner row — or a row that names this module, even when its prompt does not exist yet (new module) — keeps its code target, including an unowned/shared target outside the resolved prompt's own context territory, UNLESS the target — by its resolved on-disk location, so an in-project symlink cannot disguise it — lies in a sibling context's territory. +R7 (MUST): Reject absolute or parent-traversal paths in architecture code filepaths, architecture prompt filenames, caller basenames, and language. The two failure modes differ: unsafe ARCHITECTURE metadata (code filepath / prompt filename) is discarded — the offending value is never used as a generation target — and resolution proceeds using the configured output paths; an unsafe CALLER basename or language instead fails closed, raising a path-resolution error. (Discarding an unsafe row means resolution continues with the other valid architecture rows; the configured output paths are used only when no valid architecture row remains.) An architecture code filepath that RESOLVES (after following symlinks) outside the project root is unsafe metadata too and is likewise discarded before generation. (Backslash, Windows drive/colon, reserved-device, and control-character rejection is R9's concern, not R7's. A caller-supplied prompts_dir MAY be absolute — a custom/nested prompt root. It is accepted unless it is empty, is non-string, has surrounding whitespace, or contains Unicode control/format/line-separator characters; absoluteness is NOT a rejection reason. R14 governs only that its raw value is not logged before this validation.) +R8 (MUST): Every returned prompt path MUST resolve inside the prompts root OR, when it comes from a configured `outputs.prompt.path` (Issue #237), anywhere inside the governing project root; a prompt that escapes BOTH (a foreign absolute path or a parent-traversal escape) is never returned. A relative configured prompt path is anchored at the governing project root, not the process CWD. A DISCOVERED prompt (from prompt-file discovery, not an outputs template) resolves inside the prompts root, with ONE exception: an APPROVED canonical alias — a prompt symlink whose target escapes the prompts root but stays inside the enclosing git repository (#1991 canonical-sync) — is honored by its lexical alias identity, using the SAME containment predicate for the direct, architecture-hint, and recursive discovery paths so an alias resolves consistently no matter which finds it. A symlink whose target leaves the repository (or a non-repository tree) is never returned — an escaping candidate found during discovery is discarded and resolution continues with the remaining candidates or the configured fallback (an in-root alias is preserved). But when the resolving context's OWN expected prompt can be resolved only outside the root, resolution fails closed with a path-resolution error rather than returning the escaping path or downgrading the unit to a new module. +R9 (MUST): Reject non-portable path components in caller basenames, language, architecture filenames, and architecture output filepaths — Windows-invalid characters (< > : " \ | ? *), reserved device names (CON, PRN, AUX, NUL, COM1-COM9/COM¹-COM³, LPT1-LPT9/LPT¹-LPT³, with or without extension), NTFS alternate-data-stream colons, trailing dot or space, and Unicode control/format/surrogate/line-separator characters. +R10 (MUST): Reject degenerate or non-canonical basenames, architecture prompt filenames, AND architecture output filepaths whose normalized POSIX identity differs from the input or yields no path components (e.g. ".", "./foo", "foo/", "src/./foo", duplicated separators). +R11 (MUST): A basename that architecture.json maps to two or more distinct valid outputs (R7, R9, R10) MUST raise AmbiguousModuleError (a ValueError subclass) rather than returning any resolved path or generating for that basename — a BARE basename when two rows map it to distinct outputs, and a PATH-QUALIFIED basename when two rows' distinct outputs both path-suffix-align with it. Unsafe rows (invalid filename OR invalid output) are excluded from that count, so an unsafe row neither blocks a valid module nor is chosen ahead of a later valid row for the same basename. +R12 (MUST): Within a single resolution the returned prompt and code path MUST come from the same version of architecture.json; a concurrent atomic rewrite of architecture.json part-way through the resolution MUST NOT yield a prompt drawn from the old version paired with a code path from the new one (a torn pair). +R13 (MUST NOT): Treat a null or non-string architecture filename as an error; treat it as absent and identify the module by its code filepath stem. +R14 (MUST NOT): Emit any log record containing a raw, unvalidated caller basename, language, prompts_dir, or architecture path value; such a value is logged only after it has passed validation, and no logged value may let control, newline, or ANSI content forge or split a log record (however that is achieved). +R15 (MUST): For a not-yet-existing prompt (new module), construct prompt/code/example/test paths under the resolved subproject context, applying the context prefix exactly once with no duplicated context segment. +R17 (MUST): For any `basename`/`language` — including a traversal-, separator-, or drive-bearing `language` supplied before validation — the `SyncLock` file MUST resolve inside the locks directory, and acquiring/releasing it MUST NOT create, truncate, or delete any file outside that directory. (Observable outcome; the sanitisation mechanism is an implementation detail.) +R16 (MUST): A returned code/example/test output filepath MUST resolve inside the resolution's project boundary regardless of whether the destination came from architecture.json OR from .pddrc configuration (generate_output_path, example_output_path/test_output_path, or an outputs..path template). A configured .pddrc output value (a directory, a template, or an explicit absolute destination) MUST be rejected with a path-resolution error — the same outcome regardless of the process CWD — when it would place an output through parent traversal (even one whose normalized destination lands back inside the project), through non-portable components (the R9 set: Windows-invalid characters, reserved device names, NTFS ADS colons, drive markers, trailing dot/space), through control/format/line-separator characters, or at an explicit absolute path outside the project boundary. A configured output that resolves outside every project boundary (parent traversal, an escaping symlink, or an away-pointing absolute path) likewise fails closed, and resolution MUST NOT create or write any file or directory outside the boundary while discovering paths — not even a temporary probe, and not even in read-only/dry-run analysis. A configured output that is portable and stays inside the boundary is honored unchanged. + + + +R1: test_get_pdd_file_paths_architecture_filepath_uses_basename_context, test_all_three_output_paths_applied_symmetrically_in_arch_branch, test_generate_output_path_honored_when_arch_filepath_is_bare, test_get_pdd_file_paths_malformed_architecture_json_fails_closed, test_get_pdd_file_paths_empty_registry_dict_is_not_malformed, test_get_pdd_file_paths_external_absolute_prompt_root_finds_project_architecture +R2: test_get_pdd_file_paths_flat_arch_hint_aligns_path_qualified_basename, test_get_pdd_file_paths_misaligned_direct_arch_join_is_not_returned, test_get_pdd_file_paths_nested_frontend_page_prefers_nested_prompt, test_get_pdd_file_paths_path_qualified_prefers_filename_path_match_over_bare_leaf +R3: test_get_pdd_file_paths_context_isolation_holds_from_parent_cwd, test_get_pdd_file_paths_context_inferred_from_parent_cwd_without_override, test_get_pdd_file_paths_broad_root_context_selection_from_parent_cwd +R4: test_get_pdd_file_paths_nested_directories_match_case_insensitively, test_directory_index_case_collision_fallback_is_deterministic, test_case_insensitive_path_lookup_returns_on_disk_casing_when_alias_exists +R5: test_get_pdd_file_paths_malformed_pddrc_denies_heuristic_borrow, test_get_pdd_file_paths_no_override_none_context_denies_foreign_sibling_borrow, test_get_pdd_file_paths_does_not_borrow_sibling_context_architecture_entry, test_get_pdd_file_paths_does_not_borrow_nested_same_leaf_architecture_entry, test_get_pdd_file_paths_flat_legacy_row_respects_context_territory, test_get_pdd_file_paths_exact_flat_row_respects_sibling_territory, test_get_pdd_file_paths_does_not_borrow_stale_sibling_context_entry, test_filepath_matches_context_handles_windows_drive_config, test_filepath_matches_context_normalizes_dot_slash_glob +R6: test_get_pdd_file_paths_proven_owner_honored_for_shared_target, test_get_pdd_file_paths_exact_missing_prompt_row_shared_target_honored, test_get_pdd_file_paths_proven_owner_still_rejects_sibling_context_target, test_get_pdd_file_paths_flat_same_leaf_in_two_roots_not_proven, test_get_pdd_file_paths_rejects_symlinked_code_path_into_sibling_context, test_get_pdd_file_paths_sibling_root_output_does_not_veto_current_target, test_get_pdd_file_paths_repo_root_output_still_rejects_sibling_target +R7: test_get_pdd_file_paths_rejects_unsafe_architecture_filepath, test_get_pdd_file_paths_rejects_unsafe_architecture_filename, test_get_pdd_file_paths_rejects_symlink_architecture_escape, test_safe_architecture_prompt_filename_rejects_windows_drive, test_contained_architecture_code_path_rejects_windows_drive, test_get_pdd_file_paths_rejects_missing_basename_traversal, test_get_pdd_file_paths_rejects_language_traversal +R8: test_get_pdd_file_paths_unaligned_escaping_symlink_does_not_block_qualified_creation, test_get_pdd_file_paths_auxiliary_root_symlink_escape_does_not_invalidate_active_owner, test_get_pdd_file_paths_rejects_symlink_prompt_discovery_escape, test_find_prompt_file_direct_fast_path_rejects_escaping_symlink, test_find_prompt_file_preserves_in_root_symlink_alias, test_get_pdd_file_paths_nested_escaping_symlink_is_hard_failure, test_get_pdd_file_paths_does_not_reconstruct_escaping_direct_symlink, test_get_pdd_file_paths_rejects_outputs_prompt_path_escape, test_get_pdd_file_paths_outputs_prompt_path_anchored_from_parent_cwd, test_get_pdd_file_paths_approved_alias_via_indirect_discovery +R9: test_contained_architecture_code_path_rejects_nonportable_components, test_get_pdd_file_paths_rejects_windows_device_or_ads_basename, test_get_pdd_file_paths_rejects_nonportable_language, test_get_pdd_file_paths_rejects_unsafe_architecture_filename +R10: test_get_pdd_file_paths_rejects_degenerate_basename, test_get_pdd_file_paths_rejects_noncanonical_or_control_input, test_get_pdd_file_paths_noncanonical_architecture_metadata_rejected_end_to_end, test_contained_architecture_code_path_rejects_noncanonical, test_safe_architecture_prompt_filename_rejects_noncanonical +R11: test_get_pdd_file_paths_bare_basename_two_valid_outputs_raise_ambiguous, test_get_pdd_file_paths_trailing_space_output_does_not_block_valid_row, test_get_pdd_file_paths_path_qualified_two_suffix_aligned_outputs_raise_ambiguous, test_get_pdd_file_paths_context_prefixed_qualified_ambiguity_uses_stripped_basename, test_get_pdd_file_paths_path_qualified_unsafe_filename_row_does_not_block, test_get_pdd_file_paths_qualified_foreign_named_suffix_row_no_false_ambiguity, test_get_pdd_file_paths_whitespace_filename_row_does_not_inflate_ambiguity, test_get_pdd_file_paths_case_variant_stem_outputs_raise_ambiguous, test_get_pdd_file_paths_unsafe_same_leaf_row_does_not_block_valid_module, test_get_pdd_file_paths_unsafe_filepath_row_does_not_block_valid_module, test_get_pdd_file_paths_unsafe_duplicate_does_not_shadow_valid_arch_row, test_get_pdd_file_paths_all_legacy_suffix_aligned_rows_still_ambiguous +R12: test_get_pdd_file_paths_no_torn_pair_on_concurrent_architecture_rewrite +R13: test_get_pdd_file_paths_null_filename_uses_architecture_filepath, test_get_pdd_file_paths_non_string_filename_uses_filepath +R14: test_get_pdd_file_paths_does_not_info_log_raw_unsafe_arch_filepath, test_get_pdd_file_paths_rejects_control_bearing_prompts_dir_before_logging, test_get_pdd_file_paths_validates_before_logging_raw_input, test_get_pdd_file_paths_validates_language_before_logging +R15: test_get_pdd_file_paths_missing_custom_module_keeps_context_root, test_get_pdd_file_paths_nested_missing_custom_module_keeps_context_root, test_get_pdd_file_paths_non_architecture_templates_not_duplicated_from_parent_cwd, test_get_pdd_file_paths_construct_paths_failure_anchors_fallback_at_subproject +R16: test_get_pdd_file_paths_rejects_pddrc_example_test_output_escape, test_get_pdd_file_paths_rejects_pddrc_generate_output_escape, test_get_pdd_file_paths_rejects_pddrc_outputs_template_escape, test_get_pdd_file_paths_non_arch_generate_escape_creates_nothing_out_of_tree, test_get_pdd_file_paths_pddrc_within_project_outputs_allowed, test_get_pdd_file_paths_rejects_nonportable_or_traversal_pddrc_output, test_get_pdd_file_paths_parent_cwd_sibling_output_stays_under_project, test_get_pdd_file_paths_nearer_pddrc_nonportable_output_fails_closed, test_get_pdd_file_paths_missing_prompt_escaping_output_not_swallowed, test_pdd_sync_dry_run_cli_resolves_nested_context, test_pdd_sync_cli_refuses_escaping_pddrc_output, test_get_pdd_file_paths_rejects_absolute_escape_pddrc_output, test_get_pdd_file_paths_rejects_control_component_pddrc_output, test_get_pdd_file_paths_rejects_nonstring_pddrc_output, test_get_pdd_file_paths_rejects_nonstring_outputs_template_path, test_get_pdd_file_paths_drops_escaping_test_file_symlink, test_get_pdd_file_paths_rejects_nearer_pddrc_normalized_traversal, test_get_pdd_file_paths_rejects_nonstring_prompts_dir_config, test_get_pdd_file_paths_rejects_absolute_outputs_template, test_get_pdd_file_paths_rejects_malformed_outputs_shape, test_reject_unsafe_pddrc_output_config_rejects_normalized_traversal, test_get_pdd_file_paths_rejects_pathless_outputs_entry, test_get_pdd_file_paths_rejects_null_outputs_entry, test_get_pdd_file_paths_rejects_empty_or_unresolved_template_expansion, test_get_pdd_file_paths_test_files_rebuilt_from_anchored_dir_parent_cwd, test_get_pdd_file_paths_nested_prompt_template_keeps_physical_category, test_get_pdd_file_paths_nested_prompt_template_category_from_parent_cwd, test_get_pdd_file_paths_rejects_unresolved_or_empty_prompt_template, test_get_pdd_file_paths_arch_branch_nested_category_example_test +R17: test_sync_lock_language_cannot_escape_locks_dir, test_sync_determine_operation_malicious_language_writes_nothing_out_of_tree + + ### Dependencies: @@ -78,7 +129,7 @@ You are an expert Python developer. Your task is to implement the core decision- 1. **Data Structures**: Define `Fingerprint` (with `test_files: Dict[str,str]`, `include_deps: Dict[str,str]`), `RunReport` (with `test_hash`, `test_files`), and `SyncDecision` (with `confidence`, `estimated_cost`, `details`, `prerequisites`) dataclasses. 2. **Locking**: `SyncLock` with `acquire()`/`release()`. On failure, clean up fd and lock file before re-raising. -3. **Pathing**: `get_pdd_file_paths` returns `{prompt, code, example, test, test_files}` using `construct_paths` with template-based and legacy fallback paths. `_extract_name_part` splits subdirectory basenames. `get_extension` maps language names to file extensions. Prompt file resolution delegates to `_find_prompt_file(basename, language, prompts_root, architecture_path, context_override)`, which runs a 4-step cascade (direct path → case-insensitive parent → architecture.json hint + recursive search → recursive glob fallback) and returns the actual on-disk path with correct casing. Both the basename and language suffix are matched case-insensitively. When multiple nested files match, `context_override` (via `.pddrc` prompts_dir) scopes to the correct context subdirectory, then basename directory hints disambiguate, then shallowest-path/lexicographic order breaks ties. `_case_insensitive_path_lookup` scans the parent directory before trusting `Path.exists()` so macOS-style case-insensitive aliases do not leak lowercased prompt paths into Git path comparisons. `_case_insensitive_prompt_lookup` in `sync_main` stays narrow-scope (same parent directory only) to preserve context isolation for `_find_prompt_in_contexts` (#1049); recursive subdirectory resolution lives in `_find_prompt_file` and `_resolve_prompt_path_from_architecture`. When architecture.json supplies a code filepath, derive example/test directories from the basename-resolved context, not from cwd/default context. When the prompt file does not exist yet (new module), `.pddrc` context prompts_dir prefix is applied to the fallback path. **Ambiguity & canonical resolution (issue #1677):** `get_pdd_file_paths` raises `AmbiguousModuleError` — a `ValueError` subclass whose message lists the conflicting targets — as soon as `architecture.json` maps a **bare** basename (no `/`) to ≥2 distinct outputs, BEFORE any prompt resolution or `.pddrc` fallback, so a short leaf name like `page` (many `page.tsx`) never silently resolves first-match-wins; path-qualified names (`app/login/page`) resolve canonically under their own directory. `_reanchor_under_basename_subdir` re-anchors an unregistered path-qualified module under its basename subdirectory WITHOUT duplicating a directory segment the output already provides, and an explicitly configured output path ending in `/` is treated as a complete directory used as-is (no double-pathing). Subclassing `ValueError` lets best-effort callers (operation logging, drift heal, evidence/checkup gates) degrade via their broad `except`, while `_perform_sync_analysis`, `sync_orchestration` (dry-run and main paths), and the `sync_main` per-language loop re-raise it so the `sync` command fails fast with a clear "use one of …" message and generates nothing. +3. **Pathing**: `get_pdd_file_paths(basename, language, prompts_dir, context_override)` returns `{prompt, code, example, test, test_files}`, applying configured `outputs` templates with a legacy-convention fallback. (Its resolution/safety/context-isolation contract is Requirement 6 / R1-R17; not restated here.) 4. **Hashing**: `calculate_prompt_hash` builds composite hash from prompt + resolved include deps. `extract_include_deps` finds and hashes all `` references — bare *and* attributed (`select=`, `query=`, etc.) — so `auto_include`-emitted attributed deps still feed the fingerprint. `calculate_current_hashes` handles `test_files` (Bug #156) and `include_deps` (Issue #522) specially. 5. **Decision Logic (`sync_determine_operation`)**: Accepts `basename, language, target_coverage, budget, log_mode, prompts_dir, skip_tests, skip_verify, context_override, read_only`, plus an optional isolated replay/repair indicator consumed by sync orchestration. Delegates to `_perform_sync_analysis` (with or without lock). Follow the priority order in Requirement 3. 6. **Isolated replay/repair mode:** Preserve the default missing-file priority for ordinary full sync. When the caller marks the analysis as an isolated generation replay or code repair, missing or stale example files must not preempt `generate`, `verify`, or `fix` unless the requested operation explicitly requires example regeneration. Include a decision reason explaining that example generation was skipped for isolated replay/repair so dry-run output and operation logs are auditable. diff --git a/pdd/sync_determine_operation.py b/pdd/sync_determine_operation.py index d8e82f10af..a02e0d9931 100644 --- a/pdd/sync_determine_operation.py +++ b/pdd/sync_determine_operation.py @@ -15,7 +15,10 @@ import stat import subprocess import fnmatch -from pathlib import Path, PurePosixPath +import unicodedata +from collections import deque +from functools import lru_cache +from pathlib import Path, PurePosixPath, PureWindowsPath from dataclasses import dataclass, field from typing import Dict, List, Optional, Any, Tuple from datetime import datetime @@ -85,7 +88,7 @@ def get_locks_dir(): # Export constants for other modules __all__ = ['PDD_DIR', 'META_DIR', 'LOCKS_DIR', 'Fingerprint', 'RunReport', 'SyncDecision', 'sync_determine_operation', 'analyze_conflict_with_llm', 'read_run_report', 'get_pdd_file_paths', - '_check_example_success_history', 'AmbiguousModuleError'] + '_check_example_success_history', 'AmbiguousModuleError', 'UnsafePromptPathError'] class AmbiguousModuleError(ValueError): @@ -117,6 +120,69 @@ def __init__(self, basename: str, language: str, choices: List[str]): ) +class UnsafePromptPathError(AmbiguousModuleError): + """Raised when a prompt candidate resolves outside its configured root. + + This subclasses the existing hard path-resolution error so every sync entry + point that already propagates :class:`AmbiguousModuleError` also fails closed + before generation can write through an escaping symlink. + """ + + def __init__(self, prompt_path: Path, prompts_root: Path): + self.prompt_path = prompt_path + self.prompts_root = prompts_root + ValueError.__init__( + self, + f"Unsafe prompt path '{prompt_path}' resolves outside prompts root " + f"'{prompts_root}'", + ) + + +class MalformedArchitectureError(AmbiguousModuleError): + """Raised when a DISCOVERED architecture.json exists but cannot be read/parsed. + + Subclasses the hard path-resolution error so every sync entry point that already + propagates :class:`AmbiguousModuleError` fails closed rather than silently resolving + at convention fallback paths — which can mis-target the authoritative registered + code file instead of the one the (present but broken) registry intended. + """ + + def __init__(self, architecture_path: Path, reason: object): + self.architecture_path = architecture_path + ValueError.__init__( + self, + f"architecture.json at '{architecture_path}' is present but could not be " + f"read/parsed ({reason}); fix or remove it — refusing to resolve at " + f"convention fallback paths.", + ) + + +class UnsafeOutputPathError(AmbiguousModuleError): + """Raised when a resolved code/example/test output escapes the project root. + + Architecture code filepaths are already contained by + :func:`_contained_architecture_code_path` (R7), but the *destination* of an + output can also come from ``.pddrc`` configuration — ``generate_output_path``, + ``example_output_path``/``test_output_path``, and ``outputs.*.path`` templates. + A configured value with parent traversal, an escaping symlink, or an absolute + path pointing away from the project would otherwise let sync (or even dry-run + path discovery) create/write files outside the project tree. Subclassing the + hard path-resolution error means every sync entry point that already fails + closed on :class:`AmbiguousModuleError` also refuses an out-of-tree output, + while best-effort callers (logging, drift heal, checkup) degrade gracefully. + """ + + def __init__(self, output_path: object, project_root: object, artifact: str): + self.output_path = output_path + self.project_root = project_root + self.artifact = artifact + ValueError.__init__( + self, + f"Unsafe {artifact} output path '{output_path}' resolves outside " + f"project root '{project_root}'", + ) + + def _safe_basename(basename: str) -> str: """Sanitize basename for use in metadata filenames. @@ -126,6 +192,22 @@ def _safe_basename(basename: str) -> str: return basename.replace('/', '_') +def _safe_lock_component(value: Any) -> str: + """Collapse a lock-name component to a portable, separator-free token. + + Lock filenames are built from the caller basename and language BEFORE + :func:`get_pdd_file_paths` validates those inputs (the lock is acquired + first). A traversal- or separator-bearing ``language`` (e.g. + ``/../../../tmp/victim``) would otherwise interpolate into the lock path and + let ``SyncLock`` mkdir/touch/unlink an out-of-tree ``.lock`` file. Replacing + every character outside ``[A-Za-z0-9._-]`` with ``_`` yields a flat token + that cannot contain a path separator, ``:``, or drive marker, so the lock + file is always confined to the locks directory. Valid identifiers + (``python``, ``core/cloud`` -> ``core_cloud``) are unaffected. + """ + return re.sub(r"[^A-Za-z0-9._-]", "_", str(value)) + + def is_test_extend_disabled() -> bool: """Return True when coverage-driven ``test_extend`` is opted out via env. @@ -265,18 +347,14 @@ def _find_architecture_json(start_path: Optional[Path] = None) -> Optional[Path] return None -def _resolve_prompt_path_from_architecture(prompts_root: Path, architecture_filename: str) -> Path: - """Build a prompt path from architecture.json without duplicating subdirectories. - - Issue #1169: If the naively-joined path does not exist on disk, search - recursively under prompts_root for a case-insensitive filename match. - Handles the common case where architecture.json stores just the filename - (e.g. "firestore_client_Python.prompt") while the file lives in a nested - subdirectory (e.g. prompts/src/clients/). - """ - arch_path = Path(architecture_filename) - if arch_path.is_absolute(): - return arch_path +def _join_prompt_path_from_architecture( + prompts_root: Path, + architecture_filename: str, +) -> Optional[Path]: + """Join an architecture prompt name without duplicating root segments.""" + arch_path = _safe_architecture_prompt_filename(architecture_filename) + if arch_path is None: + return None arch_parts = arch_path.parts root_parts = prompts_root.parts @@ -288,9 +366,50 @@ def _resolve_prompt_path_from_architecture(prompts_root: Path, architecture_file overlap = candidate break - joined = prompts_root.joinpath(*arch_parts[overlap:]) - resolved_joined = _case_insensitive_path_lookup(joined) - if resolved_joined: + return prompts_root.joinpath(*arch_parts[overlap:]) + + +def _resolve_prompt_path_from_architecture( + prompts_root: Path, + architecture_filename: str, + context_prefix: Optional[str] = None, + basename: Optional[str] = None, +) -> Optional[Path]: + """Build a prompt path from architecture.json without duplicating subdirectories. + + Issue #1169: If the directly joined path does not exist on disk, search + recursively under prompts_root for a case-insensitive filename match. + Handles the common case where architecture.json stores just the filename + (e.g. "firestore_client_Python.prompt") while the file lives in a nested + subdirectory (e.g. prompts/src/clients/). + + When a legacy FLAT architecture filename matches the same leaf in more than one + context subdirectory, ``context_prefix`` (from the resolving ``.pddrc`` context) + selects the correct context's prompt instead of the shallowest/lexicographic + first — otherwise the hint returns the wrong context's prompt while code resolves + under the requested one (a torn cross-context pair). + """ + safe_filename = _safe_architecture_prompt_filename(architecture_filename) + if safe_filename is None: + return None + joined = _join_prompt_path_from_architecture(prompts_root, architecture_filename) + if joined is None: + return None + relative_parts = _prompt_relative_parts_for_root(prompts_root, safe_filename) + resolved_joined, contained = _walk_prompt_relative_path( + prompts_root, + relative_parts, + ) + if not contained: + return None + if ( + resolved_joined + and (basename is None or _prompt_candidate_aligns_basename(resolved_joined, basename)) + and ( + not context_prefix + or _prompt_path_has_context_prefix(resolved_joined, prompts_root, context_prefix) + ) + ): return resolved_joined # Recursive search for the filename under prompts_root. Collect all matches @@ -298,14 +417,55 @@ def _resolve_prompt_path_from_architecture(prompts_root: Path, architecture_file # filesystem ordering when multiple nested files share the basename. if prompts_root.is_dir(): target_lower = Path(architecture_filename).name.lower() - matches = [ - c for c in prompts_root.rglob("*.prompt") - if c.is_file() and c.name.lower() == target_lower - ] + resolved_root = prompts_root.resolve(strict=False) + matches = [] + unsafe_matches = [] + for candidate in prompts_root.rglob("*.prompt"): + if not candidate.is_file() or candidate.name.lower() != target_lower: + continue + if basename is not None and not _prompt_candidate_aligns_basename( + candidate, basename + ): + continue + # Alias-aware containment (R12 F1): use the SAME predicate as direct and + # architecture-hint discovery so an APPROVED in-repo prompt alias — a + # symlink escaping the prompts root but staying inside the enclosing git + # repository — is honoured here too. A symlink leaving the repository (or + # a non-repository tree) still fails and is treated as unsafe (R8). + if _prompt_candidate_within_root(candidate, resolved_root, prompts_root): + matches.append(candidate) + else: + unsafe_matches.append(candidate) + if matches and context_prefix: + matches = [ + m for m in matches + if _prompt_path_has_context_prefix(m, prompts_root, context_prefix) + ] if matches: matches.sort(key=lambda p: (len(p.parts), str(p))) return matches[0] + if unsafe_matches: + relevant_unsafe = unsafe_matches + if context_prefix: + relevant_unsafe = [ + candidate for candidate in unsafe_matches + if _prompt_path_has_context_prefix( + candidate, prompts_root, context_prefix + ) + ] + if relevant_unsafe: + raise UnsafePromptPathError(relevant_unsafe[0], resolved_root) + if basename is not None and not _prompt_candidate_aligns_basename(joined, basename): + return None + # A context prefix scopes EVERY architecture-hint return, not only the recursive + # matches: a flat/lexical join that lacks the resolving context's prefix must not + # be returned (it would pair a wrong-context prompt with the requested context's + # code). Fall through to the caller's context-anchored construction instead. + if context_prefix and not _prompt_path_has_context_prefix( + joined, prompts_root, context_prefix + ): + return None return joined @@ -328,18 +488,681 @@ def _case_insensitive_path_lookup(candidate: Path) -> Optional[Path]: return None +# Git environment variables that REDIRECT which repository/worktree Git operates on. +# A caller-inherited GIT_WORK_TREE=/ (or a GIT_DIR pointing at a foreign repository) +# would otherwise make `rev-parse --show-toplevel` report an attacker-chosen root, so +# an escaping symlink would be judged repository-contained (R13 F1). They are stripped +# from the subprocess environment so worktree authority derives ONLY from the queried +# directory's own on-disk `.git`. +_GIT_REPO_SELECTION_ENV = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + "GIT_PREFIX", + "GIT_NAMESPACE", +) + + +def _validated_git_worktree_root(directory: str) -> Optional[str]: + """Resolved worktree root reported by Git for ``directory``, else ``None``. + + Runs ``git -C rev-parse --show-toplevel`` so authority is granted + only by a *real* repository, never by an unvalidated ``.git`` marker: an empty + ``.git`` directory (or a planted ``.git`` file) makes ``rev-parse`` fail, so a + symlink escaping into such a tree is still rejected (R8, R12 F2). The reported + top-level is resolved (``os.path.realpath``) so the boundary is comparable to a + resolved alias target. + + Repository-selection environment variables (``GIT_DIR``/``GIT_WORK_TREE``/... , + see :data:`_GIT_REPO_SELECTION_ENV`) are stripped so a caller-inherited redirect + cannot make Git report a foreign/`/` root and thereby grant alias authority to an + escaping symlink (R13 F1). NOT cached: the query is off the normal path (only a + leaf-symlink root escape reaches it) and a per-process cache would keep granting + authority after a repository is removed or replaced mid-process, so each call + re-asks Git about the CURRENT on-disk state. + """ + env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SELECTION_ENV} + try: + result = subprocess.run( + ["git", "-C", directory, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + timeout=10, + env=env, + ) + except (OSError, ValueError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + top = result.stdout.strip() + if not top: + return None + try: + return os.path.realpath(top) + except (OSError, ValueError): + return None + + +def _enclosing_git_root(path: Any) -> Optional[Path]: + """Resolved root of the real Git worktree enclosing ``path``, else ``None``. + + Delegates to :func:`_validated_git_worktree_root`, which asks Git itself for the + worktree top-level rather than trusting a ``.git`` entry to exist. This lets an + APPROVED in-repo prompt alias (#1991: ``prompts/nested/foo`` symlinked to an + in-repository canonical location) be recognised as repository-internal, while a + planted/empty ``.git`` marker in a non-repository tree grants no authority + (R12 F2). Returns ``None`` for any directory Git does not report as a worktree, + so a plain escaping symlink outside a real repository is still rejected by R8. + + The *lexical* absolute of ``path`` seeds the query (the leaf symlink is never + followed), but the returned boundary is Git's resolved top-level. + """ + try: + current = Path(os.path.abspath(path)) + except (OSError, ValueError): + return None + if not current.is_dir(): + current = current.parent + top = _validated_git_worktree_root(str(current)) + if top is None: + return None + return Path(top) + + +def _lexical_path_within(child: str, parent: str) -> bool: + """Whether the absolute, already-resolved ``child`` is inside ``parent`` (or equal). + + Both operands are expected to be ``os.path.realpath`` results (absolute, symlink-free), + so this comparison is purely lexical and follows no further symlinks. + """ + try: + Path(child).relative_to(parent) + return True + except (ValueError, OSError): + return False + + +def _path_has_symlink(path: Any) -> bool: + """Cheap gate: whether any LEXICAL ancestor (or the leaf) of ``path`` is a symlink. + + Used to skip the (subprocess-backed) every-hop validation entirely for the common + case of a plain regular file with no symlinked components — where containment is + already established lexically. Only a genuine symlink triggers the full walk. + """ + try: + current = os.path.abspath(path) + except (OSError, ValueError): + return False + prev = None + guard = 0 + while current and current != prev and guard < 512: + guard += 1 + try: + if os.path.islink(current): + return True + except (OSError, ValueError): + return False + prev = current + current = os.path.dirname(current) + return False + + +def _split_path_anchor(p: str, pathmod: Any = None) -> Tuple[str, List[str]]: + """Split an absolute path into its (anchor, components), preserving the anchor. + + The anchor is the filesystem/drive/UNC root — ``/`` on POSIX, ``C:\\`` for a Windows + drive, ``\\\\server\\share\\`` for a UNC path (R17 F3). Splitting on ``os.sep`` alone + would drop a Windows ``C:`` drive letter (traversal would restart at a bare ``os.sep`` + and every drive-relative node would fail containment). ``splitdrive`` recovers the + drive/UNC prefix so the walk starts from the correct anchor. ``pathmod`` (defaulting to + ``os.path``) is injectable so the Windows behaviour is verifiable via ``ntpath`` on any + host. + """ + pm = pathmod or os.path + drive, rest = pm.splitdrive(p) + anchor = (drive + pm.sep) if drive else pm.sep + # Windows targets may use either separator; normalise both. + parts = [part for part in rest.replace("\\", "/").split("/") if part] + return anchor, parts + + +def _symlink_chain_within_root(path: Any, roots: Any) -> bool: + """Whether ``path`` resolves without EVER leaving the trusted ``roots`` at any hop. + + ``Path.resolve()``/``os.path.realpath`` expose only the FINAL target and COLLAPSE + intermediate directory-symlink hops, so a chain whose intermediate node leaves the + trusted tree — an in-tree symlink pointing at an EXTERNAL node that currently points + back in, possibly beneath the prompts root — would pass a terminal-only or + realpath-based check yet allow a later retarget to escape (R16 F2 / R17 F1 / TOCTOU). + This resolves ``path`` MANUALLY, one path component at a time, following each symlink + by re-queuing its target's components, and rejects the moment any physical node is + neither an ancestor of, nor inside, ANY trusted root. A legitimate in-tree alias never + has an out-of-tree hop and is unaffected. + + ``roots`` may be a single path or an iterable — used for the non-repository case, + where the trusted set is the LEXICAL prompts root together with its RESOLVED target so + a trusted top-level prompt-root symlink (``prompts -> pdd/prompts``) is still accepted + while an external intermediate is rejected. Drive/UNC anchors are preserved (R17 F3). + """ + if isinstance(roots, (str, Path)): + roots = (roots,) + root_norms: List[str] = [] + for r in roots: + if not r: + continue + try: + root_norms.append(os.path.normpath(str(r))) + except (OSError, ValueError): + continue + if not root_norms: + return False + try: + start = os.path.abspath(path) + except (OSError, ValueError): + return False + + def _node_ok(node: str) -> bool: + try: + node_n = os.path.normpath(node) + except (OSError, ValueError): + return False + for rn in root_norms: + # ON THE WAY to a root (an ancestor of it) or INSIDE it — never off to the + # side (a sibling subtree or an external dir). + if node_n == rn or _lexical_path_within(node_n, rn) or _lexical_path_within(rn, node_n): + return True + return False + + anchor, parts = _split_path_anchor(start) + queue: deque = deque(parts) + resolved = anchor + steps = 0 + while queue: + steps += 1 + if steps > 4096: # bound: breaks symlink loops / pathological chains + return False + comp = queue.popleft() + if comp == ".": + continue + if comp == "..": + resolved = os.path.dirname(resolved) or anchor + continue + node = os.path.join(resolved, comp) + if not _node_ok(node): + return False + try: + is_link = os.path.islink(node) + except (OSError, ValueError): + return False + if is_link: + try: + target = os.readlink(node) + except (OSError, ValueError): + return False + if os.path.isabs(target): + anchor, t_parts = _split_path_anchor(target) + resolved = anchor + queue.extendleft(reversed(t_parts)) + else: + # relative to `resolved` (the directory holding the link) + queue.extendleft( + reversed([part for part in target.replace("\\", "/").split("/") if part]) + ) + else: + resolved = node + return _node_ok(resolved) + + +def _hop_trust_roots( + lexical_root: Any, resolved_root: Any, repo: Optional[Path] +) -> List[str]: + """Trusted-root set for :func:`_symlink_chain_within_root` on a terminal-in-root prompt. + + When an enclosing Git worktree exists, the whole repository is the trusted boundary + (an approved in-repo alias may hop anywhere inside it). Otherwise (a NON-repository + project — R17 F1) the boundary is the prompts root itself, PLUS its lexical location, + so a trusted top-level prompt-root symlink (``prompts -> pdd/prompts``) is honoured + while an external intermediate hop is still rejected. Returning both the resolved and + lexical roots lets the walk accept the root's own redirect without opening the tree. + """ + if repo is not None: + try: + return [os.path.realpath(str(repo))] + except (OSError, ValueError): + return [] + roots: List[str] = [] + try: + roots.append(str(Path(resolved_root))) + except (OSError, ValueError): + pass + try: + roots.append(os.path.abspath(lexical_root)) + except (OSError, ValueError): + pass + return roots + + +def _find_named_file(parent: Path, filename: str) -> Optional[Path]: + """Find a filename by scanning a directory instead of joining an input leaf. + + An exact-cased match wins. Otherwise the case-insensitive fallback is chosen by + a stable ``(name, path)`` sort so a case-fold collision on a case-sensitive + filesystem (e.g. ``Foo_example.py`` beside ``FOO_example.py``) resolves the same + way regardless of directory iteration order. + """ + if not parent.is_dir(): + return None + target_lower = filename.lower() + fallback_matches: List[Path] = [] + for child in parent.iterdir(): + if not child.is_file(): + continue + if child.name == filename: + return child + if child.name.lower() == target_lower: + fallback_matches.append(child) + if not fallback_matches: + return None + return sorted(fallback_matches, key=lambda p: (p.name, str(p)))[0] + + +def _contains_disallowed_path_text(value: str) -> bool: + """Return whether text contains controls or Unicode line/format separators.""" + return any( + unicodedata.category(char) in {"Cc", "Cf", "Cs", "Zl", "Zp"} + for char in value + ) + + +def _unsafe_portable_path_component(part: str) -> bool: + """Return whether one POSIX component is invalid or special on Windows.""" + windows_identity = part.rstrip(" .").split(".", 1)[0].upper() + reserved_windows_names = { + "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", "CLOCK$", + } + return ( + any(char in '<>:"\\|?*' for char in part) + or part.endswith((" ", ".")) + or windows_identity in reserved_windows_names + or ( + len(windows_identity) == 4 + and windows_identity[:3] in {"COM", "LPT"} + and windows_identity[3] in "123456789¹²³" + ) + ) + + +def _safe_architecture_prompt_filename(value: Any) -> Optional[PurePosixPath]: + """Return one safe repository-relative architecture filename. + + Architecture filenames are metadata, not trusted filesystem paths. Prompt + discovery must never follow absolute paths, parent traversal, backslashes, + or empty values supplied by that metadata. + """ + if not isinstance(value, str): + return None + if value != value.strip(): + return None + if _contains_disallowed_path_text(value): + return None + raw = value + if not raw or "\\" in raw: + return None + # Windows drive-qualified metadata (e.g. "D:/x", "D:x") is relative to + # PurePosixPath but escapes the repository when joined on Windows, where a + # differing drive yields a drive-relative path outside prompts_root. Reject it. + if PureWindowsPath(raw).drive: + return None + filename = PurePosixPath(raw) + if ( + not filename.parts + or filename.as_posix() != raw + or filename.is_absolute() + or ".." in filename.parts + ): + return None + if any(_unsafe_portable_path_component(part) for part in filename.parts): + return None + return filename + + +def _safe_prompt_language(value: Any) -> Optional[str]: + """Return a language safe to interpolate as one prompt filename component.""" + safe = _safe_architecture_prompt_filename(value) + if ( + safe is None + or len(safe.parts) != 1 + or any(char.isspace() for char in safe.parts[0]) + ): + return None + return safe.parts[0] + + +@lru_cache(maxsize=512) +def _directory_entry_index( + directory: str, + modified_ns: int, +) -> Tuple[Dict[str, Tuple[Path, ...]], Dict[str, Tuple[Path, ...]]]: + """Index one directory; ``modified_ns`` invalidates add/remove/rename.""" + del modified_ns # Cache-key only. + directories: Dict[str, List[Path]] = {} + files: Dict[str, List[Path]] = {} + with os.scandir(directory) as entries: + for entry in entries: + path = Path(entry.path) + try: + if entry.is_dir(): + directories.setdefault(entry.name.lower(), []).append(path) + elif entry.is_file(): + files.setdefault(entry.name.lower(), []).append(path) + except OSError: + continue + def _stable(values: List[Path]) -> Tuple[Path, ...]: + return tuple( + sorted(values, key=lambda path: (path.name.casefold(), path.name, str(path))) + ) + + return ( + {key: _stable(value) for key, value in directories.items()}, + {key: _stable(value) for key, value in files.items()}, + ) + + +def _indexed_directory_child( + parent: Path, + name: str, + *, + directory: bool, +) -> Optional[Path]: + """Return an exact/case-insensitive child from a bounded cached index.""" + try: + stat = parent.stat() + directories, files = _directory_entry_index( + str(parent), + stat.st_mtime_ns, + ) + except (OSError, RuntimeError): + return None + matches = (directories if directory else files).get(name.lower(), ()) + if not matches: + return None + return next((match for match in matches if match.name == name), matches[0]) + + +def _walk_prompt_relative_path( + root: Path, + relative_parts: Tuple[str, ...], +) -> Tuple[Optional[Path], bool]: + """Find a relative prompt by walking only children of a trusted root. + + No metadata-derived path is passed to a filesystem API. Each directory or + file used for the next step comes from listing the already-contained parent, + which both avoids path-injection sinks and avoids recursive tree scans. + """ + if not relative_parts: + return None, True + try: + resolved_root = root.resolve(strict=False) + except (OSError, RuntimeError): + return None, False + current = root + for part in relative_parts[:-1]: + current = _indexed_directory_child(current, part, directory=True) + if current is None: + return None, True + try: + current.resolve(strict=False).relative_to(resolved_root) + except (OSError, RuntimeError, ValueError): + return None, False + + found = _indexed_directory_child( + current, + relative_parts[-1], + directory=False, + ) + if found is None: + return None, True + _terminal_in_root = False + try: + found.resolve(strict=False).relative_to(resolved_root) + _terminal_in_root = True + except (OSError, RuntimeError, ValueError): + _terminal_in_root = False + if _terminal_in_root: + # The terminal target is inside the prompts root, but it must not have been + # reached THROUGH an out-of-tree hop (R16 F2 / R17 F1): an intermediate external + # symlink that currently re-enters beneath the prompts root would otherwise pass + # this lexical check, and a later retarget could redirect an update outside the + # project. Only pay the walk when a symlink is actually involved. + if not _path_has_symlink(found): + return found, True + _repo = _enclosing_git_root(resolved_root) + _hop_roots = _hop_trust_roots(root, resolved_root, _repo) + if _symlink_chain_within_root(found, _hop_roots): + return found, True + return None, False + # #1991 canonical-sync: an APPROVED prompt alias is an in-repository symlink + # to a canonical location (e.g. prompts/nested/foo -> canonical-prompts/foo). + # It escapes the prompts root but stays inside the enclosing git repository, + # so treat it as CONTAINED (its lexical alias identity is authoritative). A + # symlink whose target leaves the repository (or a non-repository tree) is a + # genuine escape and stays uncontained (R8). The repository boundary is anchored + # at the TRUSTED prompts root (R15 F1), never the candidate's own location, so a + # symlinked ancestor cannot redirect it; and EVERY physical hop must stay inside + # the repository, not merely the terminal target (R16 F2). + _repo = _enclosing_git_root(resolved_root) + if _repo is not None: + _repo_real = os.path.realpath(str(_repo)) + try: + found.resolve(strict=False).relative_to(_repo_real) + if _symlink_chain_within_root(found, _repo_real): + return found, True + except (OSError, RuntimeError, ValueError): + pass + return None, False + + +def _prompt_relative_parts_for_root( + root: Path, + architecture_filename: PurePosixPath, +) -> Tuple[str, ...]: + """Strip directory segments already represented by a prompt root.""" + arch_parts = architecture_filename.parts + root_parts = root.parts + overlap = 0 + for candidate in range(min(len(root_parts), len(arch_parts)), 0, -1): + if tuple(part.lower() for part in root_parts[-candidate:]) == tuple( + part.lower() for part in arch_parts[:candidate] + ): + overlap = candidate + break + return tuple(arch_parts[overlap:]) + + +def _prompt_path_has_context_prefix( + candidate: Path, + prompts_root: Path, + context_prefix: str, +) -> bool: + """Return whether a candidate is under an exact context path prefix. + + Context names are path components, not substrings: ``backend`` must match + ``backend/foo.prompt`` but not ``a-backend/foo.prompt``. Comparing components + also supports nested prefixes such as ``backend/utils``. + """ + try: + relative_parts = candidate.relative_to(prompts_root).parts + except ValueError: + return False + prefix_parts = PurePosixPath(context_prefix.replace("\\", "/")).parts + if not prefix_parts or len(prefix_parts) > len(relative_parts): + return False + return tuple(part.lower() for part in relative_parts[:len(prefix_parts)]) == tuple( + part.lower() for part in prefix_parts + ) + + +def _prompt_candidate_aligns_basename(candidate: Path, basename: str) -> bool: + """Whether a prompt candidate aligns with a path-qualified module basename.""" + basename_parts = PurePosixPath(basename).parts + if len(basename_parts) <= 1: + return True + module_leaf = extract_module_from_include(candidate.name) or candidate.stem + module_parts = candidate.parent.parts + (module_leaf,) + return len(basename_parts) <= len(module_parts) and tuple( + part.lower() for part in module_parts[-len(basename_parts):] + ) == tuple(part.lower() for part in basename_parts) + + +def _context_prefix_for_prompts_root( + configured_prompts_dir: Any, + pddrc_path: Path, + prompts_root: Path, +) -> Optional[str]: + """Return a configured context root relative to the active prompt root. + + Custom roots need filesystem-relative normalization: for an active ``specs`` + root, ``specs/frontend`` scopes candidates by ``frontend``, not by the raw + ``specs/frontend`` configuration string. + """ + if not isinstance(configured_prompts_dir, str) or not configured_prompts_dir.strip(): + return None + configured_path = Path(configured_prompts_dir.strip()) + if not configured_path.is_absolute(): + configured_path = pddrc_path.parent / configured_path + try: + relative = configured_path.resolve(strict=False).relative_to( + prompts_root.resolve(strict=False) + ) + except (OSError, RuntimeError, ValueError): + return None + return relative.as_posix() if relative.parts else None + + +def _architecture_prompt_roots( + prompts_root: Path, + architecture_path: Path, +) -> Tuple[Path, ...]: + """Return contained roots that can own architecture prompt filenames.""" + project_root = architecture_path.parent.resolve(strict=False) + candidates: List[Path] = [ + prompts_root.resolve(strict=False), + project_root / "prompts", + project_root / "pdd" / "prompts", + ] + + pddrc_path = _find_pddrc_file(prompts_root) + if pddrc_path: + try: + config = _load_pddrc_config(pddrc_path) + contexts = config.get("contexts", {}) + if isinstance(contexts, dict): + for context in contexts.values(): + if not isinstance(context, dict): + continue + defaults = context.get("defaults", {}) + configured = defaults.get("prompts_dir") if isinstance(defaults, dict) else None + if isinstance(configured, str) and configured.strip(): + configured_path = Path(configured.strip()) + candidates.append( + configured_path + if configured_path.is_absolute() + else project_root / configured_path + ) + except (KeyError, TypeError, ValueError): + pass + + # A narrowed context root (prompts/backend or specs/backend) needs its + # immediate parent to identify a sibling context such as frontend. + for candidate in list(candidates): + if candidate.parent != project_root: + candidates.append(candidate.parent) + + roots: List[Path] = [] + seen: set[str] = set() + for candidate in candidates: + try: + resolved = candidate.resolve(strict=False) + resolved.relative_to(project_root) + except (OSError, RuntimeError, ValueError): + continue + key = os.path.normcase(str(resolved)) + if key in seen: + continue + seen.add(key) + roots.append(resolved) + return tuple(roots) + + +def _architecture_prompt_owner( + architecture_filename: PurePosixPath, + prompt_roots: Tuple[Path, ...], + active_root: Optional[Path] = None, +) -> Tuple[List[Path], bool]: + """Return distinct physical prompts and a containment verdict. + + With ``active_root`` the verdict reflects only whether the ACTIVE prompt root's walk + stayed contained; an escaping same-leaf symlink in an UNRELATED auxiliary root must + not invalidate a unique contained owner in the active root (only the active context's + own expected prompt escaping is a hard failure). Without ``active_root`` the verdict is + the legacy "every walked path was contained". + """ + owners: Dict[str, Path] = {} + all_contained = True + active_contained = True + active_key: Optional[str] = None + if active_root is not None: + try: + active_key = os.path.normcase(str(Path(active_root).resolve(strict=False))) + except (OSError, RuntimeError): + active_key = None + for root in prompt_roots: + relative_parts = _prompt_relative_parts_for_root(root, architecture_filename) + owner, contained = _walk_prompt_relative_path(root, relative_parts) + if not contained: + all_contained = False + if active_key is not None: + try: + if os.path.normcase(str(Path(root).resolve(strict=False))) == active_key: + active_contained = False + except (OSError, RuntimeError): + pass + continue + if owner is None: + continue + try: + key = os.path.normcase(str(owner.resolve(strict=False))) + except (OSError, RuntimeError): + continue + owners.setdefault(key, owner) + return list(owners.values()), (active_contained if active_root is not None else all_contained) + + def _resolve_context_name_for_basename( basename: str, context_override: Optional[str] = None, + pddrc_anchor: Optional[Path] = None, *, pddrc_path: Optional[Path] = None, config: Optional[Dict[str, Any]] = None, ) -> Optional[str]: - """Resolve the context for a basename when no explicit override is provided.""" + """Resolve the context for a basename when no explicit override is provided. + + ``pddrc_anchor`` anchors the ``.pddrc`` search at the project instead of the + process CWD; without it, detecting the context for a path-qualified basename from + a parent/sibling directory (with an absolute prompts root and no explicit + override) fails, and the canonical architecture target is missed. + """ if context_override: return context_override - pddrc_path = pddrc_path or _find_pddrc_file() + pddrc_path = pddrc_path or _find_pddrc_file(pddrc_anchor) if not pddrc_path: return None @@ -356,6 +1179,7 @@ def _prompt_basename_candidates( basename: str, context_name: Optional[str] = None, include_simple_name: bool = False, + pddrc_anchor: Optional[Path] = None, ) -> List[str]: """Return prompt-relative basename candidates ordered from most to least specific.""" candidates: List[str] = [] @@ -367,7 +1191,7 @@ def _add(value: Optional[str]) -> None: _add(basename) if context_name: - _add(_relative_basename_for_context(basename, context_name)) + _add(_relative_basename_for_context(basename, context_name, pddrc_anchor)) if include_simple_name: _add(basename.split("/")[-1] if "/" in basename else basename) @@ -379,6 +1203,7 @@ def _module_filepath_matches_basename( module_filepath: Optional[str], basename: str, context_name: Optional[str] = None, + pddrc_anchor: Optional[Path] = None, ) -> bool: """Return True when a flat architecture filename still clearly maps to a nested basename. @@ -389,12 +1214,19 @@ def _module_filepath_matches_basename( ``src/app/login/page.tsx``, and ``foo/page`` must NOT map to a root ``page.tsx``). A single-component (flat) basename keeps leaf matching. """ - if not module_filepath: + # Untrusted metadata may carry a non-string filepath; treat it as no match rather + # than letting Path() raise a TypeError that a broad except swallows into a wrong + # fallback. + if not isinstance(module_filepath, str) or not module_filepath: return False - relative_basename = _relative_basename_for_context(basename, context_name) - basename_parts = Path(relative_basename).parts - filepath_parts = Path(module_filepath).with_suffix("").parts + relative_basename = _relative_basename_for_context(basename, context_name, pddrc_anchor) + basename_parts = tuple( + part.lower() for part in Path(relative_basename).parts + ) + filepath_parts = tuple( + part.lower() for part in Path(module_filepath).with_suffix("").parts + ) if not basename_parts or not filepath_parts: return False @@ -408,6 +1240,304 @@ def _module_filepath_matches_basename( return tuple(filepath_parts[-len(basename_parts):]) == tuple(basename_parts) +def _filepath_matches_context( + normalized: str, + context_config: Any, + project_root: Optional[Path] = None, + *, + repo_root_output_matches: bool = True, +) -> Optional[bool]: + """Whether a posix filepath lies in one context's declared territory. + + Returns True/False when the context declares a territory (``paths`` globs or + configured output locations), or ``None`` when it declares none (no constraint). + Shared by :func:`_context_owned_filepath` (the resolving context) and + :func:`_filepath_owned_by_other_context` (sibling contexts). + + ``normalized`` is a project-relative architecture filepath. A context may declare + ABSOLUTE ``paths`` globs or output paths; those are re-expressed relative to + ``project_root`` before comparison (an absolute config value outside the project + can never own a project-relative target). Without this, an absolute + ``generate_output_path`` never matches and a sibling context stops owning its + code — re-opening the cross-context borrow this guard blocks. + """ + if not isinstance(context_config, dict): + return None + + root_posix = None + if project_root is not None: + root_posix = PurePosixPath(str(project_root).replace("\\", "/")) + + # Windows path semantics are case-insensitive. When the project root is a Windows + # (drive-qualified) path, compare territory case-insensitively so a drive/directory + # casing difference between .pddrc config and the resolved project root cannot hide + # sibling ownership. A POSIX root keeps case-sensitive matching (unchanged). + windows_ci = bool(project_root is not None and PureWindowsPath(str(project_root)).drive) + + def _fold(value: str) -> str: + return value.lower() if windows_ci else value + + def _project_relative(value: str) -> Optional[str]: + """Re-express a config path relative to the project; None if unusable.""" + v = value.replace("\\", "/") + pure = PurePosixPath(v) + # A Windows drive-qualified value (``C:/x``) is not POSIX-absolute yet is not + # project-relative either; relativize it against the (equally drive-qualified) + # project root so sibling-territory detection still fires on Windows instead of + # silently treating ``C:/proj/frontend`` as a relative literal that matches nothing. + if not pure.is_absolute() and not PureWindowsPath(value).drive: + # Normalize a relative value (strip leading ``./``, collapse ``//``) so a + # ``./frontend/**`` glob compares equal to the normalized project-relative + # architecture filepath instead of silently missing. + return pure.as_posix() + if root_posix is None: + return None + try: + return pure.relative_to(root_posix).as_posix() + except ValueError: + if windows_ci: + # Retry case-insensitively so ``C:/Proj/frontend`` relativizes against a + # ``c:/proj`` root; comparisons below fold both sides, so the lowered tail + # is fine. + try: + return PurePosixPath(v.lower()).relative_to( + PurePosixPath(str(root_posix).lower()) + ).as_posix() + except ValueError: + return None + return None # absolute path outside the project — cannot own it + + globs = [p for p in context_config.get("paths", []) if isinstance(p, str) and p] + prefixes: List[str] = [] + defaults = context_config.get("defaults", {}) + if isinstance(defaults, dict): + for key in ("generate_output_path", "test_output_path", "example_output_path"): + value = defaults.get(key) + if isinstance(value, str) and value.strip(): + prefixes.append(value) + outputs = defaults.get("outputs", {}) + if isinstance(outputs, dict): + for spec in outputs.values(): + template = spec.get("path") if isinstance(spec, dict) else None + if isinstance(template, str) and template.strip(): + prefixes.append(template) + + if not globs and not prefixes: + return None + + normalized_cmp = _fold(normalized) + for pattern in globs: + pattern_norm = _project_relative(pattern) + if pattern_norm is None: + continue + pattern_cmp = _fold(pattern_norm) + base = pattern_cmp.rstrip("*").rstrip("/") + if ( + fnmatch.fnmatch(normalized_cmp, pattern_cmp) + or normalized_cmp == base + or (base and normalized_cmp.startswith(base + "/")) + ): + return True + + for prefix in prefixes: + # Output templates such as "backend/functions/{name}.py" contribute only + # the directory before the first placeholder. + prefix_head = prefix.replace("\\", "/") + if "{" in prefix_head: + prefix_head = prefix_head.split("{", 1)[0] + prefix_norm = _project_relative(prefix_head) + if prefix_norm is None: + continue + base = _fold(prefix_norm.strip().rstrip("/")) + if base.startswith("./"): + base = base[2:] + if base in ("", "."): + # A repo-root output path imposes no territory constraint. + if repo_root_output_matches: + return True + continue + if normalized_cmp == base or normalized_cmp.startswith(base + "/"): + return True + + return False + + +_TERRITORY_CONFIG_UNSET: Any = object() +# Distinct from ``None`` (no .pddrc at all): a .pddrc was found but could not be parsed. +_TERRITORY_MALFORMED: Any = object() + + +def _filepath_in_named_context( + architecture_filepath: Optional[str], + config: Any, + project_root: Optional[Path] = None, +) -> bool: + """True when the filepath lies in ANY named (non-``default``) context's territory. + + Used when the resolving context cannot be established: a heuristic borrow whose + target sits in some named context may pair the prompt with a sibling context's code, + so it must be denied even though there is no single resolving context to exclude. + """ + if not isinstance(architecture_filepath, str) or not architecture_filepath.strip(): + return False + if not isinstance(config, dict): + return False + contexts = config.get("contexts", {}) + if not isinstance(contexts, dict): + return False + normalized = PurePosixPath(architecture_filepath.strip().replace("\\", "/")).as_posix() + for other_name, other_config in contexts.items(): + if other_name == "default": + continue + if _filepath_matches_context( + normalized, other_config, project_root, repo_root_output_matches=False + ) is True: + return True + return False + + +def _filepath_owned_by_other_context( + architecture_filepath: Optional[str], + context_name: Optional[str], + pddrc_anchor: Optional[Path] = None, + *, + config_snapshot: Any = _TERRITORY_CONFIG_UNSET, + project_root: Optional[Path] = None, +) -> bool: + """True when the filepath lies in the territory of a DIFFERENT named context. + + A PROVEN-owner architecture row (its physical prompt IS the resolved prompt) may + legitimately target code outside its own context's globs — e.g. an intentionally + shared path owned by no context. It must still be rejected when that target + belongs to a SIBLING context, which is the stale cross-context borrow. The + catch-all ``default`` context is ignored so a shared path it happens to match does + not read as foreign ownership. + """ + if not isinstance(architecture_filepath, str) or not architecture_filepath.strip(): + return False + if not context_name: + return False + if config_snapshot is _TERRITORY_CONFIG_UNSET: + pddrc_path = _find_pddrc_file(pddrc_anchor) + if not pddrc_path: + return False + try: + config = _load_pddrc_config(pddrc_path) + except (ValueError, KeyError, TypeError): + return False + project_root = pddrc_path.parent + else: + config = config_snapshot + if not isinstance(config, dict): + return False + contexts = config.get("contexts", {}) + if not isinstance(contexts, dict): + return False + normalized = PurePosixPath(architecture_filepath.strip().replace("\\", "/")).as_posix() + for other_name, other_config in contexts.items(): + if other_name == context_name or other_name == "default": + continue + if _filepath_matches_context( + normalized, + other_config, + project_root, + repo_root_output_matches=False, + ) is True: + return True + return False + + +def _context_owned_filepath( + architecture_filepath: Optional[str], + context_name: Optional[str], + pddrc_anchor: Optional[Path] = None, + *, + config_snapshot: Any = _TERRITORY_CONFIG_UNSET, + project_root: Optional[Path] = None, +) -> bool: + """Return True when a borrowed architecture filepath is inside a context's territory. + + Leaf- and filepath-stem-matched architecture entries are heuristic borrows: + unlike an exact filename match, they do not directly name the resolved prompt. + A stale sibling-context entry (e.g. a ``frontend`` module whose prompt was + deleted but whose ``architecture.json`` row survives) can otherwise be borrowed + by a same-leaf ``backend`` prompt and silently redirect the sync onto the + foreign module's code. Restrict such borrows to filepaths the resolving + prompt's context owns — its ``paths`` globs or configured output locations. + + ``pddrc_anchor`` anchors the ``.pddrc`` lookup at the project (the directory of + ``architecture.json``), NOT the process CWD. Resolution is frequently invoked + from a parent/sibling working directory with an absolute prompts root, and a + CWD-based lookup would miss the project's ``.pddrc`` and fail open — re-opening + the very cross-context borrow this guard exists to block. + + Returns True (permit) whenever no territory can be derived: a bare basename + with no resolvable context, a missing/invalid ``.pddrc``, or a repo-root output + path. Non-context projects therefore keep the prior, permissive behavior. + """ + if not isinstance(architecture_filepath, str) or not architecture_filepath.strip(): + return True + if not context_name: + return True + if config_snapshot is _TERRITORY_CONFIG_UNSET: + pddrc_path = _find_pddrc_file(pddrc_anchor) + if not pddrc_path: + return True + try: + config = _load_pddrc_config(pddrc_path) + except (ValueError, KeyError, TypeError): + return True + project_root = pddrc_path.parent + else: + config = config_snapshot + if not isinstance(config, dict): + return True + contexts = config.get("contexts", {}) + context_config = contexts.get(context_name) if isinstance(contexts, dict) else None + if not isinstance(context_config, dict): + return True + + normalized = PurePosixPath( + architecture_filepath.strip().replace("\\", "/") + ).as_posix() + + match = _filepath_matches_context(normalized, context_config, project_root) + # No territory declared for this context — impose no restriction. + return True if match is None else match + + +def _anchor_output_paths_at_project(result: Dict[str, Any], project_root: Path) -> Dict[str, Any]: + """Resolve relative output artifact paths against the project root, not the CWD. + + Template-generated code/example/test paths are project-relative; a resolution + run from a parent/sibling working directory must still write under the project. + When the project root IS the CWD the relative paths already resolve correctly, so + they are left as-is to preserve the established (relative) return contract; only a + differing CWD triggers re-anchoring. Absolute paths and the already-resolved + ``prompt`` key are unchanged. + """ + try: + if project_root.resolve(strict=False) == Path.cwd().resolve(): + return result + except (OSError, RuntimeError): + pass + + def _anchor(value: Any) -> Any: + if isinstance(value, Path) and not value.is_absolute(): + return project_root / value + return value + + anchored: Dict[str, Any] = {} + for key, value in result.items(): + if key == "prompt": + anchored[key] = value + elif isinstance(value, list): + anchored[key] = [_anchor(item) for item in value] + else: + anchored[key] = _anchor(value) + return anchored + + def _overlay_configured_output_paths( result: Dict[str, Path], outputs_config: Dict[str, Any], @@ -415,13 +1545,14 @@ def _overlay_configured_output_paths( basename: str, language: str, context_name: Optional[str] = None, + pddrc_anchor: Optional[Path] = None, ) -> Dict[str, Path]: """Overlay construct_paths-derived output locations onto template-derived paths.""" merged = dict(result) code_path = output_paths.get("generate_output_path") or output_paths.get("output") or output_paths.get("code_file") if "code" not in outputs_config and code_path: - relative_basename = _relative_basename_for_context(basename, context_name) + relative_basename = _relative_basename_for_context(basename, context_name, pddrc_anchor) dir_prefix, name_part = _extract_name_part(relative_basename) extension = get_extension(language) code_path_obj = Path(code_path) @@ -433,12 +1564,84 @@ def _overlay_configured_output_paths( return merged +def _prompt_candidate_within_root( + candidate: Path, resolved_root: Path, lexical_root: Optional[Path] = None +) -> bool: + """True when ``candidate`` resolves inside ``resolved_root``. + + Recursive prompt discovery follows symlinks, so a same-leaf in-root symlink can + point at an external file. Returning such a candidate lets an update operation + write through it and overwrite a file outside the repository. Every recursively + discovered candidate must therefore pass this containment check before it is + returned, mirroring the guarded search in + ``_resolve_prompt_path_from_architecture`` and ``_walk_prompt_relative_path``. + + #1991 canonical-sync: an APPROVED in-repo prompt alias is a symlink that escapes + the prompts root but stays inside the enclosing git repository. It is treated as + contained here too — the SAME predicate for direct, architecture-hint, and + recursive discovery — so an approved alias resolves consistently regardless of + which discovery path finds it. A symlink whose target leaves the repository (or a + non-repository tree) is still a genuine escape and is rejected (R8). + """ + _terminal_in_root = False + try: + candidate.resolve(strict=False).relative_to(resolved_root) + _terminal_in_root = True + except (OSError, RuntimeError, ValueError): + _terminal_in_root = False + if _terminal_in_root: + # Terminal target inside the prompts root, but it must not have been reached + # through an out-of-tree hop (R16 F2 / R17 F1). Only pay the walk when a symlink is + # involved; require every physical hop to stay inside the trusted boundary — the + # enclosing repository, or (non-repository) the prompts root plus its lexical + # location so a trusted top-level prompt-root symlink is still honoured. + if not _path_has_symlink(candidate): + return True + _repo = _enclosing_git_root(resolved_root) + _roots = _hop_trust_roots(lexical_root or resolved_root, resolved_root, _repo) + return _symlink_chain_within_root(candidate, _roots) + # The repository boundary is anchored at the TRUSTED root (R15 F1), never the + # candidate's own possibly-redirected location, and EVERY physical hop of the + # candidate must stay inside the repository — a chain that leaves the repo (even one + # that currently re-enters) is rejected, closing the intermediate-symlink escape (R16 F2). + _repo = _enclosing_git_root(resolved_root) + if _repo is not None: + _repo_real = os.path.realpath(str(_repo)) + try: + candidate.resolve(strict=False).relative_to(_repo_real) + if _symlink_chain_within_root(candidate, _repo_real): + return True + except (OSError, RuntimeError, ValueError): + pass + return False + + +# Sentinel distinguishing "read architecture.json from disk" from an explicit +# (possibly empty) pre-parsed module snapshot. get_pdd_file_paths parses the +# architecture ONCE and threads that immutable snapshot through prompt discovery +# and code-path selection so a mid-resolution rewrite of architecture.json cannot +# produce a torn prompt/code pair (prompt from the old registry, code from the new). +_ARCH_MODULES_UNSET: Any = object() + +# Three-state result of architecture-row ownership relative to the resolved prompt. +# INELIGIBLE: the row demonstrably names a DIFFERENT prompt (or is unsafe metadata). +# ELIGIBLE: a heuristic match with no proven physical owner (canonical / absent) — +# additionally constrained to the resolving context's territory. +# PROVEN: the row's physical prompt owner IS the resolved prompt (explicit +# mapping) — trusted even when its code target is outside the context's +# own globs, so long as it is not owned by a SIBLING context. +_OWNERSHIP_INELIGIBLE = 0 +_OWNERSHIP_ELIGIBLE = 1 +_OWNERSHIP_PROVEN = 2 + + def _find_prompt_file( basename: str, language: str, prompts_root: Path, architecture_path: Optional[Path] = None, context_override: Optional[str] = None, + arch_modules: Any = _ARCH_MODULES_UNSET, ) -> Optional[Path]: """Authoritative prompt file resolution — case-insensitive, subdirectory-aware. @@ -462,12 +1665,23 @@ def _find_prompt_file( Returns: Actual filesystem Path with correct casing, or None if not found. """ + if _safe_architecture_prompt_filename(basename) is None: + raise UnsafePromptPathError(Path(basename), prompts_root.resolve(strict=False)) + if _safe_prompt_language(language) is None: + raise UnsafePromptPathError(Path(str(language)), prompts_root.resolve(strict=False)) name = basename.split('/')[-1] if '/' in basename else basename - context_name = _resolve_context_name_for_basename(basename, context_override) + # Containment anchor for recursive discovery AND the CWD-independent .pddrc anchor + # for context detection / prefix stripping: resolution is often driven from a + # parent/sibling directory with an absolute (possibly custom) prompts root. + resolved_prompts_root = prompts_root.resolve(strict=False) + context_name = _resolve_context_name_for_basename( + basename, context_override, pddrc_anchor=resolved_prompts_root + ) basename_candidates = _prompt_basename_candidates( basename, context_name=context_name, include_simple_name="/" not in basename, + pddrc_anchor=resolved_prompts_root, ) # Resolve context prefix from .pddrc for scoping recursive searches. @@ -475,58 +1689,127 @@ def _find_prompt_file( # yields context_prefix='backend/utils' so we prefer matches under that path. context_prefix = None if context_name: - pddrc_path = _find_pddrc_file() + # Anchor at the prompt root, NOT the process CWD: resolution is often driven + # from a parent/sibling directory with an absolute prompts root, and a + # CWD-based lookup would miss the project's .pddrc, drop context_prefix, and + # let a same-leaf prompt in the WRONG context win the shallowest/lexicographic + # tie-break below. + pddrc_path = _find_pddrc_file(resolved_prompts_root) if pddrc_path: try: config = _load_pddrc_config(pddrc_path) context_config = config.get('contexts', {}).get(context_name, {}) prompts_dir_config = context_config.get('defaults', {}).get('prompts_dir', '') if prompts_dir_config: - from pdd.construct_paths import _extract_prefix_from_prompts_dir - context_prefix = _extract_prefix_from_prompts_dir(prompts_dir_config) + context_prefix = _context_prefix_for_prompts_root( + prompts_dir_config, + pddrc_path, + resolved_prompts_root, + ) except (ValueError, KeyError): pass # --- Step 1: Direct path (fast path for simple/flat projects) --- + # Containment applies to the fast path too: the exact expected prompt may itself + # be a file symlink whose target escapes prompts_root. An in-root alias resolves + # inside the root and is preserved; an escaping symlink is skipped so a later + # update cannot open it with "w" and truncate the external target. for candidate_basename in basename_candidates: - resolved = _case_insensitive_path_lookup(prompts_root / f"{candidate_basename}_{language}.prompt") + direct_relative = PurePosixPath( + f"{candidate_basename}_{language}.prompt" + ) + direct_candidate = prompts_root.joinpath(*direct_relative.parts) + if context_prefix and not _prompt_path_has_context_prefix( + direct_candidate, prompts_root, context_prefix + ): + continue + resolved, contained = _walk_prompt_relative_path( + prompts_root, + tuple(direct_relative.parts), + ) + if not contained: + # An escaping symlink whose target leaves the repository (or a + # non-repository tree) is rejected so a later `update` cannot + # open-and-truncate an out-of-repo file. An APPROVED in-repo canonical + # alias (#1991) is already reported CONTAINED by _walk_prompt_relative_path. + raise UnsafePromptPathError( + direct_candidate, + resolved_prompts_root, + ) if resolved: return resolved # --- Step 3: Architecture.json hint → recursive search --- - if architecture_path and architecture_path.exists(): + # Use the caller's immutable module snapshot when provided so prompt discovery + # and code-path selection agree on ONE architecture view; only re-check the file + # on disk when no snapshot was threaded in. + have_architecture = architecture_path is not None and ( + arch_modules is not _ARCH_MODULES_UNSET or architecture_path.exists() + ) + if have_architecture: + # Pass the resolved context so the architecture hint respects context + # territory: with a broad prompts root, a bare-leaf lookup must NOT borrow a + # sibling context's row (e.g. a backend resolution picking up a + # frontend/credits row) and return its prompt before the context-aware + # recursive fallback runs. _context_owned_filepath rejects the foreign row. _, arch_filename = _get_filepath_from_architecture( architecture_path, f"{basename_candidates[0]}_{language}.prompt", basename=basename, language=language, + modules=arch_modules, + resolved_context_name=context_name, ) if arch_filename: # 3a: Direct join (handles architecture filenames with subdirectory paths) - joined = _resolve_prompt_path_from_architecture(prompts_root, arch_filename) + joined = _resolve_prompt_path_from_architecture( + prompts_root, + arch_filename, + context_prefix=context_prefix, + basename=basename, + ) + if joined is None: + arch_filename = None + if arch_filename and joined is not None: resolved_joined = _case_insensitive_path_lookup(joined) - if resolved_joined: + if resolved_joined and _prompt_candidate_within_root( + resolved_joined, resolved_prompts_root, prompts_root + ): return resolved_joined # 3b: Case-insensitive in the joined parent directory if joined.parent.is_dir(): joined_lower = joined.name.lower() for candidate in joined.parent.iterdir(): - if candidate.is_file() and candidate.name.lower() == joined_lower: + if ( + candidate.is_file() + and candidate.name.lower() == joined_lower + and _prompt_candidate_within_root(candidate, resolved_prompts_root, prompts_root) + ): return candidate # 3c: Recursive search for the architecture filename in all subdirectories. - # Collect all matches and pick shallowest deterministically. + # Collect all matches and pick shallowest deterministically. Every match + # must resolve inside prompts_root so a same-leaf symlink cannot escape. arch_basename_lower = Path(arch_filename).name.lower() - arch_matches = [ - c for c in prompts_root.rglob("*.prompt") - if c.is_file() and c.name.lower() == arch_basename_lower - ] + arch_matches = [] + unsafe_arch_matches = [] + for candidate in prompts_root.rglob("*.prompt"): + if not candidate.is_file() or candidate.name.lower() != arch_basename_lower: + continue + if not _prompt_candidate_aligns_basename(candidate, basename): + continue + if _prompt_candidate_within_root(candidate, resolved_prompts_root, prompts_root): + arch_matches.append(candidate) + else: + unsafe_arch_matches.append(candidate) + if arch_matches and context_prefix: + arch_matches = [ + m for m in arch_matches + if _prompt_path_has_context_prefix( + m, prompts_root, context_prefix + ) + ] if arch_matches: if len(arch_matches) > 1: - # Prefer match within context prefix (e.g., backend/utils) - if context_prefix: - ctx_filtered = [m for m in arch_matches if context_prefix in str(m.relative_to(prompts_root))] - if ctx_filtered: - arch_matches = ctx_filtered # Then prefer match matching directory hint from basename dir_hint = basename.rsplit('/', 1)[0] if '/' in basename else None if dir_hint and len(arch_matches) > 1: @@ -535,6 +1818,19 @@ def _find_prompt_file( arch_matches = hint_filtered arch_matches.sort(key=lambda p: (len(p.parts), str(p))) return arch_matches[0] + if unsafe_arch_matches: + relevant_unsafe = unsafe_arch_matches + if context_prefix: + relevant_unsafe = [ + candidate for candidate in unsafe_arch_matches + if _prompt_path_has_context_prefix( + candidate, prompts_root, context_prefix + ) + ] + if relevant_unsafe: + raise UnsafePromptPathError( + relevant_unsafe[0], resolved_prompts_root + ) # --- Step 4: Recursive glob fallback (always works) --- # Case-insensitive on both basename and language suffix. @@ -543,21 +1839,30 @@ def _find_prompt_file( # basenames like "dashboard" vs on-disk "Dashboard". lang_lower = language.lower() matches = [] + unsafe_matches = [] + # Filter by the cheap filename leaf FIRST, then pay the containment resolve only for + # the handful of leaf-matching candidates — not once per prompt in the whole tree. + expected_leaves = { + f"{candidate_basename.split('/')[-1].lower()}_{lang_lower}.prompt" + for candidate_basename in basename_candidates + } for candidate in prompts_root.rglob("*.prompt"): if not candidate.is_file(): continue - candidate_lower = candidate.name.lower() - for candidate_basename in basename_candidates: - target_lower = f"{candidate_basename.split('/')[-1].lower()}_{lang_lower}.prompt" - if candidate_lower == target_lower: - matches.append(candidate) - break + if candidate.name.lower() not in expected_leaves: + continue + # A leaf-matching candidate that escapes prompts_root through a symlink is + # recorded as unsafe; an in-root match is used. + if not _prompt_candidate_within_root(candidate, resolved_prompts_root, prompts_root): + unsafe_matches.append(candidate) + continue + matches.append(candidate) + if matches and context_prefix: + matches = [ + m for m in matches + if _prompt_path_has_context_prefix(m, prompts_root, context_prefix) + ] if matches: - if len(matches) > 1 and context_prefix: - # Prefer match within context prefix (e.g., backend/utils) - ctx_filtered = [m for m in matches if context_prefix in str(m.relative_to(prompts_root))] - if ctx_filtered: - matches = ctx_filtered # Issue #1677: a path-qualified basename (e.g. `app/login/page`) must resolve # to a prompt WITHIN its own directory. Do not fall back to a same-leaf prompt # in a different directory — that silently syncs the wrong module for a @@ -568,14 +1873,22 @@ def _find_prompt_file( # not (and `foo` inside an absolute prefix like /home/foo cannot false-match, # since only the suffix is compared). if "/" in basename: - basename_variants = {Path(basename).parts} - relative_basename = _relative_basename_for_context(basename, context_name) + basename_variants = { + tuple(part.lower() for part in Path(basename).parts) + } + relative_basename = _relative_basename_for_context( + basename, context_name, resolved_prompts_root + ) if relative_basename != basename: - basename_variants.add(Path(relative_basename).parts) + basename_variants.add( + tuple(part.lower() for part in Path(relative_basename).parts) + ) aligned = [] for m in matches: module_leaf = extract_module_from_include(m.name) or m.stem - module_parts = m.parent.parts + (module_leaf,) + module_parts = tuple( + part.lower() for part in m.parent.parts + (module_leaf,) + ) if any( len(bp) <= len(module_parts) and tuple(module_parts[-len(bp):]) == bp for bp in basename_variants @@ -586,6 +1899,36 @@ def _find_prompt_file( matches = aligned matches.sort(key=lambda p: (len(p.parts), str(p))) return matches[0] + if unsafe_matches: + relevant_unsafe = unsafe_matches + if context_prefix: + relevant_unsafe = [ + candidate for candidate in unsafe_matches + if _prompt_path_has_context_prefix( + candidate, prompts_root, context_prefix + ) + ] + if "/" in basename and relevant_unsafe: + # An escaping same-leaf symlink under an UNRELATED directory must not hard-fail + # a path-qualified creation: restrict the hard failure to unsafe candidates that + # actually align with the requested basename's directory suffix, exactly as the + # safe matches above are aligned. + _bn_variants = {tuple(p.lower() for p in Path(basename).parts)} + _rel_bn = _relative_basename_for_context(basename, context_name, resolved_prompts_root) + if _rel_bn != basename: + _bn_variants.add(tuple(p.lower() for p in Path(_rel_bn).parts)) + + def _unsafe_aligns(cand: Path) -> bool: + leaf = extract_module_from_include(cand.name) or cand.stem + parts = tuple(p.lower() for p in cand.parent.parts + (leaf,)) + return any( + len(bp) <= len(parts) and tuple(parts[-len(bp):]) == bp + for bp in _bn_variants + ) + + relevant_unsafe = [c for c in relevant_unsafe if _unsafe_aligns(c)] + if relevant_unsafe: + raise UnsafePromptPathError(relevant_unsafe[0], resolved_prompts_root) return None @@ -594,7 +1937,12 @@ def _get_filepath_from_architecture( architecture_path: Path, prompt_filename: str, basename: Optional[str] = None, - language: Optional[str] = None + language: Optional[str] = None, + prompt_path: Optional[Path] = None, + prompts_root: Optional[Path] = None, + prompt_roots: Optional[Tuple[Path, ...]] = None, + resolved_context_name: Optional[str] = None, + modules: Any = _ARCH_MODULES_UNSET, ) -> Tuple[Optional[str], Optional[str]]: """Extract filepath for a prompt from architecture.json. @@ -606,6 +1954,18 @@ def _get_filepath_from_architecture( prompt_filename: The prompt filename to search for (e.g., "models_findings_Python.prompt"). basename: Optional basename for alternative matching (e.g., "models_findings"). language: Optional language for alternative matching (e.g., "Python"). + prompt_path: Resolved physical prompt, used to reject an architecture + entry that directly names a different same-leaf prompt. + prompts_root: Root used to resolve architecture prompt filenames. + prompt_roots: Contained prompt roots used to establish physical + ownership across sibling contexts without recursive scans. + resolved_context_name: The resolving prompt's ``.pddrc`` context. Restricts + heuristic leaf/filepath-stem borrows to that context's territory so a + stale sibling-context entry cannot redirect the sync (see + :func:`_context_owned_filepath`). + modules: Pre-parsed architecture module snapshot. When left unset the file + is read from disk; when supplied (even as ``None``/empty) it is used + as-is so caller-shared resolution reads ONE immutable architecture view. Returns: Tuple of (filepath, matched_filename) if found, else (None, None). @@ -615,15 +1975,35 @@ def _get_filepath_from_architecture( - [...] - flat array """ try: - with open(architecture_path, 'r', encoding='utf-8') as f: - arch = json.load(f) - - modules = extract_modules(arch) + if modules is _ARCH_MODULES_UNSET: + with open(architecture_path, 'r', encoding='utf-8') as f: + arch = json.load(f) + modules = extract_modules(arch) if not modules: return None, None - context_name = _resolve_context_name_for_basename(basename) if basename else None + # Prefer the caller's resolved context (CWD-independent) over re-detecting it + # from the CWD: a resolution driven from a parent/sibling directory would + # otherwise mis-detect the context, mis-strip the basename prefix, and miss a + # canonical path-qualified architecture target (falling back to the default). + context_name = resolved_context_name or ( + _resolve_context_name_for_basename(basename) if basename else None + ) + pddrc_anchor = architecture_path.parent if architecture_path is not None else None + territory_config: Any = None + territory_project_root: Optional[Path] = None + territory_pddrc = _find_pddrc_file(pddrc_anchor) + if territory_pddrc: + try: + territory_config = _load_pddrc_config(territory_pddrc) + territory_project_root = territory_pddrc.parent + except (ValueError, KeyError, TypeError): + # Present but unparseable: mark it so a heuristic borrow (which needs + # territory to be confined) is denied rather than falling open to the + # permissive "no territory" behavior. + territory_config = _TERRITORY_MALFORMED + territory_project_root = territory_pddrc.parent # Issue #1677: a path-qualified basename (e.g. `foo/page`) must only match a # module whose filepath aligns with its directory. Otherwise an exact match on @@ -631,35 +2011,313 @@ def _get_filepath_from_architecture( # path to an unrelated same-leaf module. Flat basenames are unaffected (their # ambiguity is already handled upstream). path_qualified = bool(basename) and "/" in basename + # The requested language's canonical code extension (e.g. python -> "py"). + # Selection must gate a row's filepath extension with the SAME predicate the + # ambiguity enumeration uses (R13 F2): otherwise an exact-filename row whose + # code target is a DIFFERENT language (e.g. `nested/widget_python.prompt` -> + # `widget.ts`) is excluded from the ambiguity count yet chosen here first, + # returning a wrong-language output that disagrees with the `.py` choice. + _selection_lang_ext = (get_extension(language).lstrip(".").lower() if language else "") + + def _row_language_matches(module: Dict[str, Any]) -> bool: + """Whether a row's filepath extension matches the requested language. + + Mirrors the ambiguity enumeration's gate exactly: with a known language + extension, a non-empty string filepath whose suffix differs (including an + extensionless target) is ineligible. A null/empty filepath, or no known + language extension, imposes no constraint (left to the other gates). + """ + if not _selection_lang_ext: + return True + fpv = module.get("filepath") + if not isinstance(fpv, str) or not fpv.strip(): + return True + return ( + PurePosixPath(fpv.replace("\\", "/")).suffix.lstrip(".").lower() + == _selection_lang_ext + ) def _aligns(module: Dict[str, Any]) -> bool: + if not _row_language_matches(module): + return False if not path_qualified: return True return _module_filepath_matches_basename( - module.get("filepath"), basename, context_name=context_name + module.get("filepath"), basename, context_name=context_name, + pddrc_anchor=pddrc_anchor, + ) + + def _belongs_to_resolved_prompt(module: Dict[str, Any]) -> int: + """Classify a row's ownership relative to the resolved prompt. + + Returns one of ``_OWNERSHIP_INELIGIBLE`` / ``_OWNERSHIP_ELIGIBLE`` / + ``_OWNERSHIP_PROVEN``. A flat prompt and a nested prompt can share a + basename; the nested architecture filename is useful when the nested + ``prompts_dir`` strips that prefix, but it must not be borrowed by the flat + sibling. A direct, existing architecture-derived prompt path is + authoritative evidence (PROVEN) of which physical prompt owns the entry. + Canonical filepath-derived names may not exist as prompt paths, so those + stay ELIGIBLE for the unique-filepath fallback (still territory-guarded). + """ + module_filename = module.get("filename") + if not isinstance(module_filename, str) or not module_filename: + # Architecture source-file entries without prompt-style names + # are eligible for the filepath-stem compatibility fallback. + return _OWNERSHIP_ELIGIBLE + normalized_filename = _safe_architecture_prompt_filename(module_filename) + if normalized_filename is None: + return _OWNERSHIP_INELIGIBLE + + # Validation must precede this context-free discovery return. The + # caller may not have found a physical prompt yet, but unsafe + # architecture metadata must already be ineligible as a hint. + if prompt_path is None or prompts_root is None: + return _OWNERSHIP_ELIGIBLE + + # Non-prompt source filenames have no prompt ownership identity; + # their compatibility behavior is governed by filepath stem. + if not extract_module_from_include(normalized_filename.name): + return _OWNERSHIP_ELIGIBLE + + roots = prompt_roots or (prompts_root.resolve(strict=False),) + owners, all_contained = _architecture_prompt_owner( + normalized_filename, + roots, + active_root=prompts_root, + ) + if not all_contained: + return _OWNERSHIP_INELIGIBLE + if not owners: + # Canonical filepath-derived names need not exist physically as + # prompt paths, so absence of an owner remains eligible. + return _OWNERSHIP_ELIGIBLE + # ``owners`` were obtained by contained directory walks above. Map + # the prompt's validated root-relative identity across trusted roots + # so aliases such as ``prompts -> pdd/prompts`` compare correctly. + # Keep the prompt side lexical: resolving caller-influenced ``prompt_path`` + # here would turn it into a filesystem sink. + try: + relative_prompt = prompt_path.relative_to(prompts_root) + except ValueError: + return _OWNERSHIP_INELIGIBLE + if relative_prompt.is_absolute() or ".." in relative_prompt.parts: + return _OWNERSHIP_INELIGIBLE + # Project ONLY across roots that ALIAS the resolving prompt root (resolve to + # the same directory) — never across sibling context roots. Otherwise a + # uniquely-named SIBLING prompt sitting at the same relative path under a + # different root is misread as the resolved prompt's proven owner and lends + # its shared code target across contexts. Resolving the ROOT directories (not + # the caller-influenced prompt) is safe. + try: + prompts_root_key = os.path.normcase(str(prompts_root.resolve(strict=False))) + except (OSError, RuntimeError): + return _OWNERSHIP_INELIGIBLE + alias_roots = [] + for root in roots: + try: + if os.path.normcase(str(Path(root).resolve(strict=False))) == prompts_root_key: + alias_roots.append(root) + except (OSError, RuntimeError): + continue + if not alias_roots: + alias_roots = [prompts_root] + expected_keys = { + os.path.normcase( + os.path.abspath(os.path.normpath(root.joinpath(relative_prompt))) + ) + for root in alias_roots + } + owner_keys = { + os.path.normcase(os.path.abspath(os.path.normpath(owner))) + for owner in owners + } + if not owner_keys.issubset(expected_keys): + return _OWNERSHIP_INELIGIBLE + # PROVEN requires a UNIQUE physical owner. When a flat/same-leaf filename + # matches distinct prompts in more than one context root, the row does not + # unambiguously identify the resolved prompt, so it stays territory-guarded + # (ELIGIBLE) rather than letting two contexts both claim one shared target. + if len(owners) != 1: + return _OWNERSHIP_ELIGIBLE + return _OWNERSHIP_PROVEN + + borrow_ownership_cache: Dict[Tuple[str, str], bool] = {} + + def _borrow_ownership_ok(module: Dict[str, Any]) -> bool: + """Eligibility for an architecture row in the resolving context. + + A PROVEN row (physical owner IS the resolved prompt) is an explicit + mapping: trusted even when its code target lies outside the context's own + territory, UNLESS that target belongs to a sibling context (a stale + cross-context row). A merely ELIGIBLE (heuristic) row is confined to the + resolving context's own territory. + """ + filepath = module.get("filepath") + cache_key = ( + str(module.get("filename") or ""), + str(filepath or ""), + ) + cached = borrow_ownership_cache.get(cache_key) + if cached is not None: + return cached + + root_resolved = architecture_path.parent.resolve(strict=False) + contained = ( + _contained_architecture_code_path(root_resolved, filepath) + if isinstance(filepath, str) + else None + ) + if contained is None: + borrow_ownership_cache[cache_key] = False + return False + + # Sibling-territory ownership is checked against BOTH the lexical + # filepath and its RESOLVED project-relative identity. An in-project + # symlink (e.g. ``backend/link`` -> ``frontend/src``) passes project + # containment yet physically lands in a sibling context; a lexical-only + # check would let that alias smuggle a code target into the sibling. + identities = [filepath] + try: + resolved_rel = contained.relative_to(root_resolved).as_posix() + except ValueError: + resolved_rel = None + if resolved_rel and resolved_rel != PurePosixPath( + filepath.replace("\\", "/") + ).as_posix(): + identities.append(resolved_rel) + if any( + _filepath_owned_by_other_context( + identity, + resolved_context_name, + pddrc_anchor, + config_snapshot=territory_config, + project_root=territory_project_root, + ) + for identity in identities + ): + borrow_ownership_cache[cache_key] = False + return False + context_owned = _context_owned_filepath( + filepath, + resolved_context_name, + pddrc_anchor, + config_snapshot=territory_config, + project_root=territory_project_root, + ) + ownership = _belongs_to_resolved_prompt(module) + if ownership == _OWNERSHIP_INELIGIBLE: + borrow_ownership_cache[cache_key] = False + return False + # A heuristic (non-proven) borrow is only safe if it can be confined to the + # resolving context's territory. When the .pddrc defining that territory is + # present but UNPARSEABLE, confinement cannot be verified — deny the heuristic + # borrow (fail closed) instead of falling open to the permissive default. A + # proven, explicit prompt->code mapping is still honored. + if territory_config is _TERRITORY_MALFORMED and ownership != _OWNERSHIP_PROVEN: + borrow_ownership_cache[cache_key] = False + return False + # When the resolving context cannot be established (no override and the + # basename does not encode one), a FOREIGN heuristic borrow — one whose + # architecture filename does not name this module (matched only by a leaf/stem + # collision) — whose target lies in ANY named context may pair this prompt with + # a sibling context's code. Deny it (fail closed). A row that names this module + # (even with no physical prompt yet), a proven mapping, and a genuinely + # unowned/shared target are still allowed. + # A row whose architecture filename EXACTLY names the requested module is that + # module's own explicit mapping, even if no physical prompt exists yet (new + # module) — distinct from a foreign leaf/stem collision. + row_names_this_module = ( + str(module.get("filename") or "").strip().lower() == prompt_filename.strip().lower() + ) + if ( + resolved_context_name is None + and ownership != _OWNERSHIP_PROVEN + and not row_names_this_module + and isinstance(territory_config, dict) + and any( + _filepath_in_named_context(identity, territory_config, territory_project_root) + for identity in identities + ) + ): + borrow_ownership_cache[cache_key] = False + return False + # The exact current-module row is honored even when its (safe, non-sibling) + # target is shared/unowned and no physical prompt exists yet: sibling-owned and + # unsafe targets were already rejected above, so this cannot cross contexts. + allowed = ( + context_owned + or ownership == _OWNERSHIP_PROVEN + or row_names_this_module ) + borrow_ownership_cache[cache_key] = allowed + return allowed # Try exact filename match first for module in modules: if not isinstance(module, dict): continue - if module.get("filename") == prompt_filename and _aligns(module): + if ( + module.get("filename") == prompt_filename + and _aligns(module) + and _borrow_ownership_ok(module) + ): return module.get("filepath"), module.get("filename") - # Try case-insensitive filename match + # Try case-insensitive filename match. A module may carry ``"filename": null`` + # (identified only by filepath); coerce to a string so .lower() never raises + # AttributeError, which the broad get_pdd_file_paths fallback would otherwise + # swallow into a cwd-relative default path. prompt_filename_lower = prompt_filename.lower() for module in modules: if not isinstance(module, dict): continue - if module.get("filename", "").lower() == prompt_filename_lower and _aligns(module): + if ( + str(module.get("filename") or "").lower() == prompt_filename_lower + and _aligns(module) + and _borrow_ownership_ok(module) + ): return module.get("filepath"), module.get("filename") + def _unique_match(candidates: List[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str]]: + """Return one output identity, never first-match-wins across outputs.""" + by_filepath: Dict[str, Dict[str, Any]] = {} + for candidate in candidates: + filepath = candidate.get("filepath") + if not isinstance(filepath, str) or not filepath.strip(): + continue + by_filepath.setdefault(PurePosixPath(filepath).as_posix(), candidate) + if len(by_filepath) != 1: + return None, None + matched = next(iter(by_filepath.values())) + return matched.get("filepath"), matched.get("filename") + + # A nested .pddrc may make the caller's prompt key relative to a deeper + # prompts_dir than architecture.json uses. Match a UNIQUE filename leaf + # instead of assuming the repository prompt root is literally `prompts/`. + # Path-qualified basenames remain guarded by filepath alignment, and a bare + # ambiguous leaf is rejected by _architecture_module_choices before this + # helper is called from get_pdd_file_paths. + prompt_leaf_lower = PurePosixPath(prompt_filename.replace("\\", "/")).name.lower() + leaf_match = _unique_match([ + module + for module in modules + if isinstance(module, dict) + and PurePosixPath( + str(module.get("filename", "")).replace("\\", "/") + ).name.lower() == prompt_leaf_lower + and _aligns(module) + and _borrow_ownership_ok(module) + ]) + if leaf_match[0]: + return leaf_match + # Try basename + language match if provided if basename and language: basename_candidates = _prompt_basename_candidates( basename, context_name=context_name, include_simple_name="/" not in basename, + pddrc_anchor=pddrc_anchor, ) for candidate_basename in basename_candidates: @@ -668,8 +2326,12 @@ def _aligns(module: Dict[str, Any]) -> bool: for module in modules: if not isinstance(module, dict): continue - module_filename = module.get("filename", "") - if module_filename.lower() == expected_filename_lower: + module_filename = str(module.get("filename") or "") + if ( + module_filename.lower() == expected_filename_lower + and _aligns(module) + and _borrow_ownership_ok(module) + ): return module.get("filepath"), module.get("filename") # Nested basenames must not borrow an unrelated flat architecture entry. @@ -679,25 +2341,373 @@ def _aligns(module: Dict[str, Any]) -> bool: simple_filename_lower = f"{basename.split('/')[-1]}_{language}.prompt".lower() matching_modules = [ module for module in modules - if isinstance(module, dict) and module.get("filename", "").lower() == simple_filename_lower + if isinstance(module, dict) + and str(module.get("filename") or "").lower() == simple_filename_lower ] safe_matches = [ module for module in matching_modules - if _module_filepath_matches_basename(module.get("filepath"), basename, context_name=context_name) + if _row_language_matches(module) + and _module_filepath_matches_basename( + module.get("filepath"), basename, context_name=context_name, + pddrc_anchor=architecture_path.parent, + ) + and _borrow_ownership_ok(module) ] if len(safe_matches) == 1: return safe_matches[0].get("filepath"), safe_matches[0].get("filename") + # Canonical architecture normalization derives filename from filepath. + # Consumer repositories may still keep a prompt in a context-specific + # prompts_dir, so the prompt path and normalized architecture filename + # need not share directory segments. A unique, language-compatible + # filepath identity is still safe to use. + expected_extension = get_extension(language).lower() + relative_basename = _relative_basename_for_context(basename, context_name, pddrc_anchor) + target_leaf = PurePosixPath(relative_basename).name + filepath_match = _unique_match([ + module + for module in modules + if isinstance(module, dict) + and not extract_module_from_include( + PurePosixPath( + str(module.get("filename", "")).replace("\\", "/") + ).name + ) + and isinstance(module.get("filepath"), str) + and PurePosixPath(module["filepath"]).stem.lower() == target_leaf.lower() + and ( + not expected_extension + or PurePosixPath(module["filepath"]).suffix.lower() + == f".{expected_extension}" + ) + and _aligns(module) + and _borrow_ownership_ok(module) + ]) + if filepath_match[0]: + return filepath_match + return None, None except (FileNotFoundError, json.JSONDecodeError, TypeError): return None, None +def _contained_architecture_code_path( + project_root: Path, + architecture_filepath: str, +) -> Optional[Path]: + """Resolve a safe repository-relative architecture output path. + + Architecture metadata can be generated or hand-edited. It must never turn a + sync operation into an arbitrary filesystem write. Architecture filepaths use + POSIX separators by contract; absolute paths, parent traversal, backslashes, + and symlink-assisted escapes are rejected so callers can fall back to the + repository's configured output template. + """ + if not isinstance(architecture_filepath, str): + return None + + if architecture_filepath != architecture_filepath.strip(): + return None + raw = architecture_filepath + if not raw or _contains_disallowed_path_text(raw): + return None + # Drive-qualified output metadata (e.g. "D:/x.py", "D:x.py") is POSIX-relative + # but escapes the project root when joined on Windows. Reject it so callers fall + # back to the configured output template. + if PureWindowsPath(raw).drive: + return None + + try: + relative = PurePosixPath(raw) + if ( + not relative.parts + or relative.as_posix() != raw + or relative.is_absolute() + or ".." in relative.parts + or any( + _unsafe_portable_path_component(part) + for part in relative.parts + ) + ): + return None + + resolved_root = project_root.resolve(strict=False) + candidate = resolved_root.joinpath(*relative.parts).resolve(strict=False) + candidate.relative_to(resolved_root) + except (OSError, RuntimeError, ValueError): + return None + return candidate + + +def _governing_output_root(config_anchor: Path) -> Tuple[Path, bool]: + """The single trusted root that every resolved output must live under. + + Authority comes from PROVENANCE, never from the process CWD: the governing + ``.pddrc`` directory, else the ``architecture.json`` directory. Outputs are + always anchored under this root — a parent/sibling-CWD run does not widen the + boundary. Only when neither config exists (a loose, unconfigured tree) does the + root fall back to ``config_anchor`` (which is CWD in that case). Returns + ``(root, has_project_config)``. + """ + pddrc = _find_pddrc_file(config_anchor) + if pddrc is not None: + return pddrc.parent.resolve(strict=False), True + arch = _find_architecture_json(config_anchor) + if arch is not None: + return arch.parent.resolve(strict=False), True + return config_anchor.resolve(strict=False), False + + +def _reanchor_output_to_root( + path: Any, governing_root: Path, has_project_config: bool +) -> Path: + """Anchor an output path under the governing project root. + + A relative path joins the root. An absolute path already inside the root is + left unchanged. An absolute path that a branch anchored at the process CWD + (outside the governing root) is relativised against CWD and re-anchored under + the root, so a parent/sibling-CWD run still writes UNDER the project instead of + beside it. A path that is outside both the root and CWD is left as-is for the + containment check to reject. + """ + p = Path(path) + root_resolved = governing_root.resolve(strict=False) + try: + cwd = Path.cwd().resolve(strict=False) + except (OSError, RuntimeError, ValueError): + cwd = root_resolved + if not p.is_absolute(): + # Relative paths resolve against the CWD. When the CWD IS the governing + # root they already land under it — keep them relative to preserve the + # legacy return contract. From a parent/sibling CWD, re-anchor them under + # the governing root so the write still lands under the project. + if cwd == root_resolved: + return p + return governing_root / p + try: + p_resolved = p.resolve(strict=False) + except (OSError, RuntimeError, ValueError): + return p + try: + p_resolved.relative_to(root_resolved) + return p # already under the governing root + except ValueError: + pass + if has_project_config and cwd != root_resolved: + # An absolute output a branch anchored at the CWD (outside the governing + # root) is re-anchored under the project. + try: + return governing_root / p_resolved.relative_to(cwd) + except (OSError, RuntimeError, ValueError): + pass + return p + + +def _output_path_within_root(path: Any, project_root: Path) -> bool: + """Whether ``path`` resolves (symlinks followed) inside ``project_root`` AND + every component below the root is portable/canonical (R9/R10 parity). + + Containment alone is not enough: a non-portable component (Windows device + name, NTFS ADS colon, drive marker, control char) can survive ``resolve()`` + and stay physically inside the root on POSIX. Rejecting such components here + catches values that reached a sink WITHOUT passing the raw ``.pddrc`` gate + (e.g. a nearer descendant ``.pddrc`` selected by ``construct_paths``). + """ + try: + resolved = Path(path).resolve(strict=False) + root_resolved = project_root.resolve(strict=False) + relative = resolved.relative_to(root_resolved) + except (OSError, RuntimeError, ValueError): + return False + return not any( + _unsafe_portable_path_component(part) or _contains_disallowed_path_text(part) + for part in relative.parts + ) + + +def _ensure_output_within_root( + path: Any, project_root: Path, artifact: str +) -> Any: + """Return ``path`` when contained + portable under the root; else fail closed.""" + if not _output_path_within_root(path, project_root): + raise UnsafeOutputPathError(path, project_root, artifact) + return path + + +def _configured_output_escapes_root(raw: Any, project_root: Path) -> bool: + """Whether a RAW absolute ``.pddrc`` output value points outside ``project_root``. + + Only ABSOLUTE (or Windows-drive) configured values are checked here: an + explicit away-pointing absolute destination must fail closed rather than be + silently re-anchored under the project. Relative values are left to the + provenance-based re-anchoring + containment path (they cannot be told apart + from a legitimate CWD expansion once resolved). + """ + if not isinstance(raw, str) or not raw: + return False + if not (PurePosixPath(raw).is_absolute() or PureWindowsPath(raw).drive): + return False + try: + Path(raw).resolve(strict=False).relative_to(project_root.resolve(strict=False)) + return False + except (OSError, RuntimeError, ValueError): + return True + + +def _configured_output_string_is_unsafe(raw: Any) -> bool: + """Whether a ``.pddrc`` output path/dir/template value is unsafe. + + Applies the same portability/traversal validation the architecture code + filepath gets (R7/R9/R10), but on the RAW configured string BEFORE it is + joined and resolved — so it is independent of the process CWD and catches + parent traversal (``..``) that a later ``Path.resolve()`` would normalize + away. Rejects backslashes, control/format/line-separator characters, Windows + drive markers, parent traversal, and non-portable components (Windows-invalid + characters, reserved device names, NTFS ADS colons, trailing dot/space). + ``{placeholder}`` template segments and a trailing slash (directory form) are + permitted. A None/empty value is ABSENT (a default applies), but any OTHER + present non-string value (int, list, mapping, bool) is malformed and unsafe — + it would otherwise slip past this string validation and later raise inside + ``str``-only path handling, degrading to an uncontained convention fallback. + """ + if raw is None or raw == "": + return False + if not isinstance(raw, str): + return True + if "\\" in raw or _contains_disallowed_path_text(raw): + return True + if PureWindowsPath(raw).drive: + return True + for part in PurePosixPath(raw).parts: + if part in ("/", ""): + continue + if part == "..": + return True + if part.startswith("{") and part.endswith("}"): + continue # whole-segment template placeholder, e.g. {category} + if _unsafe_portable_path_component(part): + return True + return False + + +def _reject_unsafe_output_config( + project_root: Path, + artifact: str, + *raw_values: Any, + reject_absolute: bool = False, +) -> None: + """Fail closed on any unsafe ``.pddrc`` output directory/template value. + + ``reject_absolute`` is set for ``outputs..path`` TEMPLATES: those go + through template normalization, which strips a POSIX leading slash and would + re-anchor an absolute in-project template into a doubled path — so an absolute + template is rejected rather than silently mangled. Directory values + (generate/example/test_output_path) still accept an absolute in-project path. + """ + for raw in raw_values: + if _configured_output_string_is_unsafe(raw) or _configured_output_escapes_root( + raw, project_root + ): + raise UnsafeOutputPathError(raw, project_root, artifact) + if ( + reject_absolute + and isinstance(raw, str) + and (PurePosixPath(raw).is_absolute() or PureWindowsPath(raw).drive) + ): + raise UnsafeOutputPathError(raw, project_root, artifact) + + +def _reject_unsafe_outputs_templates( + outputs_config: Any, project_root: Path +) -> None: + """Fail closed on a malformed or unsafe ``outputs`` mapping. + + The supported shape is ``outputs: {: {path: }}``. A present but + non-mapping ``outputs`` value, or an artifact entry that is present but not a + mapping (e.g. ``code: "src/{name}.py"`` written without the ``path:`` key), is + malformed — it would be silently ignored and degrade to a convention path — so + it fails closed. Absolute template paths are rejected (see ``reject_absolute``). + """ + if outputs_config is None: + return + if not isinstance(outputs_config, dict): + raise UnsafeOutputPathError(outputs_config, project_root, "outputs") + for artifact, entry in outputs_config.items(): + if not isinstance(entry, dict): + # A PRESENT artifact key must map to a `{path: ...}` mapping. A bare + # string, a number, or an explicit `null` (e.g. `code:` / `code: null`) + # is malformed: its key presence suppresses the configured legacy + # fallback downstream, silently degrading to a convention path. + raise UnsafeOutputPathError(entry, project_root, str(artifact)) + # A PRESENT artifact entry must carry a non-empty string `path`. A pathless + # (e.g. `code: {}`) or empty/non-string path is malformed: the mere presence + # of the key suppresses the configured legacy fallback downstream, silently + # degrading to a convention path. Fail closed instead. + _path = entry.get("path") + if not isinstance(_path, str) or not _path: + raise UnsafeOutputPathError(_path, project_root, str(artifact)) + _reject_unsafe_output_config( + project_root, str(artifact), _path, reject_absolute=True + ) + + +def _reject_unsafe_pddrc_output_config(config_anchor: Path) -> None: + """Fail closed EARLY on any unsafe ``.pddrc`` output destination. + + Validated once, before any branch-specific ``.pddrc`` load — whose + ``except ValueError`` / ``except Exception`` config-tolerance would otherwise + swallow the raised :class:`UnsafeOutputPathError` (a ``ValueError`` subclass) + and silently continue with the unsafe destination. This runs inside the + function's top-level ``try`` whose ``except AmbiguousModuleError: raise`` lets + the error propagate. Every context's output settings are checked so an unsafe + destination fails closed regardless of which context the resolution selects. + A malformed ``.pddrc`` is left to the existing per-branch handling. + """ + pddrc_path = _find_pddrc_file(config_anchor) + if pddrc_path is None: + return + try: + config = _load_pddrc_config(pddrc_path) + except (ValueError, OSError): + return + if not isinstance(config, dict): + return + contexts = config.get("contexts", {}) + if not isinstance(contexts, dict): + return + project_root = pddrc_path.parent.resolve(strict=False) + for context in contexts.values(): + if not isinstance(context, dict): + continue + defaults = context.get("defaults", {}) + if not isinstance(defaults, dict): + continue + _reject_unsafe_output_config( + project_root, "code", defaults.get("generate_output_path") + ) + _reject_unsafe_output_config( + project_root, "example", defaults.get("example_output_path") + ) + _reject_unsafe_output_config( + project_root, "test", defaults.get("test_output_path") + ) + _reject_unsafe_outputs_templates(defaults.get("outputs"), project_root) + # A present but non-string `prompts_dir` is malformed: it would reach the + # string-only context-prefix extraction, raise, and degrade to a wrong + # convention path. Fail closed instead (None/empty means "unset"). + _prompts_dir_cfg = defaults.get("prompts_dir") + if _prompts_dir_cfg is not None and not isinstance(_prompts_dir_cfg, str): + raise UnsafeOutputPathError(_prompts_dir_cfg, project_root, "prompts_dir") + + def _architecture_module_choices( architecture_path: Path, basename: str, language: str, + modules: Any = _ARCH_MODULES_UNSET, + context_name: Optional[str] = None, + prompts_root: Optional[Path] = None, ) -> List[str]: """Return the distinct canonical output files a BARE basename maps to. @@ -727,40 +2737,297 @@ def _architecture_module_choices( multi-architecture view resolves filepaths against each source architecture's directory before comparing; see ``agentic_sync._architecture_outputs_by_basename``.) """ - if "/" in basename: - return [] - - try: - with open(architecture_path, "r", encoding="utf-8") as handle: - modules = extract_modules(json.load(handle)) - except (FileNotFoundError, json.JSONDecodeError, TypeError, OSError): - return [] + if modules is _ARCH_MODULES_UNSET: + try: + with open(architecture_path, "r", encoding="utf-8") as handle: + modules = extract_modules(json.load(handle)) + except (FileNotFoundError, json.JSONDecodeError, TypeError, OSError): + return [] if not modules: return [] + # Prompt-root prefix-overlap stripping (R18 F2): when the active prompt root already + # represents the qualified directory (e.g. root `pdd/prompts`, request `prompts/widget`), + # the leading `prompts` overlaps the root — both a bare `widget_python.prompt` row and a + # `prompts/widget_python.prompt` row map to the SAME physical prompt. Canonicalise the + # basename against the prompt root (the SAME overlap transform physical prompt discovery + # uses via `_prompt_relative_parts_for_root`) so ownership/ambiguity is computed on the + # physical identity: otherwise the textual pre-pass would suppress the bare row that + # final selection can still choose, and a genuine two-output ambiguity would slip through. + if prompts_root is not None and "/" in basename: + try: + _stripped_parts = _prompt_relative_parts_for_root( + Path(prompts_root), PurePosixPath(basename) + ) + except (OSError, ValueError): + _stripped_parts = None + if _stripped_parts: + basename = "/".join(_stripped_parts) + + if "/" in basename: + # A path-qualified basename is NOT automatically unambiguous: because a qualified + # basename matches by path-SUFFIX, more than one distinct valid output can align + # (e.g. `app/login/page` matches both `app/login/page.tsx` and + # `src/app/login/page.tsx`). Collect the distinct valid suffix-aligned outputs so + # the caller raises AmbiguousModuleError instead of resolving by architecture row + # order. A single (or zero) match keeps the canonical resolution unchanged. + lang_ext_q = get_extension(language).lower() + # Context-stripped basename parts (SAME transform _module_filepath_matches_basename + # uses), so a context-prefixed qualified basename is owned/counted consistently. + _qualified_basename_parts = [ + p.lower() + for p in Path( + _relative_basename_for_context( + basename, context_name, architecture_path.parent + ) + ).parts + if p + ] + _target_prompt_leaf_q = ( + f"{basename.split('/')[-1].lower()}_{language.lower()}.prompt" + ) + + def _filename_path_owns_qualified(fn_value: Any) -> bool: + """Whether a prompt filename's directory identity HAS the (context-stripped) + qualified basename as a path-suffix (e.g. `nested/widget` owns `nested/widget`, + `src/nested/widget` owns it, but bare `widget` does not).""" + if not fn_value or len(_qualified_basename_parts) <= 1: + return False + fn_norm = PurePosixPath(str(fn_value).replace("\\", "/")) + leaf_stem = fn_norm.name + suffix_tag = f"_{language.lower()}.prompt" + if leaf_stem.lower().endswith(suffix_tag): + leaf_stem = leaf_stem[: -len(suffix_tag)] + fn_parts = [p.lower() for p in fn_norm.parent.parts] + [leaf_stem.lower()] + return fn_parts[-len(_qualified_basename_parts):] == _qualified_basename_parts + + def _filename_names_qualified_exactly(fn_value: Any) -> bool: + """Whether a prompt filename's directory identity EXACTLY equals the + (context-stripped) qualified basename (e.g. `nested/widget` names `nested/widget` + exactly, but the MORE-qualified `src/nested/widget` only path-owns it as a + suffix). An exact-naming row is the module's own explicit mapping, which SELECTION + honours via ``row_names_this_module`` even when its target is unowned.""" + if not fn_value or len(_qualified_basename_parts) < 1: + return False + fn_norm = PurePosixPath(str(fn_value).replace("\\", "/")) + leaf_stem = fn_norm.name + suffix_tag = f"_{language.lower()}.prompt" + if leaf_stem.lower().endswith(suffix_tag): + leaf_stem = leaf_stem[: -len(suffix_tag)] + fn_parts = [p.lower() for p in fn_norm.parent.parts] + [leaf_stem.lower()] + return fn_parts == _qualified_basename_parts + + # Only suppress a LESS-qualified filename (e.g. bare `widget` for `nested/widget`) + # when a MORE-qualified filename that actually path-owns the qualified basename is + # present among the safe, leaf-matching, filepath-aligned rows. With NO such owner, + # every eligible suffix-aligned legacy row is a genuine ambiguity contributor and + # MUST be retained (two bare rows -> two nested outputs is still ambiguous), not + # filtered to an empty set that silently falls back to first-match-wins. + _has_path_owning_filename = False + for _m in modules: + if not isinstance(_m, dict): + continue + _fnv = _m.get("filename") + if ( + isinstance(_fnv, str) + and _fnv != "" + and _safe_architecture_prompt_filename(_fnv) is None + ): + continue + _leaf = PurePosixPath(str(_fnv or "").replace("\\", "/")).name.lower() + if _leaf != _target_prompt_leaf_q or not _filename_path_owns_qualified(_fnv): + continue + _fpv = _m.get("filepath") + if not (isinstance(_fpv, str) and _fpv.strip()): + continue + # The owner flag must be built from the SAME fully-eligible row set the + # ambiguity count uses (R12 F3): a row that path-owns the qualified basename + # by FILENAME but whose FILEPATH is later dropped by the extension or + # containment gate is NOT a real owner. If such an unsafe/escaping row set the + # flag, it would suppress two genuinely-ambiguous valid legacy rows down to an + # empty choice set (silent first-match-wins) instead of raising + # AmbiguousModuleError. So apply extension + containment here too. + if not _module_filepath_matches_basename( + _fpv, basename, context_name=context_name, + pddrc_anchor=architecture_path.parent, + ): + continue + if lang_ext_q and PurePosixPath(_fpv).suffix.lstrip(".").lower() != lang_ext_q: + continue + _root_resolved_q = architecture_path.parent.resolve(strict=False) + _contained_q = _contained_architecture_code_path(architecture_path.parent, _fpv) + if _contained_q is None: + continue + # Context eligibility (R15 F3 / R17 F2): the owner flag must match SELECTION's + # full eligibility, which rejects a row whose code target lies in a SIBLING + # context's territory — checked against BOTH the LEXICAL filepath AND its + # RESOLVED project-relative identity (an in-project symlink, e.g. + # `backend/nested/widget.py` -> `frontend/nested/widget.py`, passes containment + # yet physically lands in a sibling context). A lexical-only check would let + # such a symlinked owner set the flag, suppress two valid legacy rows, then be + # rejected by selection -> (None,None) silent fall-through instead of the + # required AmbiguousModuleError. + _identities_q = [_fpv] + try: + _resolved_rel_q = _contained_q.relative_to(_root_resolved_q).as_posix() + except ValueError: + _resolved_rel_q = None + if _resolved_rel_q and _resolved_rel_q != PurePosixPath( + _fpv.replace("\\", "/") + ).as_posix(): + _identities_q.append(_resolved_rel_q) + if any( + _filepath_owned_by_other_context( + _identity_q, context_name, architecture_path.parent + ) + for _identity_q in _identities_q + ): + continue + # Current-context eligibility (R16 F1): SELECTION accepts a heuristic borrow + # ONLY when its target is inside the resolving context's OWN territory + # (`_context_owned_filepath`) OR the row's filename EXACTLY names this module + # (`row_names_this_module`). A MORE-qualified owner (e.g. `src/nested/widget` + # for `nested/widget`) whose target is unowned/out-of-context is NOT selectable, + # so it must not set the owner flag — otherwise it suppresses two valid legacy + # rows while selection returns nothing, again falling through silently instead + # of raising AmbiguousModuleError. + if not ( + _filename_names_qualified_exactly(_fnv) + or _context_owned_filepath(_fpv, context_name, architecture_path.parent) + ): + continue + _has_path_owning_filename = True + break + + qualified: set = set() + for module in modules: + if not isinstance(module, dict): + continue + # Exclude rows with an unsafe architecture FILENAME (as the bare branch and + # resolution do), so an invalid row cannot inflate the count and falsely block + # a valid mapping. + filename_value_q = module.get("filename") + if ( + isinstance(filename_value_q, str) + and filename_value_q != "" + and _safe_architecture_prompt_filename(filename_value_q) is None + ): + # A non-empty STRING filename that fails validation (incl. whitespace-only) + # is unsafe/ineligible in selection too — skip it so it cannot inflate the + # count. An EMPTY string and null/non-string stay filepath-stem-eligible. + continue + # Mirror selection eligibility: a row whose PROMPT filename names a DIFFERENT + # module (its leaf is a recognized prompt filename but not this module's) is not + # a candidate for this basename — only a filename that names this module, or a + # null/non-prompt filename (filepath-stem eligible), counts. Otherwise a + # coincidentally suffix-aligned foreign row raises a false AmbiguousModuleError + # before selection uniquely resolves the named row. + filename_leaf_q = PurePosixPath( + str(filename_value_q or "").replace("\\", "/") + ).name.lower() + target_prompt_leaf = f"{basename.split('/')[-1].lower()}_{language.lower()}.prompt" + if ( + filename_leaf_q != target_prompt_leaf + and filename_value_q + and extract_module_from_include(filename_leaf_q) + ): + continue + # When the row's filename NAMES this module (its leaf matched the target + # prompt leaf) AND the (context-stripped) basename is PATH-QUALIFIED, the + # qualified basename must be a path-SUFFIX of the filename's directory + # identity: a LESS-qualified filename (e.g. `widget` for a `nested/widget` + # request) does not own the qualified module, so it must not inflate the + # ambiguity count against the row whose filename actually path-matches + # (e.g. `nested/widget_python.prompt`). A same- or more-qualified filename + # (e.g. `nested/widget` or `src/nested/widget`) still owns it and remains a + # genuine ambiguity contributor. Uses the SAME context-relative basename + # as _module_filepath_matches_basename so context-prefixed qualified + # ambiguity is detected consistently. + if ( + _has_path_owning_filename + and filename_value_q + and filename_leaf_q == target_prompt_leaf + and len(_qualified_basename_parts) > 1 + and not _filename_path_owns_qualified(filename_value_q) + ): + continue + filepath_value = module.get("filepath") + if not isinstance(filepath_value, str) or not filepath_value.strip(): + continue + # Cheap pure-string checks FIRST — use the SAME context-relative basename and + # anchor as final resolution, so a context-prefixed qualified basename (stripped + # during resolution) is counted consistently instead of evading detection under + # the raw prefix — then pay the filesystem containment resolve only for the rows + # that already match, not every registry row. + if not _module_filepath_matches_basename( + filepath_value, basename, + context_name=context_name, pddrc_anchor=architecture_path.parent, + ): + continue + if lang_ext_q and PurePosixPath(filepath_value).suffix.lstrip(".").lower() != lang_ext_q: + continue + # Validate the RAW filepath (not a stripped copy): a trailing-space or + # otherwise noncanonical value that resolution rejects must not be counted. + if _contained_architecture_code_path(architecture_path.parent, filepath_value) is None: + continue + qualified.add(PurePosixPath(filepath_value).as_posix()) + return sorted(qualified) + target_filename = f"{basename}_{language}.prompt".lower() lang_ext = get_extension(language).lower() distinct: set = set() for module in modules: if not isinstance(module, dict): continue - filepath = str(module.get("filepath") or "").strip() + filepath_value = module.get("filepath") + # A non-string filepath is malformed metadata, not a real output: skip it so + # it cannot be stringified (e.g. 123 -> "123") into a bogus distinct target + # that falsely raises AmbiguousModuleError and blocks a valid mapping. + if not isinstance(filepath_value, str): + continue + filepath = filepath_value.strip() if not filepath: continue - filename = module.get("filename", "") or "" + filename_value = module.get("filename") + # A non-empty STRING filename that fails validation (unsafe path, OR whitespace-only, + # which selection also treats as ineligible) is excluded from ambiguity counting. An + # EMPTY string and null/non-string are left to the filepath-stem branch (the module + # is filepath-owned). Cheap string check before the filesystem containment resolve. + if ( + isinstance(filename_value, str) + and filename_value != "" + and _safe_architecture_prompt_filename(filename_value) is None + ): + continue + filename = filename_value if isinstance(filename_value, str) else "" if filename.endswith("_LLM.prompt"): continue leaf = Path(filename).name - if leaf.lower() == target_filename: - distinct.add(Path(filepath).as_posix()) - elif not extract_module_from_include(leaf): + matched = leaf.lower() == target_filename + if not matched and not extract_module_from_include(leaf): # Non-prompt architecture filename (e.g. `GitHubAppCTA.tsx`): the module # is identified by its filepath stem instead. Gate on the language # extension so a same-stem file in another language is not conflated. suffix = Path(filepath).suffix.lstrip(".").lower() - if Path(filepath).stem == basename and lang_ext and suffix == lang_ext: - distinct.add(Path(filepath).as_posix()) + matched = bool( + Path(filepath).stem.lower() == basename.lower() + and lang_ext + and suffix == lang_ext + ) + if not matched: + continue + # Only NOW — for the handful of filename/stem matches, not every module — pay + # the filesystem containment resolution. An unsafe OUTPUT path (absolute, ``..``, + # backslash, Windows drive, symlink escape) is rejected before generation, so it + # must not count toward ambiguity: otherwise a valid ``foo -> src/foo.py`` plus a + # same-filename ``foo -> ../../outside/foo.py`` read as two targets and raise + # AmbiguousModuleError, blocking the valid module. + # Validate the RAW filepath so a trailing-space / noncanonical value (which + # resolution rejects) is not canonicalized by the earlier strip and counted. + if _contained_architecture_code_path(architecture_path.parent, filepath_value) is None: + continue + distinct.add(Path(filepath).as_posix()) return sorted(distinct) @@ -812,7 +3079,19 @@ class SyncLock: def __init__(self, basename: str, language: str): self.basename = basename self.language = language - self.lock_file = get_locks_dir() / f"{_safe_basename(basename)}_{language.lower()}.lock" + locks_root = get_locks_dir() + lock_file = locks_root / ( + f"{_safe_lock_component(basename)}_" + f"{_safe_lock_component(str(language).lower())}.lock" + ) + # Defense in depth: the sanitized components carry no separators, so the + # lock file can never resolve outside the locks directory. Assert it so a + # future change to the naming scheme fails loudly instead of silently + # letting a lock escape onto an arbitrary out-of-tree path. + resolved_root = locks_root.resolve(strict=False) + if resolved_root not in lock_file.resolve(strict=False).parents: + raise UnsafeOutputPathError(lock_file, resolved_root, "lock") + self.lock_file = lock_file self.fd = None self.current_pid = os.getpid() @@ -959,15 +3238,23 @@ def _resolve_prompts_root(prompts_dir: str) -> Path: def _relative_basename_for_context( basename: str, context_name: Optional[str], + pddrc_anchor: Optional[Path] = None, *, pddrc_path: Optional[Path] = None, config: Optional[Dict[str, Any]] = None, ) -> str: - """Strip context-specific prefixes from basename when possible.""" + """Strip context-specific prefixes from basename when possible. + + ``pddrc_anchor`` anchors the ``.pddrc`` lookup at the project instead of the + process CWD; without it a resolution driven from a parent/sibling directory with + an absolute prompts root cannot strip the context prefix, so a path-qualified + basename fails to align with its canonical architecture filepath and silently + falls back to the default output path. + """ if not context_name: return basename - pddrc_path = pddrc_path or _find_pddrc_file() + pddrc_path = pddrc_path or _find_pddrc_file(pddrc_anchor) if not pddrc_path: return basename @@ -1136,24 +3423,375 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts """ import logging logger = logging.getLogger(__name__) - logger.info(f"get_pdd_file_paths called: basename={basename}, language={language}, prompts_dir={prompts_dir}") try: # Use construct_paths to get configuration-aware paths + if ( + not isinstance(prompts_dir, str) + or not prompts_dir + or prompts_dir != prompts_dir.strip() + or _contains_disallowed_path_text(prompts_dir) + ): + raise UnsafePromptPathError(Path(str(prompts_dir)), Path.cwd()) prompts_root = _resolve_prompts_root(prompts_dir) + if _safe_architecture_prompt_filename(basename) is None: + raise UnsafePromptPathError( + Path(basename), prompts_root.resolve(strict=False) + ) + if _safe_prompt_language(language) is None: + raise UnsafePromptPathError( + Path(str(language)), prompts_root.resolve(strict=False) + ) + logger.info( + "get_pdd_file_paths called: basename=%s, language=%s, prompts_dir=%r", + basename, + language, + prompts_dir, + ) name = basename.split('/')[-1] if '/' in basename else basename - resolved_context_name = _resolve_context_name_for_basename(basename, context_override) - construct_paths_basename = _relative_basename_for_context(basename, resolved_context_name) - template_basename = construct_paths_basename # Anchor configuration lookups (architecture.json, .pddrc) at the resolved # prompts root so nested subprojects (e.g. extensions//prompts/) find # their own architecture.json/.pddrc rather than falling back to the # caller's CWD, which would honor configured output paths inconsistently. + # Established BEFORE context/prefix resolution so those lookups are also + # CWD-independent (parent-CWD runs must still detect the context and strip the + # basename prefix instead of duplicating it in example/test templates). prompts_root_anchor = prompts_root if prompts_root.is_absolute() else prompts_root.resolve() + # When an absolute custom prompt root lies OUTSIDE any project (searching up from it + # finds neither architecture.json nor .pddrc), config/architecture discovery would + # start from that external root and silently miss the caller's project. Anchor + # config lookups at the project (CWD) instead in that case; a nested subproject + # prompt root (which DOES find its own config up-tree) still anchors at itself. + config_anchor = prompts_root_anchor + if ( + _find_pddrc_file(prompts_root_anchor) is None + and _find_architecture_json(prompts_root_anchor) is None + ): + config_anchor = Path.cwd() + + # The single trusted project root every returned output must live under. + # Authority is from PROVENANCE (the governing .pddrc / architecture.json + # directory), NEVER the process CWD — so a parent/sibling-CWD run cannot + # widen the boundary and authorise a write beside the real project (R16). + _governing_root, _has_project_config = _governing_output_root(config_anchor) + + # R16: reject unsafe .pddrc output destinations up front (CWD-independent, + # on the raw configured value) so parent traversal, non-portable components + # (device names, ADS colons, drive markers), or control characters fail + # closed before a branch-local `except ValueError` can swallow the error. + _config_pddrc = _find_pddrc_file(config_anchor) + _reject_unsafe_pddrc_output_config(config_anchor) + + # Explicit prompt provenance (R18 F3): set True only when prompt discovery + # (_find_prompt_file) actually RETURNS a path — i.e. a real on-disk prompt. A + # convention-reconstructed path (missing-prompt branch) leaves this False, so the + # discovery-only approved-alias exception cannot be granted to a dangling alias + # that discovery itself excluded. A mutable holder captured by the finalizer. + _prompt_provenance = {"discovered": False} + + def _discovered_alias_within_repo(_prompt: Any, _prompt_resolved: Path) -> bool: + """Whether a DISCOVERED approved-alias prompt is contained in the project repo. + + #1991 approved aliases let a discovered prompt SYMLINK target a canonical + prompt elsewhere in the SAME enclosing git repository (e.g. a nested + subproject aliasing a shared prompt). This grants that exception, but only + when it cannot be abused: + + - R14 F1: the repository boundary is anchored at the PROJECT (the prompts root + / governing root — real directories), NOT at the prompt's own resolved + location. Every physical ANCESTOR of the prompt must stay inside the project + (``os.path.realpath`` of the parent, with the LEAF symlink NOT followed), so + a symlinked parent cannot redirect ``git -C`` into a FOREIGN repository whose + boundary would then vacuously "contain" the alias target. + - The prompt itself must be a leaf symlink (the alias), and its resolved target + must live inside the git worktree enclosing the project. + + The DISCOVERY-only gate (R14 F2) is enforced by the caller; a configured + ``outputs.prompt.path`` never reaches here. + """ + if not Path(_prompt).is_symlink(): + return False + _prompt_lexical = Path(os.path.abspath(_prompt)) + try: + _parent_real = Path(os.path.realpath(_prompt_lexical.parent)) + except (OSError, ValueError): + return False + _anchor: Optional[Path] = None + for _root in (prompts_root_anchor, _governing_root): + try: + _root_real = Path(os.path.realpath(_root)) + except (OSError, ValueError): + continue + if _parent_real == _root_real or _root_real in _parent_real.parents: + _anchor = _root_real + break + if _anchor is None: + return False + _repo = _enclosing_git_root(_anchor) + if _repo is None: + return False + _repo_real = os.path.realpath(str(_repo)) + try: + _prompt_resolved.relative_to(_repo_real) + except (OSError, RuntimeError, ValueError): + return False + # Every physical hop must stay inside the repository, not just the terminal + # target (R15 F1): an intermediate external symlink is rejected even if it + # currently re-enters the repo. + return _symlink_chain_within_root(_prompt, _repo_real) + + def _finalize_output_paths( + paths: Dict[str, Path], *, prompt_from_config: bool = False + ) -> Dict[str, Path]: + # Re-anchor CWD-relative/absolute outputs UNDER the governing root, then + # fail closed if the result escapes it or carries a non-portable + # component (R16). The prompt is held to the prompts root (R8): an + # outputs.prompt.path template must not return a prompt outside it. + # + # ``prompt_from_config`` is the EXPLICIT provenance flag (R15 F2): True only + # when ``paths["prompt"]`` was produced by a configured ``outputs.prompt.path`` + # template, False when it is the discovered/convention prompt. The + # discovery-only approved-alias exception below keys off this flag, NOT off + # final-path equality — a configured destination that merely coincides with a + # discovered alias path must NOT inherit the discovery privilege. + _governing_resolved = _governing_root.resolve(strict=False) + for _artifact in ("code", "example", "test"): + _candidate = paths.get(_artifact) + if _candidate is not None: + # A template that expanded to nothing (e.g. `{category}` for a + # flat basename -> ".") or that still carries an unexpanded + # `{placeholder}` is not a real artifact FILE — reject it rather + # than return the project root directory or a literal brace path. + if "{" in str(_candidate) or "}" in str(_candidate): + raise UnsafeOutputPathError(_candidate, _governing_root, _artifact) + _reanchored = _reanchor_output_to_root( + _candidate, _governing_root, _has_project_config + ) + paths[_artifact] = _reanchored + _ensure_output_within_root( + _reanchored, _governing_root, _artifact + ) + _resolved_artifact = Path(_reanchored).resolve(strict=False) + if _resolved_artifact == _governing_resolved: + raise UnsafeOutputPathError(_reanchored, _governing_root, _artifact) + # R13 F3: an EXISTING non-regular-file destination (a directory, or a + # fifo/socket/device) is not a writable artifact FILE — returning it + # would surface downstream as an IsADirectoryError or a bogus + # "file exists" sync decision. Reject it with a clear path error. A + # nonexistent path (to be generated) or a regular file / contained + # symlink-to-regular-file is still allowed. + if _resolved_artifact.exists() and not _resolved_artifact.is_file(): + raise UnsafeOutputPathError(_reanchored, _governing_root, _artifact) + # R18 F1: an artifact path must not have been reached THROUGH an + # out-of-tree hop either — a configured output like `link/back/foo.py` + # where `link` -> /outside -> back-into-repo passes the terminal check + # yet lets a later retarget of the external node redirect the write + # outside the project. Apply the same every-hop walk as prompts, gated + # by a cheap symlink pre-check. + if _path_has_symlink(_reanchored): + _art_roots = _hop_trust_roots( + _governing_root, _governing_resolved, + _enclosing_git_root(_governing_root), + ) + if not _symlink_chain_within_root(_reanchored, _art_roots): + raise UnsafeOutputPathError(_reanchored, _governing_root, _artifact) + # Rebuild test_files from the ANCHORED test directory rather than trusting + # a list globbed before anchoring (which, from a parent CWD, would glob the + # PARENT's siblings and hand nonexistent nested paths to the runner). Keep + # only siblings that exist and stay contained; always keep the primary path. + _primary_test = paths.get("test") + if isinstance(_primary_test, Path): + _rebuilt = [_primary_test] + _test_dir = _primary_test.parent + if _test_dir.is_dir(): + _stem = glob.escape(_primary_test.stem) + _suffix = _primary_test.suffix + for _sib in sorted(_test_dir.glob(f"{_stem}*{_suffix}")): + if _sib == _primary_test: + continue + if _output_path_within_root(_sib, _governing_root): + _rebuilt.append(_sib) + paths["test_files"] = _rebuilt + elif isinstance(paths.get("test_files"), list): + _contained_tfs = [] + for _tf in paths["test_files"]: + if isinstance(_tf, Path): + _tf = _reanchor_output_to_root( + _tf, _governing_root, _has_project_config + ) + if not _output_path_within_root(_tf, _governing_root): + continue + _contained_tfs.append(_tf) + paths["test_files"] = _contained_tfs + _prompt = paths.get("prompt") + if _prompt is not None: + # Discovery provenance (R14 F2 / R15 F2): the approved-alias exception below + # is DISCOVERY-ONLY, keyed on the EXPLICIT `prompt_from_config` flag threaded + # from configuration parsing — never on final-path equality. A configured + # `outputs.prompt.path` destination (even one that coincides with a + # discovered alias path) must obey the normal configured-path policy + # (contained in the prompts root / governing project) and may NOT inherit + # discovery's enclosing-repository alias acceptance. The exception ALSO + # requires the prompt to have been ACTUALLY returned by discovery (R18 F3): + # a convention-reconstructed dangling alias — one discovery excluded because + # its target does not exist — must NOT receive the approved-alias privilege. + _is_discovered_prompt = (not prompt_from_config) and _prompt_provenance["discovered"] + # A CONFIGURED outputs.prompt.path (Issue #237, e.g. `custom/prompts/`) is + # GOVERNING-ROOT-relative, not CWD-relative: anchor it under the governing + # root the same way outputs are, so a parent/sibling-CWD run still resolves + # it under the project. A DISCOVERED prompt, in contrast, is already the + # real CWD-relative on-disk path (e.g. `extensions/app/prompts/foo.prompt` + # from a parent CWD); re-anchoring it against the subproject governing root + # would DOUBLE the subproject prefix and break `_resolve_meta_dir`'s walk-up + # to the subproject `.pddrc`. Only re-anchor the configured case. + if prompt_from_config: + _prompt = _reanchor_output_to_root( + _prompt, _governing_root, _has_project_config + ) + paths["prompt"] = _prompt + # An outputs.prompt.path template that kept an unexpanded/unsupported + # `{placeholder}`, or expanded to nothing (`{category}` for a flat + # basename -> the project root directory), is not a real prompt FILE — + # reject it rather than return a literal-brace path or a directory (R16). + if "{" in str(_prompt) or "}" in str(_prompt): + raise UnsafePromptPathError(Path(_prompt), prompts_root_anchor) + # Resolve ONCE, GUARDED (R14 F3): a cyclic/looping configured prompt + # symlink makes resolve() raise RuntimeError/OSError. Fail CLOSED as + # UnsafePromptPathError (re-raised past the broad fallback) rather than let + # the raw error propagate to the outer handler, which would silently + # discard the configured paths or leak the exception. The single resolved + # value is reused by every check below. + try: + _prompt_resolved = Path(_prompt).resolve(strict=False) + except (OSError, RuntimeError, ValueError): + raise UnsafePromptPathError(Path(_prompt), prompts_root_anchor) + if _prompt_resolved == _governing_resolved: + raise UnsafePromptPathError(Path(_prompt), prompts_root_anchor) + # R13 F3: an EXISTING non-regular-file prompt destination (e.g. an + # `outputs.prompt.path` pointing at a real directory) is not a prompt + # FILE — reject it rather than hand back a directory a later read/update + # would choke on. A discovered approved alias is a symlink to a regular + # file (is_file() follows the link) and stays allowed. + if _prompt_resolved.exists() and not _prompt_resolved.is_file(): + raise UnsafePromptPathError(Path(_prompt), prompts_root_anchor) + # A nearer descendant .pddrc (governing the resolved prompt's own + # subtree) may carry output values the up-front gate at config_anchor + # never saw; validate its RAW values too so a normalized-away `..` or + # a non-portable/non-string field fails closed (R16). Skip the reload + # when it is the SAME .pddrc already validated above (the common case), + # so duplicate architecture rows don't cause repeated config loads. + _prompt_pddrc = _find_pddrc_file(Path(_prompt).parent) + if _prompt_pddrc is not None and _prompt_pddrc != _config_pddrc: + _reject_unsafe_pddrc_output_config(Path(_prompt).parent) + # R8: the returned prompt must not escape the project — an + # outputs.prompt.path template must not hand back a FOREIGN prompt a + # later `update` would overwrite. It is safe when it resolves inside + # the prompts root OR anywhere inside the governing project (Issue + # #237 lets outputs.prompt.path point at a custom in-project prompts + # location such as `custom/prompts/`, which is legitimate); only a + # path outside both is rejected. Resolve so a trusted in-root symlink + # alias (prompts -> pdd/prompts) is preserved. + _prompt_ok = False + for _root in (prompts_root_anchor, _governing_root): + try: + _prompt_resolved.relative_to(_root.resolve(strict=False)) + _prompt_ok = True + break + except (OSError, RuntimeError, ValueError): + continue + if _prompt_ok and _path_has_symlink(_prompt): + # Even a prompt resolving inside the prompts root / governing project + # must not have been reached through an out-of-tree hop (R16 F2 / R17 + # F1): an intermediate external symlink that re-enters is rejected. The + # trusted boundary is the enclosing repository, or (non-repository) the + # prompts root plus its lexical location so a trusted top-level + # prompt-root symlink is still honoured. + _hop_repo = _enclosing_git_root(_governing_root) or _enclosing_git_root( + prompts_root_anchor + ) + # Use the LEXICAL prompts root (prompts_root), not the already-resolved + # prompts_root_anchor, so a trusted top-level prompt-root symlink + # (prompts -> pdd/prompts) is honoured in a non-repository project (R17 F1). + _hop_roots = _hop_trust_roots( + prompts_root, prompts_root_anchor.resolve(strict=False), _hop_repo + ) + if not _symlink_chain_within_root(_prompt, _hop_roots): + _prompt_ok = False + if not _prompt_ok and not ( + _is_discovered_prompt + and _discovered_alias_within_repo(_prompt, _prompt_resolved) + ): + raise UnsafePromptPathError(Path(_prompt), prompts_root_anchor) + return paths + + resolved_context_name = _resolve_context_name_for_basename( + basename, context_override, pddrc_anchor=config_anchor + ) + construct_paths_basename = _relative_basename_for_context( + basename, resolved_context_name, config_anchor + ) + # Anchored (CWD-independent) template basename for the #1985 global-sync path. + template_basename = construct_paths_basename + # Issue #225: Check architecture.json for filepath FIRST - arch_path = _find_architecture_json(prompts_root_anchor) + arch_path = _find_architecture_json(config_anchor) + # Parse the architecture ONCE and thread this immutable snapshot through + # ambiguity detection, prompt discovery, and code-path selection. Reading it + # separately per phase lets an atomic rewrite of architecture.json between + # phases pair a prompt from the old registry with a code target from the new + # one (torn prompt/code pair); one snapshot also avoids the redundant parse. + arch_modules: Any = _ARCH_MODULES_UNSET + if arch_path: + try: + with open(arch_path, "r", encoding="utf-8") as _arch_handle: + _arch_data = json.load(_arch_handle) + except FileNotFoundError: + # Raced away between discovery and open — treat as no registry. + _arch_data = _ARCH_MODULES_UNSET + arch_modules = None + except (json.JSONDecodeError, ValueError, TypeError, OSError) as _arch_err: + # Present but unreadable/malformed: fail closed rather than downgrade to + # an empty registry and resolve at convention fallback paths. + raise MalformedArchitectureError(arch_path, _arch_err) from _arch_err + else: + # A present architecture.json must be a SUPPORTED shape: a bare module + # list or an object. A dict MAY omit ``modules`` (a legitimately empty + # registry), but a non-list ``modules`` value or a top-level scalar is + # malformed schema — fail closed instead of silently treating valid JSON + # of the wrong shape as an empty registry and resolving at fallback paths. + _valid_shape = isinstance(_arch_data, list) or ( + isinstance(_arch_data, dict) + and ( + "modules" not in _arch_data + or isinstance(_arch_data.get("modules"), list) + ) + ) + if not _valid_shape: + raise MalformedArchitectureError( + arch_path, + f"unsupported architecture schema (top-level " + f"{type(_arch_data).__name__})", + ) + # Every module entry must be an object. extract_modules silently discards + # non-object entries, which would let a corrupted registry fall through to + # convention paths instead of failing closed. + _module_list = ( + _arch_data if isinstance(_arch_data, list) + else _arch_data.get("modules", []) + ) + if any(not isinstance(_entry, dict) for _entry in _module_list): + raise MalformedArchitectureError( + arch_path, "modules list contains a non-object entry" + ) + arch_modules = extract_modules(_arch_data) + prompt_ownership_roots: Tuple[Path, ...] = (prompts_root_anchor,) + if arch_path: + prompt_ownership_roots = _architecture_prompt_roots( + prompts_root_anchor, + arch_path, + ) # Issue #1677: fail fast on an ambiguous BARE basename BEFORE resolving a # prompt or falling back to a .pddrc default path. A short name such as @@ -1161,18 +3799,28 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts # more than one architecture module must not be resolved by silent # first-match-wins or written to a generic `/page.tsx`. if arch_path: - ambiguous_choices = _architecture_module_choices(arch_path, basename, language) + ambiguous_choices = _architecture_module_choices( + arch_path, basename, language, modules=arch_modules, + context_name=resolved_context_name, prompts_root=prompts_root, + ) if len(ambiguous_choices) > 1: raise AmbiguousModuleError(basename, language, ambiguous_choices) # Issue #1169: Use _find_prompt_file for authoritative prompt resolution. # This handles case-insensitive matching, nested subdirectories, and # architecture.json hints in a single code path. - resolved_prompt = _find_prompt_file(basename, language, prompts_root, arch_path, context_override=context_override) + resolved_prompt = _find_prompt_file(basename, language, prompts_root, arch_path, context_override=context_override, arch_modules=arch_modules) + _prompt_provenance["discovered"] = bool(resolved_prompt) if resolved_prompt: prompt_path = str(resolved_prompt) try: - relative_prompt = resolved_prompt.resolve().relative_to(prompts_root.resolve()) + # Use the LEXICAL prompt path (os.path.abspath), not resolve(): an + # approved #1991 alias (prompts/nested/foo -> canonical/foo) keeps its + # logical nested identity, and a parent/sibling-CWD run still yields a + # path relative to the prompts root. + relative_prompt = Path(os.path.abspath(resolved_prompt)).relative_to( + Path(os.path.abspath(prompts_root)) + ) prompt_stem = relative_prompt.stem suffix = f"_{language}" if prompt_stem.lower().endswith(suffix.lower()): @@ -1180,8 +3828,11 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts discovered_basename = ( relative_prompt.parent / prompt_stem ).as_posix() + # Anchor the context strip at config_anchor so the DISCOVERED physical + # basename (e.g. `nested/foo`) survives a parent/sibling-CWD run and the + # `{category}` template resolves to `src/nested/foo.py`, not `src/foo.py`. template_basename = _relative_basename_for_context( - discovered_basename, resolved_context_name + discovered_basename, resolved_context_name, config_anchor ) except ValueError: pass @@ -1197,7 +3848,9 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts # directory segments already present at the tail of prompts_root (a deep # prompts_dir passed by sync_main, or a context prefix) are stripped so they # are not duplicated. - relative_basename = _relative_basename_for_context(basename, resolved_context_name) + relative_basename = _relative_basename_for_context( + basename, resolved_context_name, config_anchor + ) rel_dir_parts = Path(relative_basename).parts[:-1] root_parts = prompts_root.parts overlap = 0 @@ -1210,8 +3863,8 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts prompt_path = str(prompts_root.joinpath(*effective_dir_parts, prompt_filename)) else: prompt_path = str(prompts_root / prompt_filename) - pddrc_path = _find_pddrc_file(prompts_root_anchor) - if pddrc_path and not effective_dir_parts: + pddrc_path = _find_pddrc_file(config_anchor) + if pddrc_path: try: config = _load_pddrc_config(pddrc_path) context_name = ( @@ -1222,11 +3875,20 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts context_config = config.get('contexts', {}).get(context_name or '', {}) prompts_dir_config = context_config.get('defaults', {}).get('prompts_dir', '') if prompts_dir_config: - from pdd.construct_paths import _extract_prefix_from_prompts_dir - prefix = _extract_prefix_from_prompts_dir(prompts_dir_config) + prefix = _context_prefix_for_prompts_root( + prompts_dir_config, + pddrc_path, + prompts_root_anchor, + ) prompts_root_ends_with_prefix = prefix and prompts_root.parts[-len(Path(prefix).parts):] == Path(prefix).parts - if prefix and not prompts_root_ends_with_prefix and not (basename == prefix or basename.startswith(prefix + '/')): - prompt_path = str(prompts_root / prefix / prompt_filename) + if prefix and not prompts_root_ends_with_prefix: + prompt_path = str( + prompts_root.joinpath( + *Path(prefix).parts, + *effective_dir_parts, + prompt_filename, + ) + ) except ValueError: pass @@ -1249,37 +3911,68 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts arch_path, prompt_filename_for_lookup, basename=basename, - language=language + language=language, + prompt_path=prompt_path_obj, + prompts_root=prompts_root, + prompt_roots=prompt_ownership_roots, + resolved_context_name=resolved_context_name, + modules=arch_modules, ) if arch_filepath: - logger.info(f"Found filepath in architecture.json: {arch_filepath}") extension = get_extension(language) # Resolve filepath relative to architecture.json's directory (project root) - project_root = arch_path.parent - code_path = project_root / arch_filepath + project_root = arch_path.parent.resolve(strict=False) + contained_code_path = _contained_architecture_code_path(project_root, arch_filepath) + if contained_code_path is None: + logger.warning( + "Ignoring unsafe architecture.json filepath for %s: %r", + basename, + arch_filepath, + ) + arch_filepath = None + else: + # Containment (and territory ownership) is validated against the + # RESOLVED target, but the returned code path preserves + # architecture.json's authoritative validated filepath: an + # in-project symlink in the metadata is not silently rewritten to + # its physical target (which would lose filepath authority and + # re-point if the alias later moved). + arch_filepath = PurePosixPath(arch_filepath).as_posix() + logger.info( + "Found filepath in architecture.json: %r", arch_filepath + ) + code_path = project_root / arch_filepath + + if arch_filepath: code_stem = code_path.stem # Get configured directories from .pddrc if available - pddrc_path = _find_pddrc_file(prompts_root_anchor) + pddrc_path = _find_pddrc_file(config_anchor) example_dir = "examples/" test_dir = "tests/" generate_dir = "" + outputs_config: Dict[str, Any] = {} if pddrc_path: try: config = _load_pddrc_config(pddrc_path) context_name = context_override or resolved_context_name if not context_name: - arch_context_path = project_root / arch_filepath + arch_context_path = code_path context_name = ( _detect_context(arch_context_path, config, None) or _detect_context(Path.cwd(), config, None) ) context_config = config.get('contexts', {}).get(context_name or '', {}) defaults = context_config.get('defaults', {}) - example_dir = defaults.get('example_output_path', 'examples/') - test_dir = defaults.get('test_output_path', 'tests/') - generate_dir = defaults.get('generate_output_path', '') + # `or default` (not just .get default) so an explicit null + # value falls back consistently instead of becoming None. + example_dir = defaults.get('example_output_path') or 'examples/' + test_dir = defaults.get('test_output_path') or 'tests/' + generate_dir = defaults.get('generate_output_path') or '' + configured_outputs = defaults.get('outputs', {}) + if isinstance(configured_outputs, dict): + outputs_config = configured_outputs if example_dir and not example_dir.endswith('/'): example_dir = example_dir + '/' if test_dir and not test_dir.endswith('/'): @@ -1298,27 +3991,65 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts # example/test stem from the canonical code path so the artifacts are # distinct per module. Unique leaves keep the flat stem (backward compat). example_stem = code_stem - if arch_path and len(_architecture_module_choices(arch_path, name, language)) > 1: + if arch_path and len(_architecture_module_choices(arch_path, name, language, modules=arch_modules, prompts_root=prompts_root)) > 1: example_stem = _safe_basename(Path(arch_filepath).with_suffix("").as_posix()) + # #1985 global-sync builds the base architecture artifact paths. artifacts = _architecture_artifact_paths( project_root, arch_filepath_path, example_stem, extension, generate_dir, example_dir, test_dir, ) + configured_example = False + configured_test = False + + # architecture.json owns the code destination, but exact .pddrc + # example/test templates still own those artifact destinations. + # Do this before legacy basename-existing-file preferences so a + # configured path is never silently replaced by a conventional one. + if outputs_config: + # Use the DISCOVERED physical prompt identity (template_basename, + # e.g. `nested/foo`) so an example/test `{category}` template keeps + # the nested prefix (examples/nested/foo_example.py) instead of + # collapsing to the bare leaf — matching the non-architecture branch. + template_paths = _generate_paths_from_templates( + basename=template_basename, + language=language, + extension=extension, + outputs_config=outputs_config, + prompt_path=prompt_path, + ) + for artifact in ("example", "test"): + if artifact not in outputs_config or artifact not in template_paths: + continue + configured_path = template_paths[artifact] + if not configured_path.is_absolute(): + configured_path = project_root / configured_path + artifacts[artifact] = configured_path + if artifact == "example": + configured_example = True + else: + configured_test = True + artifacts["test_files"] = [configured_path] # If the flattened prompt basename already has corresponding example/test # artifacts, prefer those over the architecture filepath stem. This keeps # command summaries and sync behavior aligned with repos that intentionally # namespace files such as lib_sse_example.ts or test_api_route.ts. if name != code_stem and example_stem == code_stem: - basename_example_path = project_root / f"{example_dir}{name}_example{_dot(extension)}" - basename_test_path = project_root / f"{test_dir}test_{name}{_dot(extension)}" + basename_example_path = _find_named_file( + project_root / example_dir, + f"{name}_example{_dot(extension)}", + ) + basename_test_path = _find_named_file( + project_root / test_dir, + f"test_{name}{_dot(extension)}", + ) preferred_example = False preferred_test = False - if basename_example_path.exists(): + if not configured_example and basename_example_path is not None: artifacts["example"] = basename_example_path preferred_example = True - if basename_test_path.exists(): + if not configured_test and basename_test_path is not None: artifacts["test"] = basename_test_path artifacts["test_files"] = [basename_test_path] preferred_test = True @@ -1334,7 +4065,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts result = {"prompt": Path(prompt_path), **artifacts} logger.info(f"get_pdd_file_paths returning (from architecture.json): {result}") - return result + return _finalize_output_paths(result) # Check if prompt file exists - if not, we still need configuration-aware paths if not Path(prompt_path).exists(): @@ -1342,9 +4073,14 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts # even when prompt doesn't exist extension = get_extension(language) try: - # Call construct_paths with empty input_file_paths to get configured output paths + # Pass the (not-yet-existing) prompt path as an anchoring hint so + # construct_paths locates the SUBPROJECT .pddrc (walking up from the + # prompt dir) and resolves outputs against it — not the process CWD. + # Without this, a new module resolved from a parent/sibling CWD writes + # code/test under the parent instead of the project. _find_pddrc_file + # only walks parent dirs, so the file need not exist. resolved_config, _, output_paths, _ = construct_paths( - input_file_paths={}, # Empty dict since files don't exist yet + input_file_paths={"prompt_file": prompt_path}, force=True, quiet=True, command="sync", @@ -1363,7 +4099,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts if outputs_config: logger.info(f"Using template-based paths from outputs config") context_name = context_override or resolved_config.get('_matched_context') - basename_for_templates = _relative_basename_for_context(basename, context_name) + basename_for_templates = _relative_basename_for_context(basename, context_name, prompts_root_anchor) result = _generate_paths_from_templates( basename=basename_for_templates, language=language, @@ -1378,9 +4114,19 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts basename, language, context_name=context_name, + pddrc_anchor=prompts_root_anchor, ) + # Template paths are project-relative; anchor them at the + # subproject (the .pddrc directory) so a new module resolved from a + # parent/sibling CWD writes under the project, not the CWD. + _new_pddrc = _find_pddrc_file(config_anchor) + if _new_pddrc is not None: + result = _anchor_output_paths_at_project(result, _new_pddrc.parent) logger.debug(f"get_pdd_file_paths returning (template-based): {result}") - return result + return _finalize_output_paths( + result, + prompt_from_config=isinstance(outputs_config, dict) and "prompt" in outputs_config, + ) # Legacy path construction (backwards compatibility) # Extract directory configuration from resolved_config @@ -1400,7 +4146,7 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts code_dir = code_dir + '/' # Extract directory and name parts for subdirectory basename support - dir_prefix, name_part = _extract_name_part(basename) + dir_prefix, name_part = _extract_name_part(construct_paths_basename) # Get explicit config paths (these are the SOURCE OF TRUTH when configured) # These should be used directly, NOT combined with dir_prefix @@ -1416,24 +4162,24 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts # Explicit complete directory - use directly with just filename code_path = f"{generate_output_path}{name_part}{_dot(extension)}" else: - # Old behavior - use code_dir + dir_prefix - code_path = f"{code_dir}{dir_prefix}{name_part}{_dot(extension)}" + # The shared re-anchor below adds dir_prefix exactly once. + code_path = f"{code_dir}{name_part}{_dot(extension)}" # Example path if example_output_path and example_output_path.endswith('/'): # Explicit complete directory - use directly with just filename example_path = f"{example_output_path}{name_part}_example{_dot(extension)}" else: - # Old behavior - use example_dir + dir_prefix - example_path = f"{example_dir}{dir_prefix}{name_part}_example{_dot(extension)}" + # The shared re-anchor below adds dir_prefix exactly once. + example_path = f"{example_dir}{name_part}_example{_dot(extension)}" # Test path if test_output_path and test_output_path.endswith('/'): # Explicit complete directory - use directly with just filename test_path = f"{test_output_path}test_{name_part}{_dot(extension)}" else: - # Old behavior - use test_dir + dir_prefix - test_path = f"{test_dir}{dir_prefix}test_{name_part}{_dot(extension)}" + # The shared re-anchor below adds dir_prefix exactly once. + test_path = f"{test_dir}test_{name_part}{_dot(extension)}" logger.debug(f"Final paths: test={test_path}, example={example_path}, code={code_path}") @@ -1475,27 +4221,50 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts 'test_files': matching_test_files or [test_path] # Bug #156 } logger.debug(f"get_pdd_file_paths returning (prompt missing): test={test_path}") - return result + return _finalize_output_paths(result) + except AmbiguousModuleError: + # A hard path-resolution error (ambiguity, unsafe/out-of-tree output + # or prompt) MUST fail closed — never fall through to the convention + # fallback below, which would silently return an unvalidated target. + raise except Exception as e: - # If construct_paths fails, fall back to current directory paths - # This maintains backward compatibility + # If construct_paths fails, fall back to convention-based paths. Anchor + # them at the resolved subproject (the .pddrc directory) when it differs + # from the process CWD, so a new module resolved from a parent/sibling CWD + # does not land its code/example/test under the wrong root; when they + # coincide the paths stay relative, preserving the legacy return contract. import logging logger = logging.getLogger(__name__) logger.debug(f"construct_paths failed for non-existent prompt, using defaults: {e}") - dir_prefix, name_part = _extract_name_part(basename) - fallback_test_path = Path(f"{dir_prefix}test_{name_part}{_dot(extension)}") - # Bug #156: Find matching test files even in fallback - if Path('.').exists(): - fallback_matching = sorted(Path('.').glob(f"{glob.escape(dir_prefix)}test_{glob.escape(name_part)}*.{glob.escape(extension)}")) + dir_prefix, name_part = _extract_name_part(construct_paths_basename) + _pddrc_fallback = _find_pddrc_file(config_anchor) + _subproject = _pddrc_fallback.parent if _pddrc_fallback else None + + def _anchor_fallback(rel: str) -> Path: + rel_path = Path(rel) + if ( + _subproject is not None + and _subproject.resolve(strict=False) != Path.cwd().resolve(strict=False) + ): + return _subproject / rel_path + return rel_path + + fallback_test_path = _anchor_fallback(f"{dir_prefix}test_{name_part}{_dot(extension)}") + # Bug #156: Find matching test files even in fallback (under the anchored dir) + fallback_test_dir = fallback_test_path.parent + if fallback_test_dir.exists(): + fallback_matching = sorted( + fallback_test_dir.glob(f"test_{glob.escape(name_part)}*.{glob.escape(extension)}") + ) else: fallback_matching = [fallback_test_path] if fallback_test_path.exists() else [] - return { + return _finalize_output_paths({ 'prompt': Path(prompt_path), - 'code': Path(f"{dir_prefix}{name_part}{_dot(extension)}"), - 'example': Path(f"{dir_prefix}{name_part}_example{_dot(extension)}"), + 'code': _anchor_fallback(f"{dir_prefix}{name_part}{_dot(extension)}"), + 'example': _anchor_fallback(f"{dir_prefix}{name_part}_example{_dot(extension)}"), 'test': fallback_test_path, 'test_files': fallback_matching or [fallback_test_path] # Bug #156 - } + }) input_file_paths = { "prompt_file": prompt_path @@ -1515,10 +4284,16 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts # Issue #237: Check for 'outputs' config for template-based path generation # This must be checked even when prompt EXISTS (not just when it doesn't exist) outputs_config = resolved_config.get('outputs') + # R16: reject unsafe .pddrc output templates on the EFFECTIVE resolved config + # (construct_paths may select a nearer descendant .pddrc than config_anchor). + _reject_unsafe_outputs_templates(outputs_config, _governing_root) if outputs_config: extension = get_extension(language) logger.info(f"Using template-based paths from outputs config (prompt exists)") context_name = context_override or resolved_config.get('_matched_context') + # Use the DISCOVERED physical prompt identity (template_basename, e.g. + # `nested/foo`) so an outputs `{category}` template maps under the nested + # directory (src/nested/foo.py) instead of collapsing to the bare leaf. basename_for_templates = template_basename result = _generate_paths_from_templates( basename=basename_for_templates, @@ -1534,9 +4309,19 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts basename, language, context_name=context_name, + pddrc_anchor=prompts_root_anchor, ) + # Anchor project-relative template outputs at the subproject (as the + # missing-prompt branch does) so an existing prompt resolved from a + # parent/sibling CWD still writes code/example/test under the project. + _existing_pddrc = _find_pddrc_file(config_anchor) + if _existing_pddrc is not None: + result = _anchor_output_paths_at_project(result, _existing_pddrc.parent) logger.debug(f"get_pdd_file_paths returning (template-based, prompt exists): {result}") - return result + return _finalize_output_paths( + result, + prompt_from_config=isinstance(outputs_config, dict) and "prompt" in outputs_config, + ) # For sync command, output_file_paths contains the configured paths # Extract the code path from output_file_paths @@ -1567,7 +4352,15 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts # Create a temporary empty code file if it doesn't exist for path resolution code_path_obj = Path(code_path) temp_code_created = False - if not code_path_obj.exists(): + # Never materialize a probe file outside the governing project: a + # .pddrc generate_output_path that resolves outside the trusted root + # (traversal, sibling-of-project under a parent CWD, or an away-pointing + # absolute path) must not create directories out of tree here. When the + # target is not within the governing root, skip the probe — the + # containment guard on the return value fails the resolution closed. + if not code_path_obj.exists() and _output_path_within_root( + code_path_obj, _governing_root + ): code_path_obj.parent.mkdir(parents=True, exist_ok=True) code_path_obj.touch() temp_code_created = True @@ -1605,7 +4398,11 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts # Clean up temporary file if we created it if temp_code_created and code_path_obj.exists() and code_path_obj.stat().st_size == 0: code_path_obj.unlink() - + + except AmbiguousModuleError: + # A hard path-resolution error (unsafe/out-of-tree target) must fail + # closed, not degrade into the convention fallback below. + raise except Exception as e: # Log the specific exception that's causing fallback to wrong paths import logging @@ -1679,13 +4476,13 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts else: matching_test_files = [test_path] if test_path.exists() else [] - return { + return _finalize_output_paths({ 'prompt': Path(prompt_path), 'code': code_path, 'example': example_path, 'test': test_path, 'test_files': matching_test_files or [test_path] # Bug #156: All matching test files - } + }) except AmbiguousModuleError: # Issue #1677: ambiguity is a hard, actionable error — never let the broad @@ -1712,13 +4509,26 @@ def get_pdd_file_paths(basename: str, language: str, prompts_dir: str = "prompts if candidate.name.lower() == target_lower and candidate.is_file(): fallback_prompt_path = candidate break - return { + _outer_fallback = { 'prompt': fallback_prompt_path, 'code': Path(f"{dir_prefix}{name_part}{_dot(extension)}"), 'example': Path(f"{dir_prefix}{name_part}_example{_dot(extension)}"), 'test': test_path, 'test_files': matching_test_files or [test_path] # Bug #156: All matching test files } + # Even this last-resort fallback must be anchored under the governing root + # and contained — otherwise a parent/sibling CWD makes these relative + # basename paths resolve outside the project. Route it through the same + # finalizer. If resolution failed so early that the finalizer/governing + # root were never established (e.g. a pathological prompts_dir that could + # not be resolved), fail closed rather than return unvalidated CWD-relative + # paths. + try: + return _finalize_output_paths(_outer_fallback) + except NameError: + raise UnsafePromptPathError( + Path(str(prompts_dir)), Path.cwd() + ) def calculate_sha256(file_path: Path) -> Optional[str]: diff --git a/pdd/templates/architecture/pdd_path_construction_guide.prompt b/pdd/templates/architecture/pdd_path_construction_guide.prompt index 9da7b348c5..f065d4bcdd 100644 --- a/pdd/templates/architecture/pdd_path_construction_guide.prompt +++ b/pdd/templates/architecture/pdd_path_construction_guide.prompt @@ -360,9 +360,9 @@ pdd sync home_page --dry-run % COMMON MISTAKES AND FIXES % ============================================================================ -1. **Using slashes in filename** - - ❌ `app/api/tasks/route_TypeScript.prompt` - - ✅ `api_tasks_route_TypeScript.prompt` +1. **Flattening a path-aware filename** + - ❌ `api_tasks_route_TypeScript.prompt` + - ✅ `app/api/tasks/route_TypeScript.prompt` (mirrors `app/api/tasks/route.ts`) 2. **Using framework names as language suffix** - ❌ `page_NextJS.prompt` @@ -416,7 +416,7 @@ For deeper understanding, these are the key code files: Before finishing architecture.json and .pddrc generation: **Common checks (both styles):** -□ Every filename uses underscores, NO slashes +□ Every filename mirrors its filepath structure; preserve slashes for path-aware modules □ Every filename ends with valid PascalCase language suffix □ Every filepath matches framework/project conventions □ Verified paths using Python script or dry-run diff --git a/tests/test_sync_determine_operation.py b/tests/test_sync_determine_operation.py index 3f52f90413..b002cc0cc7 100644 --- a/tests/test_sync_determine_operation.py +++ b/tests/test_sync_determine_operation.py @@ -37,7 +37,12 @@ validate_expected_files, _handle_missing_expected_files, _is_workflow_complete, - get_pdd_file_paths + get_pdd_file_paths, + _safe_architecture_prompt_filename, + _contained_architecture_code_path, + _find_prompt_file, + UnsafePromptPathError, + UnsafeOutputPathError, ) # --- Test Plan --- @@ -1560,4198 +1565,8957 @@ def test_get_pdd_file_paths_architecture_filepath_uses_basename_context(tmp_path assert paths["test"] == tmp_path / "context_tests" / "test_agentic_architecture.py" -# --- Part 6: Auto-deps Infinite Loop Regression Tests --- +def _write_nested_architecture_project( + root: Path, + *, + prompts_dir: str, + architecture_filename: str, + architecture_filepath: str, +) -> None: + prompt_dir = root / prompts_dir + prompt_dir.mkdir(parents=True) + (prompt_dir / "credits_Python.prompt").write_text( + "% endpoint test mixin for the credits endpoints\n", + encoding="utf-8", + ) + (root / ".pdd" / "meta").mkdir(parents=True) + (root / ".pdd" / "locks").mkdir(parents=True) + (root / ".pddrc").write_text( + 'contexts:\n' + ' backend:\n' + f' paths: ["backend/**", "{prompts_dir}/**"]\n' + ' defaults:\n' + f' prompts_dir: "{prompts_dir}"\n' + ' generate_output_path: "backend/functions/"\n' + ' outputs:\n' + ' code:\n' + ' path: "backend/functions/{name}.py"\n' + ' example:\n' + ' path: "custom_usage/{name}_usage.py"\n' + ' test:\n' + ' path: "custom_specs/{name}_spec.py"\n', + encoding="utf-8", + ) + (root / "architecture.json").write_text( + json.dumps({ + "modules": [{ + "filename": architecture_filename, + "filepath": architecture_filepath, + }] + }), + encoding="utf-8", + ) -class TestAutoDepsInfiniteLoopFix: - """Test the auto-deps infinite loop fix implemented to prevent continuous auto-deps operations.""" - - def test_auto_deps_to_generate_progression(self, pdd_test_environment): - """Test that after auto-deps completes, sync decides to run generate (not auto-deps again).""" - - # Create prompt file with dependencies - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_content = """Create a YouTube client function. -src/config.py -src/models.py +def test_get_pdd_file_paths_matches_prompt_root_symlink_alias(tmp_path, monkeypatch): + """A trusted prompt-root symlink keeps architecture ownership identity.""" + monkeypatch.chdir(tmp_path) + _write_nested_architecture_project( + tmp_path, + prompts_dir="pdd/prompts", + architecture_filename="credits_Python.prompt", + architecture_filepath="backend/tests/endpoint_tests/tests/credits.py", + ) + try: + (tmp_path / "prompts").symlink_to("pdd/prompts", target_is_directory=True) + except OSError: + pytest.skip("directory symlinks are unavailable") -Requirements: -- Function should discover new videos from YouTube channels -- Use the config and models from included dependencies -""" - prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) - - # Create fingerprint showing auto-deps was just completed - fingerprint_data = { - "pdd_version": "0.0.46", - "timestamp": "2025-08-04T05:22:58.044203+00:00", - "command": "auto-deps", # This is the key - auto-deps was last completed - "prompt_hash": prompt_hash, # Use actual calculated hash - "code_hash": None, # Code file doesn't exist yet - "example_hash": None, - "test_hash": None - } - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, fingerprint_data) - - # Test the decision logic - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) - - # CRITICAL: Should decide 'generate', not 'auto-deps' again - assert decision.operation == 'generate' - assert 'Auto-deps completed' in decision.reason - assert decision.details['previous_command'] == 'auto-deps' - assert decision.details['code_exists'] == False - - def test_auto_deps_infinite_loop_before_fix_scenario(self, pdd_test_environment): - """Test the exact scenario that caused infinite loop before the fix.""" - - # Create prompt file with dependencies (like youtube_client_python.prompt) - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_content = """YouTube Client Module + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts", + context_override="backend", + ) -This module discovers new videos from configured YouTube channels. + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "tests" / "endpoint_tests" / "tests" / "credits.py" + ).resolve(strict=False) -### Dependencies - -src/config.py - +def test_get_pdd_file_paths_architecture_filepath_with_custom_prompt_root_and_outputs( + tmp_path, + monkeypatch, +): + """A custom prompt root resolves from one architecture lookup.""" + import sync_determine_operation as sync_determine_module - -src/models.py - + monkeypatch.chdir(tmp_path) + real_code = tmp_path / "backend" / "tests" / "endpoint_tests" / "tests" / "credits.py" + real_code.parent.mkdir(parents=True) + real_code.write_text("class CreditsTestsMixin:\n pass\n", encoding="utf-8") + _write_nested_architecture_project( + tmp_path, + prompts_dir="specs/backend", + architecture_filename="backend/credits_Python.prompt", + architecture_filepath="backend/tests/endpoint_tests/tests/credits.py", + ) + architecture_lookups = 0 + original_lookup = sync_determine_module._get_filepath_from_architecture + + def counting_lookup(*args, **kwargs): + nonlocal architecture_lookups + architecture_lookups += 1 + return original_lookup(*args, **kwargs) + + monkeypatch.setattr( + sync_determine_module, + "_get_filepath_from_architecture", + counting_lookup, + ) -Requirements: -- Discover new videos from YouTube channels -- Process metadata for each video -""" - prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) - - # Simulate the exact state from the sync log: auto-deps completed but code file missing - fingerprint_data = { - "pdd_version": "0.0.46", - "timestamp": "2025-08-04T05:07:29.753906+00:00", - "command": "auto-deps", - "prompt_hash": prompt_hash, # Use actual calculated hash - "code_hash": None, # This is the key issue - no code file exists - "example_hash": None, - "test_hash": None - } - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, fingerprint_data) - - # Before the fix: this would return 'auto-deps' and cause infinite loop - # After the fix: this should return 'generate' - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) - - # Verify the fix - assert decision.operation == 'generate', f"Expected 'generate', got '{decision.operation}' - infinite loop fix failed" - assert decision.operation != 'auto-deps', "Should not return auto-deps again (infinite loop)" - assert 'Auto-deps completed' in decision.reason - assert decision.confidence == 0.90 # High confidence since this is deterministic - - def test_auto_deps_without_dependencies_still_works(self, pdd_test_environment): - """Test that normal auto-deps logic still works when prompt has no dependencies.""" - - # Create prompt file WITHOUT dependencies - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_content = """Create a simple calculator function. + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="specs/backend", + context_override="backend", + ) -Requirements: -- Function name: add -- Parameters: a, b (both numbers) -- Return: sum of a and b -""" - create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) - - # No fingerprint (new unit scenario) - # Code file doesn't exist - - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) - - # Should go directly to generate since no dependencies detected - assert decision.operation == 'generate' - assert 'New prompt ready' in decision.reason - assert decision.details.get('has_dependencies', True) == False # No dependencies - - def test_auto_deps_first_time_with_dependencies(self, pdd_test_environment): - """Test that auto-deps is correctly chosen for new prompts with dependencies.""" - - # Create prompt file WITH dependencies - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_content = """Create a data processor. + assert paths["code"] == real_code + assert paths["example"] == tmp_path / "custom_usage" / "credits_usage.py" + assert paths["test"] == tmp_path / "custom_specs" / "credits_spec.py" + assert architecture_lookups == 1 -context/database_example.py -https://example.com/api-docs -Requirements: -- Process data using included database example -- Fetch API documentation from web -""" - create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) - - # No fingerprint (new unit scenario) - # Code file doesn't exist - - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) - - # Should choose auto-deps for first time with dependencies - assert decision.operation == 'auto-deps' - assert 'New prompt with dependencies detected' in decision.reason - assert decision.details['has_dependencies'] == True - assert decision.details['fingerprint_found'] == False +def test_get_pdd_file_paths_matches_canonical_filepath_derived_architecture_filename( + tmp_path, + monkeypatch, +): + """Issue #617 normalized filenames can differ from the physical prompt path.""" + monkeypatch.chdir(tmp_path) + real_code = tmp_path / "backend" / "tests" / "endpoint_tests" / "tests" / "credits.py" + real_code.parent.mkdir(parents=True) + real_code.write_text("class CreditsTestsMixin:\n pass\n", encoding="utf-8") + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="backend/tests/endpoint_tests/tests/credits_Python.prompt", + architecture_filepath="backend/tests/endpoint_tests/tests/credits.py", + ) - @patch('sync_determine_operation.construct_paths') - def test_auto_deps_regenerates_when_code_exists_from_previous_run(self, mock_construct, pdd_test_environment): - """Test that after auto-deps completes, generate runs even when code file exists from previous run. + def fail_recursive_scan(*_args, **_kwargs): + pytest.fail("canonical architecture ownership must not scan the prompt tree") - This is a regression test for a bug where: - 1. User changes prompt - 2. auto-deps runs (updates dependencies, saves fingerprint with new prompt hash) - 3. Code file exists from a previous generation (stale code) - 4. Next sync should run 'generate' to regenerate code with new prompt + monkeypatch.setattr(Path, "rglob", fail_recursive_scan) - Bug: sync was skipping to 'crash' because code file existed, missing the regeneration step. - Fix: Added check for fingerprint.command == 'auto-deps' that triggers generate regardless - of whether code file exists. - """ + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) - # Create prompt file with dependencies - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_content = """Generate a credit helper function. + assert paths["code"] == real_code -context/firebase_helpers.py -context/user_model.py -Requirements: -- Deduct credits from user account -- Verify authentication before deducting -""" - prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) +def test_get_pdd_file_paths_does_not_borrow_nested_same_leaf_architecture_entry( + tmp_path, + monkeypatch, +): + """A flat prompt cannot inherit a nested sibling's architecture filepath.""" + monkeypatch.chdir(tmp_path) + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="backend/utils/credits_Python.prompt", + architecture_filepath="backend/functions/utils/credits.py", + ) + nested_prompt = tmp_path / "prompts" / "backend" / "utils" / "credits_Python.prompt" + nested_prompt.parent.mkdir(parents=True) + nested_prompt.write_text("% nested credits helper\n", encoding="utf-8") + nested_code = tmp_path / "backend" / "functions" / "utils" / "credits.py" + nested_code.parent.mkdir(parents=True) + nested_code.write_text("def nested_credits():\n pass\n", encoding="utf-8") - # Create code, example, test files directly in pdd_test_environment (following test pattern) - old_code_hash = create_file(pdd_test_environment / f"{BASENAME}.py", "# OLD CODE\ndef old_func(): pass") - old_example_hash = create_file(pdd_test_environment / f"{BASENAME}_example.py", "# OLD EXAMPLE") - old_test_hash = create_file(pdd_test_environment / f"test_{BASENAME}.py", "# OLD TEST\ndef test_old(): pass") + flat_paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) + nested_paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend/utils", + context_override="backend", + ) - # Mock construct_paths to return correct file paths - mock_construct.return_value = ( - {}, {}, - { - 'code_file': str(pdd_test_environment / f"{BASENAME}.py"), - 'example_file': str(pdd_test_environment / f"{BASENAME}_example.py"), - 'test_file': str(pdd_test_environment / f"test_{BASENAME}.py") - }, - LANGUAGE - ) + assert flat_paths["code"].resolve() == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve() + assert nested_paths["code"] == nested_code - # Create fingerprint showing auto-deps JUST completed - # The fingerprint has the NEW prompt hash but OLD code/example/test hashes - fingerprint_data = { - "pdd_version": "0.0.88", - "timestamp": "2025-12-23T02:10:02.143829+00:00", - "command": "auto-deps", # auto-deps just completed - "prompt_hash": prompt_hash, # NEW prompt hash - "code_hash": old_code_hash, # OLD code hash (stale) - "example_hash": old_example_hash, # OLD example hash - "test_hash": old_test_hash # OLD test hash - } - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, fingerprint_data) - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) +def test_get_pdd_file_paths_does_not_borrow_sibling_context_architecture_entry( + tmp_path, + monkeypatch, +): + """A narrowed backend root cannot inherit a frontend prompt's module.""" + monkeypatch.chdir(tmp_path) + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="frontend/credits_Python.prompt", + architecture_filepath="frontend/credits.py", + ) + frontend_prompt = tmp_path / "prompts" / "frontend" / "credits_Python.prompt" + frontend_prompt.parent.mkdir(parents=True) + frontend_prompt.write_text("% frontend credits\n", encoding="utf-8") - # CRITICAL: Should decide 'generate' (not 'crash' or 'nothing') - # Even though all files exist, auto-deps just ran, so code needs regeneration - assert decision.operation == 'generate', \ - f"Expected 'generate', got '{decision.operation}'. " \ - f"Bug: after auto-deps, should regenerate code even when code file exists from previous run." - assert 'Auto-deps completed' in decision.reason - assert decision.details.get('regenerate_after_autodeps') == True - assert decision.details.get('code_exists') == True # Confirms code existed but we still regenerate + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) - @patch('sync_determine_operation.construct_paths') - def test_no_fingerprint_with_stale_run_report_should_generate(self, mock_construct, pdd_test_environment): - """Test that when fingerprint is deleted but run_report exists, sync treats it as fresh start. + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) - Regression test for bug where: - 1. User deletes fingerprint to force regeneration - 2. Stale run_report exists with test failures - 3. Expected: sync detects as fresh start → auto-deps/generate - 4. Actual (bug): sync sees run_report.tests_failed > 0 → runs fix - IMPORTANT: Must create test file so the buggy 'fix' path at line 958 is triggered. - Without test file, the code skips 'fix' and accidentally returns correct result. - """ - # Create prompt with dependencies - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_content = """Generate a helper function. +def test_get_pdd_file_paths_does_not_borrow_stale_sibling_context_entry( + tmp_path, + monkeypatch, +): + """A deleted sibling prompt's surviving architecture row must not be borrowed. -context/helpers.py + Regression: when the frontend prompt no longer exists, its ``frontend`` entry + has no physical owner. The same-leaf ``backend`` prompt must still fall back to + its own configured output, never inherit the foreign module's ``frontend`` + filepath and silently overwrite another context's code. + """ + monkeypatch.chdir(tmp_path) + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="frontend/credits_Python.prompt", + architecture_filepath="frontend/credits.py", + ) + # NOTE: the frontend prompt is intentionally NOT created — the entry is stale. -Requirements: -- Do something useful -""" - create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) - # CRITICAL: Create test file so buggy 'fix' path is triggered - # Line 958: if test_file and test_file.exists(): return 'fix' - create_file(pdd_test_environment / f"test_{BASENAME}.py", "def test_old(): pass") + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) + assert paths["code"].resolve(strict=False) != ( + tmp_path / "frontend" / "credits.py" + ).resolve(strict=False) - # Mock construct_paths to return test file path - mock_construct.return_value = ( - {}, {}, - {'test_file': str(pdd_test_environment / f"test_{BASENAME}.py")}, - LANGUAGE - ) - # Create stale run_report with test failures - rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" - create_run_report_file(rr_path, { - "timestamp": "2025-12-23T03:00:00+00:00", - "exit_code": 1, - "tests_passed": 5, - "tests_failed": 2, - "coverage": 50.0, - "test_hash": "stale_hash" - }) +def test_get_pdd_file_paths_does_not_alias_prompt_named_module_by_filepath_stem( + tmp_path, + monkeypatch, +): + """A billing prompt whose code stem is credits remains the billing module.""" + monkeypatch.chdir(tmp_path) + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="frontend/billing_Python.prompt", + architecture_filepath="frontend/credits.py", + ) - # NO fingerprint exists (user deleted it) + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) - # Should treat as fresh start (auto-deps because prompt has dependencies) - # NOT 'fix' based on stale run_report - assert decision.operation == 'auto-deps', \ - f"Expected 'auto-deps', got '{decision.operation}'. " \ - f"Bug: stale run_report should be ignored when fingerprint is missing." - @patch('sync_determine_operation.construct_paths') - def test_auto_deps_ignores_stale_run_report_with_low_coverage(self, mock_construct, pdd_test_environment): - """Test that after auto-deps completes, stale run_report with low coverage is ignored. +@pytest.mark.parametrize( + "unsafe_filepath", + ["../../outside.py", "/tmp/pdd-sync-outside.py", "..\\..\\outside.py"], +) +def test_get_pdd_file_paths_rejects_unsafe_architecture_filepath( + tmp_path, + monkeypatch, + unsafe_filepath, +): + """Architecture metadata cannot make sync write outside the project.""" + monkeypatch.chdir(tmp_path) + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="backend/credits_Python.prompt", + architecture_filepath=unsafe_filepath, + ) - Regression test for bug where: - 1. auto-deps completes (fingerprint.command == 'auto-deps') - 2. Stale run_report exists with low coverage (e.g., 77% below 90% target) - 3. Expected: sync returns 'generate' to regenerate code - 4. Actual (bug): sync sees low coverage in run_report → returns 'test_extend' + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) - The run_report is stale because it was from the PREVIOUS code generation, - not the new code that will be generated after auto-deps. - """ - # Create prompt with dependencies - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_content = """Generate a helper function. + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) -context/helpers.py -Requirements: -- Do something useful -""" - prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) +@pytest.mark.parametrize( + "unsafe_filename", + [ + "../../credits_Python.prompt", + "/tmp/credits_Python.prompt", + "..\\..\\credits_Python.prompt", + ], +) +def test_get_pdd_file_paths_rejects_unsafe_architecture_filename( + tmp_path, + monkeypatch, + unsafe_filename, +): + """Architecture prompt identities cannot probe outside the prompt root.""" + monkeypatch.chdir(tmp_path) + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename=unsafe_filename, + architecture_filepath="backend/tests/endpoint_tests/tests/credits.py", + ) - # Create code, example, test files (from previous run) - code_hash = create_file(pdd_test_environment / f"{BASENAME}.py", "# OLD CODE") - example_hash = create_file(pdd_test_environment / f"{BASENAME}_example.py", "# OLD EXAMPLE") - test_hash = create_file(pdd_test_environment / f"test_{BASENAME}.py", "def test_old(): pass") + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) - mock_construct.return_value = ( - {}, {}, - { - 'code_file': str(pdd_test_environment / f"{BASENAME}.py"), - 'example_file': str(pdd_test_environment / f"{BASENAME}_example.py"), - 'test_file': str(pdd_test_environment / f"test_{BASENAME}.py") - }, - LANGUAGE - ) + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) - # Create fingerprint showing auto-deps JUST completed - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, { - "pdd_version": "0.0.88", - "timestamp": "2025-12-23T03:00:00+00:00", - "command": "auto-deps", # auto-deps just completed - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) - # Create STALE run_report with low coverage (from previous code generation) - rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" - create_run_report_file(rr_path, { - "timestamp": "2025-12-23T02:00:00+00:00", # Before auto-deps - "exit_code": 0, - "tests_passed": 6, - "tests_failed": 0, - "coverage": 77.0, # Below 90% target - would trigger test_extend if not ignored - "test_hash": test_hash - }) +@pytest.mark.parametrize( + "drive_filename", + [ + "D:/credits_Python.prompt", + "D:credits_Python.prompt", + "C:/nested/credits_Python.prompt", + ], +) +def test_safe_architecture_prompt_filename_rejects_windows_drive(drive_filename): + """Drive-qualified prompt metadata is POSIX-relative but escapes on Windows. - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + ``PurePosixPath`` treats ``D:/x`` as relative, so the earlier absolute/`..` + checks pass it through; joining it on Windows yields a drive-relative path + outside ``prompts_root``. The validator must reject it on every platform. + """ + assert _safe_architecture_prompt_filename(drive_filename) is None - # Should return 'generate' because auto-deps just completed - # NOT 'test_extend' based on stale run_report's low coverage - assert decision.operation == 'generate', \ - f"Expected 'generate', got '{decision.operation}'. " \ - f"Bug: after auto-deps, stale run_report should be ignored." - assert 'Auto-deps completed' in decision.reason - assert decision.details.get('regenerate_after_autodeps') == True -# --- Part 7: Edge Cases and Helper Function Tests --- -# These tests were consolidated from test_sync_edge_cases.py +@pytest.mark.parametrize( + "drive_filepath", + [ + "D:/credits.py", + "D:credits.py", + "C:/nested/credits.py", + ], +) +def test_contained_architecture_code_path_rejects_windows_drive(tmp_path, drive_filepath): + """Drive-qualified output metadata must not resolve to a code path.""" + assert _contained_architecture_code_path(tmp_path, drive_filepath) is None -class TestValidateExpectedFiles: - """Test the validate_expected_files function.""" - - def test_validate_with_no_fingerprint(self): - """Test validation when no fingerprint is provided.""" - paths = { - 'code': Path('test.py'), - 'example': Path('test_example.py'), - 'test': Path('test_test.py') - } - - result = validate_expected_files(None, paths) - assert result == {} - - def test_validate_all_files_exist(self, tmp_path): - """Test validation when all expected files exist.""" - # Create test files - code_file = tmp_path / "test.py" - example_file = tmp_path / "test_example.py" - test_file = tmp_path / "test_test.py" - - code_file.write_text("print('hello')") - example_file.write_text("from test import *") - test_file.write_text("def test_func(): pass") - - paths = { - 'code': code_file, - 'example': example_file, - 'test': test_file - } - - fingerprint = Fingerprint( - pdd_version="0.0.41", - timestamp=datetime.now(timezone.utc).isoformat(), - command="test", - prompt_hash="prompt123", - code_hash="code456", - example_hash="example789", - test_hash="test012" + +def test_get_pdd_file_paths_rejects_unsafe_filename_when_prompt_is_missing( + tmp_path, + monkeypatch, +): + """A missing internal prompt cannot make discovery follow an external hint.""" + monkeypatch.chdir(tmp_path) + external_dir = tmp_path.parent / f"{tmp_path.name}-external-prompts" + external_dir.mkdir() + external_prompt = external_dir / "credits_Python.prompt" + external_prompt.write_text("% external prompt\n", encoding="utf-8") + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename=str(external_prompt), + architecture_filepath="backend/tests/endpoint_tests/tests/credits.py", + ) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").unlink() + + unsafe_filenames = ( + str(external_prompt), + f"../../../{external_dir.name}/credits_Python.prompt", + ) + for unsafe_filename in unsafe_filenames: + (tmp_path / "architecture.json").write_text( + json.dumps({ + "modules": [{ + "filename": unsafe_filename, + "filepath": "backend/tests/endpoint_tests/tests/credits.py", + }] + }), + encoding="utf-8", ) - - result = validate_expected_files(fingerprint, paths) - - assert result == { - 'code': True, - 'example': True, - 'test': True - } - - def test_validate_missing_files(self, tmp_path): - """Test validation when expected files are missing.""" - # Create only code file - code_file = tmp_path / "test.py" - example_file = tmp_path / "test_example.py" - test_file = tmp_path / "test_test.py" - - code_file.write_text("print('hello')") - # Don't create example and test files - - paths = { - 'code': code_file, - 'example': example_file, - 'test': test_file - } - - fingerprint = Fingerprint( - pdd_version="0.0.41", - timestamp=datetime.now(timezone.utc).isoformat(), - command="test", - prompt_hash="prompt123", - code_hash="code456", - example_hash="example789", - test_hash="test012" + + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", ) - - result = validate_expected_files(fingerprint, paths) - - assert result == { - 'code': True, - 'example': False, - 'test': False - } + assert paths["prompt"].resolve(strict=False) != external_prompt.resolve() + assert paths["prompt"].resolve(strict=False).is_relative_to( + (tmp_path / "prompts" / "backend").resolve() + ) + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) -class TestHandleMissingExpectedFiles: - """Test the _handle_missing_expected_files function.""" - - def test_missing_code_file_with_prompt(self, tmp_path): - """Test recovery when code file is missing but prompt exists.""" - prompt_file = tmp_path / "test_python.prompt" - prompt_file.write_text("Create a simple function") - - paths = { - 'prompt': prompt_file, - 'code': tmp_path / "test.py", - 'example': tmp_path / "test_example.py", - 'test': tmp_path / "test_test.py" - } - - fingerprint = Fingerprint( - pdd_version="0.0.41", - timestamp=datetime.now(timezone.utc).isoformat(), - command="test", - prompt_hash="prompt123", - code_hash="code456", - example_hash=None, - test_hash=None - ) - - decision = _handle_missing_expected_files( - missing_files=['code'], - paths=paths, - fingerprint=fingerprint, - basename="test", - language="python", - prompts_dir="prompts" - ) - - assert decision.operation == 'generate' - assert 'Code file missing' in decision.reason - # The confidence value is set to 1.0 because the decision to generate - # a new code file is deterministic when the code file is missing, and - # all other required files (e.g., prompt) are present. - assert decision.confidence == 1.0 - def test_missing_test_file_with_skip_tests(self, tmp_path): - """Test recovery when test file is missing and skip_tests is True.""" - code_file = tmp_path / "test.py" - example_file = tmp_path / "test_example.py" - - code_file.write_text("def add(a, b): return a + b") - example_file.write_text("from test import add; print(add(1, 2))") - - paths = { - 'prompt': tmp_path / "test_python.prompt", - 'code': code_file, - 'example': example_file, - 'test': tmp_path / "test_test.py" - } - - fingerprint = Fingerprint( - pdd_version="0.0.41", - timestamp=datetime.now(timezone.utc).isoformat(), - command="test", - prompt_hash="prompt123", - code_hash="code456", - example_hash="example789", - test_hash="test012" - ) - - decision = _handle_missing_expected_files( - missing_files=['test'], - paths=paths, - fingerprint=fingerprint, - basename="test", - language="python", - prompts_dir="prompts", - skip_tests=True + +def test_get_pdd_file_paths_rejects_symlink_prompt_discovery_escape( + tmp_path, + monkeypatch, +): + """Architecture prompt discovery cannot escape through a directory symlink.""" + monkeypatch.chdir(tmp_path) + external_dir = tmp_path.parent / f"{tmp_path.name}-external-symlink-prompts" + external_dir.mkdir() + external_prompt = external_dir / "credits_Python.prompt" + external_prompt.write_text("% external prompt\n", encoding="utf-8") + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="linked/credits_Python.prompt", + architecture_filepath="backend/tests/endpoint_tests/tests/credits.py", + ) + internal_prompt = tmp_path / "prompts" / "backend" / "credits_Python.prompt" + internal_prompt.unlink() + try: + (tmp_path / "prompts" / "backend" / "linked").symlink_to( + external_dir, + target_is_directory=True, ) - - assert decision.operation == 'nothing' - assert 'skip-tests specified' in decision.reason - assert decision.details['skip_tests'] is True - - def test_missing_example_file(self, tmp_path): - """Test recovery when example file is missing but code exists.""" - code_file = tmp_path / "test.py" - code_file.write_text("def add(a, b): return a + b") - - paths = { - 'prompt': tmp_path / "test_python.prompt", - 'code': code_file, - 'example': tmp_path / "test_example.py", - 'test': tmp_path / "test_test.py" - } - - fingerprint = Fingerprint( - pdd_version="0.0.41", - timestamp=datetime.now(timezone.utc).isoformat(), - command="test", - prompt_hash="prompt123", - code_hash="code456", - example_hash="example789", - test_hash=None + except OSError: + pytest.skip("directory symlinks are unavailable") + + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) + + assert paths["prompt"].resolve(strict=False) != external_prompt.resolve() + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) + + +def test_get_pdd_file_paths_rejects_same_leaf_file_symlink_discovery_escape( + tmp_path, + monkeypatch, +): + """Recursive discovery must not return a same-leaf FILE symlink that escapes root. + + The directory-symlink test above does not cover this route: ``rglob`` does not + descend into symlinked directories, but it DOES yield a file symlink, and + ``is_file()`` follows the link. Architecture names a safe-but-missing prompt so + resolution falls through to the recursive scan (Step 3c / Step 4); a same-leaf + in-root file symlink points outside the repo. Returning it would let an update + write through the link and overwrite the external file. + """ + monkeypatch.chdir(tmp_path) + external_dir = tmp_path.parent / f"{tmp_path.name}-external-file-symlink" + external_dir.mkdir() + external_prompt = external_dir / "credits_Python.prompt" + external_prompt.write_text("% external prompt (must not be written through)\n", encoding="utf-8") + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="missing/credits_Python.prompt", + architecture_filepath="backend/tests/endpoint_tests/tests/credits.py", + ) + # Remove the real internal prompt so discovery must fall through to recursion. + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").unlink() + nested = tmp_path / "prompts" / "backend" / "sub" + nested.mkdir() + try: + (nested / "credits_Python.prompt").symlink_to(external_prompt) + except OSError: + pytest.skip("file symlinks are unavailable") + + with pytest.raises(UnsafePromptPathError, match="resolves outside prompts root"): + get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", ) - - decision = _handle_missing_expected_files( - missing_files=['example'], - paths=paths, - fingerprint=fingerprint, - basename="test", - language="python", - prompts_dir="prompts" + + assert external_prompt.read_text(encoding="utf-8") == ( + "% external prompt (must not be written through)\n" + ) + + +def test_get_pdd_file_paths_context_isolation_holds_from_parent_cwd( + tmp_path, + monkeypatch, +): + """Context territory must hold when resolution runs outside the project CWD. + + Sync is frequently driven from a parent/sibling directory with an absolute + prompts root. The territory guard must anchor its ``.pddrc`` lookup at the + project (``architecture.json``'s directory), not the process CWD — a CWD-based + lookup finds no ``.pddrc`` and fails open, re-opening the stale sibling-context + borrow it exists to block. + """ + project = tmp_path / "project" + project.mkdir() + _write_nested_architecture_project( + project, + prompts_dir="prompts/backend", + architecture_filename="frontend/credits_Python.prompt", + architecture_filepath="frontend/credits.py", + ) + # No frontend prompt is created — the entry is stale. + monkeypatch.chdir(tmp_path) # PARENT of the project; no .pddrc here. + abs_prompts = str((project / "prompts" / "backend").resolve()) + + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir=abs_prompts, + context_override="backend", + ) + + code = paths["code"].resolve(strict=False).as_posix() + assert not code.endswith("frontend/credits.py") + assert code.endswith("backend/functions/credits.py") + + +def test_find_prompt_file_direct_fast_path_rejects_escaping_symlink(tmp_path): + """The direct/case-insensitive fast path must not return an escaping symlink. + + The exact expected prompt (``prompts/backend/credits_Python.prompt``) can itself + be a file symlink to a target outside the root. `_find_prompt_file` must not + return it — otherwise a later update opens it with ``"w"`` and truncates the + external file. Containment must gate Step 1 too, not only recursive candidates. + """ + prompts_root = tmp_path / "prompts" / "backend" + prompts_root.mkdir(parents=True) + external_dir = tmp_path / "outside" + external_dir.mkdir() + external = external_dir / "credits_Python.prompt" + external.write_text("% external (must not be written through)\n", encoding="utf-8") + try: + (prompts_root / "credits_Python.prompt").symlink_to(external) + except OSError: + pytest.skip("file symlinks are unavailable") + + with pytest.raises(UnsafePromptPathError, match="resolves outside prompts root"): + _find_prompt_file( + "credits", "python", prompts_root, tmp_path / "architecture.json" ) - - assert decision.operation == 'example' - assert 'Example file missing' in decision.reason -class TestIsWorkflowComplete: - """Test the _is_workflow_complete function.""" - - def test_workflow_complete_without_skip_flags(self, tmp_path): - """Test workflow completion when all files exist and no skip flags.""" - code_file = tmp_path / "test.py" - example_file = tmp_path / "test_example.py" - test_file = tmp_path / "test_test.py" - - # Create all files - code_file.write_text("def add(a, b): return a + b") - example_file.write_text("from test import add") - test_file.write_text("def test_add(): pass") - - paths = { - 'code': code_file, - 'example': example_file, - 'test': test_file - } - - assert _is_workflow_complete(paths) is True - assert _is_workflow_complete(paths, skip_tests=False) is True - - def test_workflow_complete_with_skip_tests(self, tmp_path): - """Test workflow completion when test file missing but skip_tests=True.""" - code_file = tmp_path / "test.py" - example_file = tmp_path / "test_example.py" - - # Create only code and example files - code_file.write_text("def add(a, b): return a + b") - example_file.write_text("from test import add") - - paths = { - 'code': code_file, - 'example': example_file, - 'test': tmp_path / "test_test.py" # Doesn't exist - } - - assert _is_workflow_complete(paths) is False # Requires test file - assert _is_workflow_complete(paths, skip_tests=True) is True # Skip test requirement +def test_find_prompt_file_preserves_in_root_symlink_alias(tmp_path): + """An in-root symlink alias (target inside the root) must still resolve.""" + prompts_root = tmp_path / "prompts" + prompts_root.mkdir() + real = prompts_root / "real_credits_Python.prompt" + real.write_text("% real in-root prompt\n", encoding="utf-8") + try: + (prompts_root / "credits_Python.prompt").symlink_to(real) + except OSError: + pytest.skip("file symlinks are unavailable") - def test_workflow_complete_with_both_skip_flags_needs_no_run_report(self, tmp_path, monkeypatch): - """When tests and verify are skipped, file existence is enough.""" - monkeypatch.chdir(tmp_path) - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - code_file = tmp_path / "test.py" - example_file = tmp_path / "test_example.py" - code_file.write_text("def add(a, b): return a + b") - example_file.write_text("from test import add") + result = _find_prompt_file("credits", "python", prompts_root, tmp_path / "architecture.json") - paths = { - 'code': code_file, - 'example': example_file, - 'test': tmp_path / "test_test.py" - } + assert result is not None + assert Path(result).resolve().is_relative_to(prompts_root.resolve()) - assert _is_workflow_complete( - paths, - skip_tests=True, - skip_verify=True, - basename="test", - language="python", - ) is True - - def test_workflow_incomplete(self, tmp_path): - """Test workflow is incomplete when required files are missing.""" - code_file = tmp_path / "test.py" - code_file.write_text("def add(a, b): return a + b") - - paths = { - 'code': code_file, - 'example': tmp_path / "test_example.py", # Doesn't exist - 'test': tmp_path / "test_test.py" # Doesn't exist - } - - assert _is_workflow_complete(paths) is False - assert _is_workflow_complete(paths, skip_tests=True) is False # Still needs example +def test_get_pdd_file_paths_broad_root_context_selection_from_parent_cwd( + tmp_path, + monkeypatch, +): + """Same-leaf prompts in sibling contexts must resolve by requested context, not CWD. -class TestSyncDetermineOperationRegressionScenarios: - """Additional regression tests for sync_determine_operation edge cases.""" - - def test_missing_files_with_metadata_regression_scenario(self, tmp_path): - """Test the exact regression scenario: files deleted but metadata remains.""" - # Change to temp directory for the test - original_cwd = os.getcwd() - try: - os.chdir(tmp_path) - - # Create directory structure - (tmp_path / "prompts").mkdir() - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - - # Create prompt file - prompt_file = tmp_path / "prompts" / "simple_math_python.prompt" - prompt_file.write_text("""Create a Python module with a simple math function. + With a broad, project-level prompts root, two contexts owning a same-leaf prompt, + and resolution driven from the project's parent directory, `_find_prompt_file` + must load the context prefix (anchored at the prompts root, not the CWD) so the + Step-4 tie-break picks the requested context — not the shallowest/lexicographic + first match (backend). + """ + project = tmp_path / "project" + for ctx in ("backend", "frontend"): + d = project / "prompts" / ctx + d.mkdir(parents=True) + (d / "credits_Python.prompt").write_text(f"% {ctx} credits\n", encoding="utf-8") + (project / ".pdd" / "meta").mkdir(parents=True) + (project / ".pdd" / "locks").mkdir(parents=True) + (project / ".pddrc").write_text( + "contexts:\n" + " backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " frontend:\n paths: [\"frontend/**\", \"prompts/frontend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/frontend\"\n" + " generate_output_path: \"frontend/src/\"\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) # PARENT of the project. + abs_prompts = str((project / "prompts").resolve()) # BROAD root spanning both contexts. -Requirements: -- Function name: add -- Parameters: a, b (both numbers) -- Return: sum of a and b -""") - - # Create metadata (simulating previous successful sync) - meta_file = tmp_path / ".pdd" / "meta" / "simple_math_python.json" - meta_file.write_text(json.dumps({ - "pdd_version": "0.0.41", - "timestamp": datetime.now(timezone.utc).isoformat(), - "command": "test", - "prompt_hash": "abc123", - "code_hash": "def456", - "example_hash": "ghi789", - "test_hash": "jkl012" - }, indent=2)) - - # Files are deliberately missing (deleted like in regression test) - - # Test sync_determine_operation behavior - decision = sync_determine_operation( + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir=abs_prompts, + context_override="frontend", + ) + + resolved = paths["prompt"].resolve(strict=False).as_posix() + assert "/prompts/frontend/" in resolved + assert "/prompts/backend/" not in resolved + + +def test_get_pdd_file_paths_parses_architecture_once(tmp_path, monkeypatch): + """Prompt discovery and code-path selection must share ONE architecture snapshot. + + Parsing architecture.json separately per phase opens a window where an atomic + rewrite between phases pairs a prompt from the old registry with a code target + from the new one (torn pair). The nested prompt below forces the architecture + hint (Step 3) AND the code-path lookup, both of which previously reparsed the + file; a single snapshot means exactly one parse. + """ + import sync_determine_operation as sync_determine_module + + monkeypatch.chdir(tmp_path) + pdir = tmp_path / "prompts" / "backend" / "deep" + pdir.mkdir(parents=True) + (pdir / "credits_Python.prompt").write_text("% nested credits\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " outputs:\n code:\n path: \"backend/functions/{name}.py\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "deep/credits_Python.prompt", "filepath": "backend/functions/credits.py"} + ]}), + encoding="utf-8", + ) + + parses = {"n": 0} + original = sync_determine_module.extract_modules + + def counting(arch): + parses["n"] += 1 + return original(arch) + + monkeypatch.setattr(sync_determine_module, "extract_modules", counting) + + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) + + assert parses["n"] == 1 + assert paths["prompt"].resolve(strict=False).as_posix().endswith( + "prompts/backend/deep/credits_Python.prompt" + ) + assert paths["code"].resolve(strict=False).as_posix().endswith( + "backend/functions/credits.py" + ) + + +def test_get_pdd_file_paths_no_torn_pair_on_concurrent_architecture_rewrite(tmp_path, monkeypatch): + """A single resolution must draw prompt AND code from ONE architecture.json + version, even if the registry is atomically rewritten part-way through. + + This is the observable R12 guarantee, expressed behaviorally rather than as a + parse count: the reader is made to return a DIFFERENT registry on any read after + the first. If the resolver re-read architecture.json mid-resolution it would pair + the version-A prompt with a version-B code target (a torn pair), so the returned + code path must still match version A — proving one consistent snapshot. + """ + import sync_determine_operation as sync_determine_module + + monkeypatch.chdir(tmp_path) + pdir = tmp_path / "prompts" / "backend" / "deep" + pdir.mkdir(parents=True) + (pdir / "credits_Python.prompt").write_text("% nested credits\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " outputs:\n code:\n path: \"backend/functions/{name}.py\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "deep/credits_Python.prompt", "filepath": "backend/functions/credits.py"} + ]}), + encoding="utf-8", + ) + + version_a = [{"filename": "deep/credits_Python.prompt", "filepath": "backend/functions/credits.py"}] + version_b = [{"filename": "deep/credits_Python.prompt", "filepath": "backend/functions/credits_v2.py"}] + reads = {"n": 0} + + def rewriting(arch): + reads["n"] += 1 + # First read sees version A; any later read sees the "rewritten" version B. + source = version_a if reads["n"] == 1 else version_b + return [dict(m) for m in source] + + monkeypatch.setattr(sync_determine_module, "extract_modules", rewriting) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir="prompts/backend", context_override="backend", + ) + + assert paths["code"].resolve(strict=False).as_posix().endswith( + "backend/functions/credits.py" + ), f"torn pair: code path came from a re-read architecture version: {paths['code']!r}" + assert not paths["code"].as_posix().endswith("credits_v2.py") + + +def test_find_named_file_case_collision_is_deterministic(tmp_path): + """A case-fold collision for an artifact lookup resolves to a stable pick, + independent of directory iteration order (matching the resolver's determinism + guarantee elsewhere), instead of the first `iterdir()` entry. + """ + import sync_determine_operation as sync_determine_module + + a = tmp_path / "Foo_example.py" + b = tmp_path / "FOO_example.py" + a.write_text("", encoding="utf-8") + b.write_text("", encoding="utf-8") + if a.samefile(b): + pytest.skip("filesystem is case-insensitive") + + # Exact-cased target is absent; both files match case-insensitively. The stable + # (name, path) sort picks 'FOO_example.py' ('F'..'O' sort before 'oo'). + found = sync_determine_module._find_named_file(tmp_path, "foo_example.py") + assert found is not None and found.name == "FOO_example.py" + + +def test_get_pdd_file_paths_construct_paths_failure_anchors_fallback_at_subproject(tmp_path, monkeypatch): + """When construct_paths raises for a new module, the convention fallback anchors + code/example/test under the resolved subproject (.pddrc dir), not the parent CWD.""" + import sync_determine_operation as sync_determine_module + + project = tmp_path / "project" + (project / "prompts").mkdir(parents=True) + (project / ".pdd" / "meta").mkdir(parents=True) + (project / ".pdd" / "locks").mkdir(parents=True) + (project / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n defaults:\n prompts_dir: \"prompts\"\n", + encoding="utf-8", + ) + (project / "architecture.json").write_text(json.dumps({"modules": []}), encoding="utf-8") + monkeypatch.chdir(tmp_path) # PARENT of the subproject. + + def boom(*_a, **_k): + raise RuntimeError("construct_paths failed") + + monkeypatch.setattr(sync_determine_module, "construct_paths", boom) + + paths = get_pdd_file_paths( + "widget", "python", prompts_dir=str((project / "prompts").resolve()), + ) + + assert str(paths["code"].resolve(strict=False)).startswith(str(project.resolve())) + assert str(paths["test"].resolve(strict=False)).startswith(str(project.resolve())) + + +def test_get_pdd_file_paths_path_qualified_two_suffix_aligned_outputs_raise_ambiguous(tmp_path, monkeypatch): + """A PATH-QUALIFIED basename that suffix-aligns with two distinct valid outputs is + ambiguous and MUST raise, not resolve by architecture row order (R11).""" + import sync_determine_operation as sync_determine_module + + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "app/login/page_Python.prompt", "filepath": "app/login/page.py"}, + {"filename": "src/app/login/page_Python.prompt", "filepath": "src/app/login/page.py"}, + ]}), + encoding="utf-8", + ) + + with pytest.raises(sync_determine_module.AmbiguousModuleError): + get_pdd_file_paths("app/login/page", "python", prompts_dir="prompts") + + +def test_get_pdd_file_paths_external_absolute_prompt_root_finds_project_architecture(tmp_path, monkeypatch): + """An absolute custom prompt root OUTSIDE any project must not make architecture/config + discovery start from that external root and silently miss the caller's project's + authoritative mapping — config discovery falls back to the project (CWD).""" + project = tmp_path / "project" + (project / ".pdd" / "meta").mkdir(parents=True) + (project / ".pdd" / "locks").mkdir(parents=True) + (project / "architecture.json").write_text( + json.dumps({"modules": [{"filename": "widget_Python.prompt", "filepath": "src/widget.py"}]}), + encoding="utf-8", + ) + external = tmp_path / "external_prompts" # OUTSIDE the project; no config up-tree. + external.mkdir() + monkeypatch.chdir(project) + + paths = get_pdd_file_paths("widget", "python", prompts_dir=str(external.resolve())) + assert paths["code"].as_posix().endswith("src/widget.py") + + +@pytest.mark.parametrize("bad_content", ["{ not valid json ", "42", "\"a string\"", "{\"modules\": \"notalist\"}", "{\"modules\": [{\"filename\": \"x\"}, 42]}"]) +def test_get_pdd_file_paths_malformed_architecture_json_fails_closed(tmp_path, monkeypatch, bad_content): + """A present-but-malformed architecture.json — unparseable JSON, a top-level scalar, + or a non-list ``modules`` — fails closed with a path-resolution error rather than + silently resolving at convention fallback paths (R1).""" + import sync_determine_operation as sync_determine_module + + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "architecture.json").write_text(bad_content, encoding="utf-8") + + with pytest.raises(sync_determine_module.MalformedArchitectureError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts") + + +def test_get_pdd_file_paths_empty_registry_dict_is_not_malformed(tmp_path, monkeypatch): + """A dict registry that legitimately has no modules (``{"modules": []}`` or a dict + omitting the key) is NOT malformed — resolution falls back to configured paths.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "prompts" / "widget_Python.prompt").write_text("% widget\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text(json.dumps({"modules": []}), encoding="utf-8") + + paths = get_pdd_file_paths("widget", "python", prompts_dir="prompts") + assert paths["code"].name == "widget.py" + + +def test_get_pdd_file_paths_context_prefixed_qualified_ambiguity_uses_stripped_basename(tmp_path, monkeypatch): + """Path-qualified ambiguity detection uses the SAME context-relative basename as + final resolution: a context-prefixed qualified basename whose STRIPPED form + suffix-aligns with two distinct outputs must raise, not evade under the raw prefix.""" + import sync_determine_operation as sync_determine_module + + monkeypatch.chdir(tmp_path) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"**\"]\n defaults:\n prompts_dir: \"prompts/backend\"\n", + encoding="utf-8", + ) + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "app/login/page_Python.prompt", "filepath": "app/login/page.py"}, + {"filename": "src/app/login/page_Python.prompt", "filepath": "src/app/login/page.py"}, + ]}), + encoding="utf-8", + ) + + with pytest.raises(sync_determine_module.AmbiguousModuleError): + get_pdd_file_paths( + "backend/login/page", "python", + prompts_dir="prompts/backend", context_override="backend", + ) + + +def test_get_pdd_file_paths_whitespace_filename_row_does_not_inflate_ambiguity(tmp_path, monkeypatch): + """A whitespace-only architecture filename (ineligible in selection) must not count + toward ambiguity and falsely block a valid uniquely-named module.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "app/login/page_Python.prompt", "filepath": "app/login/page.py"}, + {"filename": " ", "filepath": "src/app/login/page.py"}, + ]}), + encoding="utf-8", + ) + + paths = get_pdd_file_paths("app/login/page", "python", prompts_dir="prompts") + assert paths["code"].as_posix().endswith("app/login/page.py") + + +def test_get_pdd_file_paths_noncanonical_architecture_metadata_rejected_end_to_end(tmp_path, monkeypatch): + """End-to-end (public API): a noncanonical architecture output filepath (dot segments) + is not used as an authoritative mapping; resolution falls back to a canonical path (R10).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "prompts" / "widget_Python.prompt").write_text("% widget\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{"filename": "widget_Python.prompt", "filepath": "src/./widget.py"}]}), + encoding="utf-8", + ) + + paths = get_pdd_file_paths("widget", "python", prompts_dir="prompts") + assert "/./" not in paths["code"].as_posix() + assert paths["code"].name == "widget.py" + + +def test_get_pdd_file_paths_qualified_foreign_named_suffix_row_no_false_ambiguity(tmp_path, monkeypatch): + """A row whose FILEPATH suffix-aligns with a path-qualified basename but whose PROMPT + filename names a DIFFERENT module must not count toward ambiguity; the uniquely named + row resolves without a false AmbiguousModuleError.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "app/login/page_Python.prompt", "filepath": "app/login/page.py"}, + {"filename": "other_Python.prompt", "filepath": "src/app/login/page.py"}, + ]}), + encoding="utf-8", + ) + + paths = get_pdd_file_paths("app/login/page", "python", prompts_dir="prompts") + assert paths["code"].as_posix().endswith("app/login/page.py") + + +def test_get_pdd_file_paths_path_qualified_unsafe_filename_row_does_not_block(tmp_path, monkeypatch): + """A row with an unsafe architecture FILENAME must not count toward path-qualified + ambiguity and falsely block a valid mapping.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "../../evil_Python.prompt", "filepath": "src/app/login/page.py"}, + {"filename": "app/login/page_Python.prompt", "filepath": "app/login/page.py"}, + ]}), + encoding="utf-8", + ) + + # The unsafe-filename row is excluded, so only one valid output remains -> no ambiguity. + paths = get_pdd_file_paths("app/login/page", "python", prompts_dir="prompts") + assert paths["code"].as_posix().endswith("app/login/page.py") + + +def test_get_pdd_file_paths_trailing_space_output_does_not_block_valid_row(tmp_path, monkeypatch): + """A trailing-space (noncanonical) output row — which resolution rejects — must not be + canonicalized in ambiguity counting and falsely conflict with a valid distinct row.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "prompts" / "widget_Python.prompt").write_text("% widget\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "widget_Python.prompt", "filepath": "src/foo.py "}, # trailing space + {"filename": "widget_Python.prompt", "filepath": "src/bar.py"}, + ]}), + encoding="utf-8", + ) + + # The trailing-space row is unsafe (excluded), so only ONE valid output remains. + paths = get_pdd_file_paths("widget", "python", prompts_dir="prompts") + assert paths["code"].as_posix().endswith("src/bar.py") + + +def test_get_pdd_file_paths_exact_missing_prompt_row_shared_target_honored(tmp_path, monkeypatch): + """An exact architecture row that names the requested module (prompt not created yet) + with a SAFE shared/unowned target is honored, not rejected into a fallback path just + because eligibility would otherwise require context ownership or a physical prompt.""" + (tmp_path / "prompts" / "backend").mkdir(parents=True) # prompt MISSING (new module) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n generate_output_path: \"backend/functions/\"\n" + " frontend:\n paths: [\"frontend/**\"]\n defaults:\n prompts_dir: \"prompts/frontend\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{"filename": "credits_Python.prompt", "filepath": "shared/credits.py"}]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", + prompts_dir=str((tmp_path / "prompts" / "backend").resolve()), context_override="backend", + ) + assert paths["code"].as_posix().endswith("shared/credits.py") + + +def test_get_pdd_file_paths_auxiliary_root_symlink_escape_does_not_invalidate_active_owner(tmp_path, monkeypatch): + """An escaping same-leaf symlink in an UNRELATED auxiliary prompt root must not + invalidate the unique contained owner in the ACTIVE prompt root, whose authoritative + architecture mapping is retained.""" + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "frontend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + external = tmp_path / "external.prompt" + external.write_text("% external\n", encoding="utf-8") + try: + (tmp_path / "prompts" / "frontend" / "credits_Python.prompt").symlink_to(external) + except (OSError, NotImplementedError): + pytest.skip("symlinks unsupported") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " frontend:\n paths: [\"frontend/**\", \"prompts/frontend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/frontend\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{"filename": "credits_Python.prompt", "filepath": "backend/credits.py"}]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", + prompts_dir=str((tmp_path / "prompts" / "backend").resolve()), context_override="backend", + ) + assert paths["code"].as_posix().endswith("backend/credits.py") + + +def test_get_pdd_file_paths_no_override_none_context_denies_foreign_sibling_borrow(tmp_path, monkeypatch): + """With no context_override and a basename that does not encode the context (resolved + context None), a FOREIGN heuristic row (filepath-stem match, not naming this module) + targeting a named sibling context must be denied, not paired with this prompt.""" + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n generate_output_path: \"backend/functions/\"\n" + " frontend:\n paths: [\"frontend/**\"]\n defaults:\n prompts_dir: \"prompts/frontend\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{"filename": "credits.tsx", "filepath": "frontend/credits.py"}]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir=str((tmp_path / "prompts" / "backend").resolve()), + ) + assert not paths["code"].as_posix().endswith("frontend/credits.py") + + +def test_get_pdd_file_paths_malformed_pddrc_denies_heuristic_borrow(tmp_path, monkeypatch): + """When the .pddrc defining context territory is present but UNPARSEABLE, a heuristic + (non-proven) architecture borrow cannot be confined and is denied (fail closed) rather + than falling open to permit a sibling-context target.""" + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text("contexts: {[ not valid yaml", encoding="utf-8") + # Non-prompt filename -> heuristic (filepath-stem) borrow targeting a sibling path. + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{"filename": "credits.tsx", "filepath": "frontend/credits.py"}]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", + prompts_dir=str((tmp_path / "prompts" / "backend").resolve()), + context_override="backend", + ) + assert not paths["code"].as_posix().endswith("frontend/credits.py") + + +def test_get_pdd_file_paths_unaligned_escaping_symlink_does_not_block_qualified_creation(tmp_path, monkeypatch): + """An escaping same-leaf symlink under an UNRELATED directory must not hard-fail a + path-qualified new-module creation whose basename it does not align with.""" + (tmp_path / "prompts" / "unrelated").mkdir(parents=True) + external = tmp_path / "external_page.prompt" + external.write_text("% external\n", encoding="utf-8") + try: + (tmp_path / "prompts" / "unrelated" / "page_Python.prompt").symlink_to(external) + except (OSError, NotImplementedError): + pytest.skip("symlinks unsupported") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + # New path-qualified module backend/foo/page; the unrelated escaping symlink (leaf + # 'page') must not hard-fail it. + paths = get_pdd_file_paths( + "backend/foo/page", "python", prompts_dir=str((tmp_path / "prompts").resolve()), + ) + assert paths["prompt"].name.lower() == "page_python.prompt" + + +def test_get_pdd_file_paths_bare_basename_two_valid_outputs_raise_ambiguous(tmp_path, monkeypatch): + """A bare basename that architecture.json maps to two DISTINCT valid outputs MUST + raise AmbiguousModuleError before any prompt/fallback resolution (positive R11).""" + import sync_determine_operation as sync_determine_module + + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "page_Python.prompt", "filepath": "a/page.py"}, + {"filename": "page_Python.prompt", "filepath": "b/page.py"}, + ]}), + encoding="utf-8", + ) + + with pytest.raises(sync_determine_module.AmbiguousModuleError): + get_pdd_file_paths("page", "python", prompts_dir="prompts") + + +def test_get_pdd_file_paths_case_variant_stem_outputs_raise_ambiguous(tmp_path, monkeypatch): + """Two case-variant filepath-stem outputs for a bare basename are BOTH counted + (case-insensitively, matching resolution) and raise AmbiguousModuleError; a + case-sensitive count would undercount and let the module silently fall through.""" + import sync_determine_operation as sync_determine_module + + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "A.tsx", "filepath": "a/foo.py"}, + {"filename": "B.tsx", "filepath": "b/Foo.py"}, + ]}), + encoding="utf-8", + ) + + with pytest.raises(sync_determine_module.AmbiguousModuleError): + get_pdd_file_paths("foo", "python", prompts_dir="prompts") + + +def test_get_pdd_file_paths_non_string_filepath_does_not_block_valid_module(tmp_path, monkeypatch): + """A malformed row with a non-string filepath is ignored, not stringified into a + bogus distinct output that would falsely raise AmbiguousModuleError for a valid row.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "prompts" / "page_Python.prompt").write_text("% page\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "page_Python.prompt", "filepath": 123}, + {"filename": "page_Python.prompt", "filepath": "src/page.py"}, + ]}), + encoding="utf-8", + ) + + paths = get_pdd_file_paths("page", "python", prompts_dir="prompts") + assert paths["code"].as_posix().endswith("src/page.py") + + +def test_filepath_matches_context_handles_windows_drive_config(): + """A Windows drive-qualified context path (``C:/proj/frontend/**``) is not + POSIX-absolute; it must still be relativized against the project so sibling + territory is detected instead of being treated as a non-matching literal.""" + import sync_determine_operation as sync_determine_module + + project_root = Path("C:/proj") + ctx = {"paths": ["C:/proj/frontend/**"]} + assert sync_determine_module._filepath_matches_context( + "frontend/credits.py", ctx, project_root, repo_root_output_matches=False + ) is True + assert sync_determine_module._filepath_matches_context( + "backend/credits.py", ctx, project_root, repo_root_output_matches=False + ) is not True + + +@pytest.mark.parametrize("bad_filename", [123, 4.5, True, ["x"], {"k": "v"}]) +def test_get_pdd_file_paths_non_string_filename_uses_filepath(tmp_path, monkeypatch, bad_filename): + """A non-string architecture filename (number, bool, list, object) is treated as + ABSENT, so the module resolves by its filepath stem instead of a stringified name + or an AttributeError swallowed into a wrong default (R13).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n api:\n paths: [\"src/**\", \"prompts/**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n", + encoding="utf-8", + ) + (tmp_path / "prompts" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{"filename": bad_filename, "filepath": "src/foo.py"}]}), + encoding="utf-8", + ) + + paths = get_pdd_file_paths("foo", "python", prompts_dir="prompts", context_override="api") + assert paths["code"].as_posix().endswith("src/foo.py") + + +def test_filepath_matches_context_windows_paths_are_case_insensitive(): + """When the project root is a Windows (drive-qualified) path, territory matching is + case-insensitive, so a drive/dir casing difference between .pddrc and the resolved + project root cannot hide sibling ownership. POSIX roots stay case-sensitive.""" + import sync_determine_operation as sync_determine_module + + # Config uses ``C:/Proj`` while the resolved project root is ``c:/proj`` (Windows is + # case-insensitive); ``Frontend/foo.py`` must still be recognized as frontend's. + ctx = {"paths": ["C:/Proj/Frontend/**"]} + assert sync_determine_module._filepath_matches_context( + "frontend/foo.py", ctx, Path("c:/proj"), repo_root_output_matches=False + ) is True + # A POSIX root keeps case-sensitive semantics: a case-variant glob does NOT match. + ctx_posix = {"paths": ["Frontend/**"]} + assert sync_determine_module._filepath_matches_context( + "frontend/foo.py", ctx_posix, Path("/proj"), repo_root_output_matches=False + ) is not True + + +def test_filepath_matches_context_normalizes_dot_slash_glob(): + """A ``./frontend/**`` context glob is normalized so it matches the normalized + project-relative architecture filepath instead of silently missing (territory + detection for R5/R6).""" + import sync_determine_operation as sync_determine_module + + ctx = {"paths": ["./frontend/**"]} + assert sync_determine_module._filepath_matches_context( + "frontend/foo.py", ctx, Path("/proj"), repo_root_output_matches=False + ) is True + assert sync_determine_module._filepath_matches_context( + "backend/foo.py", ctx, Path("/proj"), repo_root_output_matches=False + ) is not True + + +@pytest.mark.parametrize("bad_language", ["CON", "aux.txt", "lpt1", "foo bar", "foo."]) +def test_get_pdd_file_paths_rejects_nonportable_language(tmp_path, monkeypatch, bad_language): + """A reserved-device / whitespace / trailing-dot language component fails closed + rather than being interpolated into prompt or artifact paths (R9).""" + (tmp_path / "prompts").mkdir() + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("foo", bad_language, prompts_dir="prompts") + + +def test_get_pdd_file_paths_filepath_stem_match_is_case_insensitive(tmp_path, monkeypatch): + """A filepath-stem architecture match (non-prompt filename) must resolve for a + case-variant basename too, consistent with case-insensitive prompt discovery (R4).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n api:\n paths: [\"src/**\", \"prompts/**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n", + encoding="utf-8", + ) + (tmp_path / "prompts" / "Foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + # Non-prompt filename -> module is identified by its filepath stem ('foo'). + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{"filename": "Foo.tsx", "filepath": "src/foo.py"}]}), + encoding="utf-8", + ) + + paths = get_pdd_file_paths("Foo", "python", prompts_dir="prompts", context_override="api") + assert paths["code"].as_posix().endswith("src/foo.py") + + +def _write_two_context_pddrc(root): + (root / ".pdd" / "meta").mkdir(parents=True) + (root / ".pdd" / "locks").mkdir(parents=True) + (root / ".pddrc").write_text( + "contexts:\n" + " backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " outputs:\n code:\n path: \"backend/functions/{name}.py\"\n" + " frontend:\n paths: [\"frontend/**\", \"prompts/frontend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/frontend\"\n" + " generate_output_path: \"frontend/src/\"\n", + encoding="utf-8", + ) + + +def test_get_pdd_file_paths_arch_hint_respects_context_from_broad_root(tmp_path, monkeypatch): + """A broad-root backend resolution must not borrow a sibling frontend arch row. + + With the project-level prompts root, `_find_prompt_file`'s architecture hint runs + a bare-leaf lookup. If it ignores context ownership it returns the frontend row's + prompt (and code) via the direct join, before the context-aware recursive fallback + runs — so `pdd sync credits` in the backend context overwrites frontend/credits.py. + The hint must reject a row whose filepath is outside the resolving context. + """ + for ctx in ("backend", "frontend"): + d = tmp_path / "prompts" / ctx + d.mkdir(parents=True) + (d / "credits_Python.prompt").write_text(f"% {ctx} credits\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + # Only a frontend row exists (no backend row => no ambiguity error). + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "frontend/credits_Python.prompt", "filepath": "frontend/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", + prompts_dir=str((tmp_path / "prompts").resolve()), + context_override="backend", + ) + + assert "/prompts/backend/" in paths["prompt"].resolve(strict=False).as_posix() + assert not paths["code"].resolve(strict=False).as_posix().endswith("frontend/credits.py") + + +def test_get_pdd_file_paths_unsafe_same_leaf_row_does_not_block_valid_module(tmp_path, monkeypatch): + """An unsafe same-leaf architecture row must not raise AmbiguousModuleError. + + A valid ``foo_Python.prompt`` row plus an unsafe ``../../foo_Python.prompt`` row + share the leaf ``foo``. The unsafe row is rejected before generation; it must also + be excluded from ambiguity counting, otherwise its collision with the valid row + blocks a legitimate module instead of resolving to the safe target. + """ + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n generate_output_path: \"src/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "foo_Python.prompt", "filepath": "src/foo.py"}, + {"filename": "../../foo_Python.prompt", "filepath": "escaped/foo.py"}, + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths("foo", "python", prompts_dir="prompts") # must NOT raise + + assert paths["code"].resolve(strict=False).as_posix().endswith("src/foo.py") + + +def test_get_pdd_file_paths_null_filename_uses_architecture_filepath(tmp_path, monkeypatch): + """A module with ``"filename": null`` must resolve to its architecture filepath. + + Coercing the null filename avoids ``None.lower()`` (an AttributeError the broad + fallback swallows into a cwd-relative default); the module is then matched by its + filepath stem and resolves to the architecture-declared ``src/foo.py``. + """ + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n generate_output_path: \"src/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{"filename": None, "filepath": "src/foo.py"}]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths("foo", "python", prompts_dir="prompts") + + assert paths["code"].resolve(strict=False).as_posix().endswith("src/foo.py") + + +def test_get_pdd_file_paths_flat_legacy_row_respects_context_territory(tmp_path, monkeypatch): + """A FLAT legacy same-leaf row must not bypass context ownership. + + The leaf-match and filepath-stem borrows already apply territory, but the + basename+language match loop did not. A stale sibling row with a flat + ``credits_Python.prompt`` filename pointing at ``frontend/credits.py`` could be + borrowed by a backend resolution, overwriting sibling-context code. + """ + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "credits_Python.prompt", "filepath": "frontend/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", + prompts_dir=str((tmp_path / "prompts").resolve()), + context_override="backend", + ) + + assert not paths["code"].resolve(strict=False).as_posix().endswith("frontend/credits.py") + + +def test_get_pdd_file_paths_canonical_target_resolves_from_parent_cwd(tmp_path, monkeypatch): + """A path-qualified canonical target must resolve when run from a parent CWD. + + `_get_filepath_from_architecture` re-detected the context and stripped the + basename prefix via a CWD-based `.pddrc` lookup. From the project's parent with an + absolute prompts root, that missed the context, so the canonical + ``backend/services/foo.py`` row failed to align with ``backend/foo`` and resolution + fell back to the default ``backend/functions/foo.py``. The lookup must anchor at + the project and prefer the resolved context. + """ + project = tmp_path / "project" + (project / "prompts" / "backend").mkdir(parents=True) + (project / "prompts" / "backend" / "foo_Python.prompt").write_text("% backend foo\n", encoding="utf-8") + _write_two_context_pddrc(project) + (project / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "backend/services/foo_Python.prompt", "filepath": "backend/services/foo.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) # PARENT of the project. + + paths = get_pdd_file_paths( + "backend/foo", "python", + prompts_dir=str((project / "prompts").resolve()), + context_override="backend", + ) + + assert paths["code"].resolve(strict=False).as_posix().endswith("backend/services/foo.py") + + +@pytest.mark.parametrize( + "unsafe_filepath", + ["../../outside/foo.py", "/tmp/pdd-outside-foo.py", "D:/outside/foo.py"], +) +def test_get_pdd_file_paths_unsafe_filepath_row_does_not_block_valid_module( + tmp_path, monkeypatch, unsafe_filepath +): + """An unsafe OUTPUT path on a same-filename row must not cause false ambiguity. + + A valid ``foo_Python.prompt -> src/foo.py`` row plus a same-filename row targeting + an unsafe filepath (absolute, ``..``, or Windows drive) must not read as two + distinct targets — the unsafe row is rejected before generation and must be + excluded from ambiguity counting so the valid module still resolves. + """ + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n generate_output_path: \"src/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "foo_Python.prompt", "filepath": "src/foo.py"}, + {"filename": "foo_Python.prompt", "filepath": unsafe_filepath}, + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths("foo", "python", prompts_dir="prompts") # must NOT raise + + assert paths["code"].resolve(strict=False).as_posix().endswith("src/foo.py") + + +def test_get_pdd_file_paths_context_inferred_from_parent_cwd_without_override(tmp_path, monkeypatch): + """Context must be inferable from a parent CWD even with NO explicit override. + + ``_resolve_context_name_for_basename`` searched from the process CWD, so a + path-qualified ``backend/foo`` run from the project's parent (no override) failed to + detect the backend context and missed the canonical ``backend/services/foo.py``, + falling back to ``backend/functions/foo.py``. The lookup must anchor at the prompts + root. + """ + project = tmp_path / "project" + (project / "prompts" / "backend").mkdir(parents=True) + (project / "prompts" / "backend" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + _write_two_context_pddrc(project) + (project / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "backend/services/foo_Python.prompt", "filepath": "backend/services/foo.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) # PARENT; note: NO context_override below. + + paths = get_pdd_file_paths( + "backend/foo", "python", prompts_dir=str((project / "prompts").resolve()), + ) + + assert paths["code"].resolve(strict=False).as_posix().endswith("backend/services/foo.py") + + +def test_get_pdd_file_paths_custom_root_finds_existing_prompt_from_parent_cwd(tmp_path, monkeypatch): + """Custom-root prompt discovery must strip the context prefix from a parent CWD. + + `_find_prompt_file` built its basename candidates and did prefix stripping via a + CWD-based `.pddrc` lookup. From a parent CWD with a custom prompt root, the context + prefix was not stripped, so an existing ``specs/services/utils/foo_Python.prompt`` + was missed and a duplicated ``specs/services/backend/utils/foo_Python.prompt`` was + returned (risking a duplicate prompt). The anchor must reach candidate building. + """ + project = tmp_path / "project" + (project / "specs" / "services" / "utils").mkdir(parents=True) + existing = project / "specs" / "services" / "utils" / "foo_Python.prompt" + existing.write_text("% existing\n", encoding="utf-8") + (project / ".pdd" / "meta").mkdir(parents=True) + (project / ".pdd" / "locks").mkdir(parents=True) + (project / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"specs/services/**\"]\n" + " defaults:\n prompts_dir: \"specs/services\"\n" + " generate_output_path: \"backend/functions/\"\n", + encoding="utf-8", + ) + (project / "architecture.json").write_text(json.dumps({"modules": []}), encoding="utf-8") + monkeypatch.chdir(tmp_path) # PARENT. + + paths = get_pdd_file_paths( + "backend/utils/foo", "python", + prompts_dir=str((project / "specs" / "services").resolve()), + context_override="backend", + ) + + assert paths["prompt"].resolve(strict=False) == existing.resolve() + + +def test_get_pdd_file_paths_example_test_templates_not_duplicated_from_parent_cwd(tmp_path, monkeypatch): + """Example/test artifact paths must not duplicate the context prefix from a parent CWD. + + ``construct_paths_basename`` was stripped via a CWD-based `.pddrc` lookup, so from a + parent CWD a path-qualified ``backend/foo`` kept its ``backend/`` prefix and produced + ``backend/examples/backend/foo_example.py``. Anchoring the strip yields the + configured, non-duplicated paths. + """ + project = tmp_path / "project" + (project / "prompts" / "backend").mkdir(parents=True) + (project / "prompts" / "backend" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + (project / ".pdd" / "meta").mkdir(parents=True) + (project / ".pdd" / "locks").mkdir(parents=True) + # Exact example/test TEMPLATES with {category}: the category is the basename's + # directory part, which duplicates the `backend/` prefix if the basename is not + # stripped to `foo` (which requires the CWD-independent .pddrc anchor). + (project / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " outputs:\n" + " example:\n path: \"backend/examples/{category}/{name}_example.py\"\n" + " test:\n path: \"backend/tests/{category}/test_{name}.py\"\n", + encoding="utf-8", + ) + (project / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "backend/foo_Python.prompt", "filepath": "backend/functions/foo.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) # PARENT. + + paths = get_pdd_file_paths( + "backend/foo", "python", prompts_dir=str((project / "prompts").resolve()), + context_override="backend", + ) + + assert "examples/backend/" not in paths["example"].resolve(strict=False).as_posix() + assert "tests/backend/" not in paths["test"].resolve(strict=False).as_posix() + + +def test_architecture_module_choices_defers_containment_to_matches(tmp_path, monkeypatch): + """Ambiguity counting must not filesystem-resolve every module's filepath. + + Containment resolution is O(filesystem) and must run only for the handful of rows + whose filename/stem actually matches the requested basename, not for every module — + otherwise a large architecture makes each lookup many times slower. + """ + import sync_determine_operation as sync_determine_module + + modules = [ + {"filename": f"m{i}_Python.prompt", "filepath": f"src/m{i}.py"} for i in range(300) + ] + modules.append({"filename": "foo_Python.prompt", "filepath": "src/foo.py"}) + (tmp_path / "architecture.json").write_text(json.dumps({"modules": modules}), encoding="utf-8") + + calls = {"n": 0} + original = sync_determine_module._contained_architecture_code_path + + def counting(project_root, filepath): + calls["n"] += 1 + return original(project_root, filepath) + + monkeypatch.setattr(sync_determine_module, "_contained_architecture_code_path", counting) + + choices = sync_determine_module._architecture_module_choices( + tmp_path / "architecture.json", "foo", "python", modules=modules + ) + + assert choices == ["src/foo.py"] + assert calls["n"] <= 5 # only the matching row(s), not all 301 modules. + + +def test_get_pdd_file_paths_new_module_outputs_under_subproject_from_parent_cwd(tmp_path, monkeypatch): + """A NEW module's outputs must resolve under the subproject, not the parent CWD. + + The missing-prompt branch called construct_paths with no anchoring input and + forced ``path_resolution_mode="cwd"``, so from the project's parent the code/test + paths resolved under the parent (``/backend/foo.py``). Passing the prompt + path as a hint lets construct_paths find the subproject ``.pddrc`` and resolve + against it. + """ + project = tmp_path / "project" + (project / "prompts" / "backend").mkdir(parents=True) # NOTE: no foo prompt (new module) + _write_two_context_pddrc(project) + (project / "architecture.json").write_text(json.dumps({"modules": []}), encoding="utf-8") + monkeypatch.chdir(tmp_path) # PARENT. + + paths = get_pdd_file_paths( + "foo", "python", + prompts_dir=str((project / "prompts" / "backend").resolve()), + context_override="backend", + ) + + code = paths["code"].resolve(strict=False) + assert str(code).startswith(str(project.resolve())) + assert code.as_posix().endswith("backend/functions/foo.py") + + +def test_get_pdd_file_paths_non_architecture_templates_not_duplicated_from_parent_cwd(tmp_path, monkeypatch): + """Existing-prompt template paths (no architecture entry) must not duplicate the prefix. + + The non-architecture template branch recomputed the basename without the project + anchor, so from a parent CWD a `{category}` template duplicated the context prefix + (`backend/examples/backend/foo_example.py`). + """ + project = tmp_path / "project" + (project / "prompts" / "backend").mkdir(parents=True) + (project / "prompts" / "backend" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + (project / ".pdd" / "meta").mkdir(parents=True) + (project / ".pdd" / "locks").mkdir(parents=True) + (project / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " outputs:\n" + " example:\n path: \"backend/examples/{category}/{name}_example.py\"\n" + " test:\n path: \"backend/tests/{category}/test_{name}.py\"\n", + encoding="utf-8", + ) + # No architecture entry for foo — exercise the non-architecture template branch. + (project / "architecture.json").write_text(json.dumps({"modules": []}), encoding="utf-8") + monkeypatch.chdir(tmp_path) # PARENT. + + paths = get_pdd_file_paths( + "backend/foo", "python", prompts_dir=str((project / "prompts").resolve()), + context_override="backend", + ) + + assert "examples/backend/" not in paths["example"].resolve(strict=False).as_posix() + assert "tests/backend/" not in paths["test"].resolve(strict=False).as_posix() + + +def test_get_pdd_file_paths_proven_owner_honored_for_shared_target(tmp_path, monkeypatch): + """A PROVEN-owner architecture row keeps a shared, no-context code target. + + When an architecture row's physical prompt owner IS the resolved prompt (proven, + explicit mapping), its authoritative code target must be honored even when it lies + outside the context's own globs — as long as it belongs to NO sibling context + (an intentionally shared path). Territory only guards heuristic borrows. + """ + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "backend/foo_Python.prompt", "filepath": "shared/foo.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "foo", "python", prompts_dir="prompts/backend", context_override="backend", + ) + + assert paths["code"].resolve(strict=False).as_posix().endswith("shared/foo.py") + + +def test_get_pdd_file_paths_proven_owner_still_rejects_sibling_context_target(tmp_path, monkeypatch): + """The proven-owner exception must NOT extend to a SIBLING context's territory. + + A stale flat row (`credits_Python.prompt` -> `frontend/credits.py`) leaf-collides + with the backend prompt and thus proves ownership, but its target is owned by the + frontend context — it must still be rejected, or backend sync overwrites frontend + code. This locks in the round-5 behavior against the round-7 proven-owner relaxation. + """ + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "credits_Python.prompt", "filepath": "frontend/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir=str((tmp_path / "prompts").resolve()), + context_override="backend", + ) + + assert not paths["code"].resolve(strict=False).as_posix().endswith("frontend/credits.py") + + +def test_get_pdd_file_paths_flat_same_leaf_in_two_roots_not_proven(tmp_path, monkeypatch): + """A flat architecture filename that matches distinct same-leaf prompts in TWO + context roots is ambiguously owned: it must not be classified as a proven owner and + borrowed into a shared code target (both contexts would otherwise claim one file). + """ + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "frontend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + (tmp_path / "prompts" / "frontend" / "credits_Python.prompt").write_text("% frontend\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "credits_Python.prompt", "filepath": "shared/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", + prompts_dir=str((tmp_path / "prompts" / "backend").resolve()), + context_override="backend", + ) + + assert not paths["code"].resolve(strict=False).as_posix().endswith("shared/credits.py") + + +def test_get_pdd_file_paths_rejects_symlinked_code_path_into_sibling_context(tmp_path, monkeypatch): + """A code filepath that resolves THROUGH an in-project symlink into a sibling + context's territory must be rejected, even though it is lexically inside the + resolving context and stays within the project root. + + ``backend/link`` -> ``frontend``, and architecture.json targets + ``backend/link/credits.py``. Lexically that is backend territory and passes + project containment, but it physically lands in the frontend sibling context. + Sibling ownership is checked against the resolved identity, so the borrow is + rejected and a backend sync cannot overwrite frontend code through the alias. + """ + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + (tmp_path / "frontend").mkdir() + (tmp_path / "backend").mkdir() + os.symlink(tmp_path / "frontend", tmp_path / "backend" / "link") + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "credits_Python.prompt", "filepath": "backend/link/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir=str((tmp_path / "prompts").resolve()), + context_override="backend", + ) + + resolved = paths["code"].resolve(strict=False).as_posix() + assert not resolved.endswith("frontend/credits.py"), ( + f"symlinked code path leaked into the frontend sibling context: {resolved!r}" + ) + + +def test_get_pdd_file_paths_existing_prompt_template_anchored_from_parent_cwd(tmp_path, monkeypatch): + """An EXISTING prompt's template outputs must anchor at the subproject too. + + Round 7 anchored only the missing-prompt template branch; the existing-prompt + branch still returned project-relative paths, so from a parent CWD an existing + prompt's example/test resolved under the parent. + """ + project = tmp_path / "project" + (project / "prompts" / "backend").mkdir(parents=True) + (project / "prompts" / "backend" / "foo_Python.prompt").write_text("% foo\n", encoding="utf-8") + (project / ".pdd" / "meta").mkdir(parents=True) + (project / ".pdd" / "locks").mkdir(parents=True) + (project / ".pddrc").write_text( + "contexts:\n backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " outputs:\n" + " example:\n path: \"backend/examples/{name}_example.py\"\n" + " test:\n path: \"backend/tests/test_{name}.py\"\n", + encoding="utf-8", + ) + (project / "architecture.json").write_text(json.dumps({"modules": []}), encoding="utf-8") + monkeypatch.chdir(tmp_path) # PARENT. + + paths = get_pdd_file_paths( + "backend/foo", "python", prompts_dir=str((project / "prompts").resolve()), + context_override="backend", + ) + + assert str(paths["example"].resolve(strict=False)).startswith(str(project.resolve())) + assert str(paths["test"].resolve(strict=False)).startswith(str(project.resolve())) + + +def test_get_pdd_file_paths_absolute_sibling_output_path_still_isolates(tmp_path, monkeypatch): + """A sibling context with an ABSOLUTE output path must still own its code. + + `_filepath_matches_context` compared raw config prefixes against the project-relative + architecture value, so an absolute `generate_output_path` never matched and the + sibling context stopped owning its code — a stale flat row targeting `frontend/` + was then accepted by a backend resolution. Absolute config paths are now + re-expressed relative to the project before comparison. + """ + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "backend" / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + abs_frontend = (tmp_path / "frontend").resolve().as_posix() + # Frontend's relative paths do NOT cover frontend/credits.py; only its ABSOLUTE + # generate_output_path does. + (tmp_path / ".pddrc").write_text( + "contexts:\n" + " backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " frontend:\n paths: [\"frontend/src/**\"]\n" + f" defaults:\n prompts_dir: \"prompts/frontend\"\n generate_output_path: \"{abs_frontend}/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "credits_Python.prompt", "filepath": "frontend/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir=str((tmp_path / "prompts").resolve()), + context_override="backend", + ) + + assert not paths["code"].resolve(strict=False).as_posix().endswith("frontend/credits.py") + + +def test_get_pdd_file_paths_flat_hint_selects_requested_context_prompt(tmp_path, monkeypatch): + """A legacy FLAT architecture hint must resolve the prompt in the REQUESTED context. + + When a flat architecture filename matches the same leaf in two context subdirs, + `_resolve_prompt_path_from_architecture` sorted the recursive matches without + context and returned the shallowest/lexicographic first — so a `frontend` request + got the `backend` prompt while code resolved under `frontend` (a torn pair). The + recursive search now prefers the resolving context's prefix. + """ + for ctx in ("backend", "frontend"): + d = tmp_path / "prompts" / ctx + d.mkdir(parents=True) + (d / "credits_Python.prompt").write_text(f"% {ctx}\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "credits_Python.prompt", "filepath": "frontend/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir=str((tmp_path / "prompts").resolve()), + context_override="frontend", + ) + + resolved = paths["prompt"].resolve(strict=False).as_posix() + assert "/prompts/frontend/" in resolved + assert "/prompts/backend/" not in resolved + + +def test_get_pdd_file_paths_exact_flat_row_respects_sibling_territory(tmp_path, monkeypatch): + """An exact flat filename row cannot redirect a narrowed root to sibling code.""" + backend_prompts = tmp_path / "prompts" / "backend" + backend_prompts.mkdir(parents=True) + (backend_prompts / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "credits_Python.prompt", "filepath": "frontend/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir="prompts/backend", context_override="backend", + ) + + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) + + +def test_get_pdd_file_paths_context_prefix_matches_path_components(tmp_path, monkeypatch): + """The backend context must not select a lexicographically earlier a-backend prompt.""" + for context in ("a-backend", "backend"): + prompt_dir = tmp_path / "prompts" / context + prompt_dir.mkdir(parents=True) + (prompt_dir / "credits_Python.prompt").write_text( + f"% {context}\n", encoding="utf-8" + ) + _write_two_context_pddrc(tmp_path) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "credits_Python.prompt", "filepath": "backend/functions/credits.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir="prompts", context_override="backend", + ) + + resolved = paths["prompt"].resolve(strict=False).as_posix() + assert "/prompts/backend/" in resolved + assert "/prompts/a-backend/" not in resolved + + +def test_get_pdd_file_paths_absolute_sibling_prompt_root_establishes_owner( + tmp_path, + monkeypatch, +): + """Contained absolute sibling prompt roots participate in ownership discovery.""" + backend_root = tmp_path / "apps" / "backend" / "specs" + frontend_root = tmp_path / "apps" / "frontend" / "specs" + backend_root.mkdir(parents=True) + (frontend_root / "frontend").mkdir(parents=True) + (backend_root / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + (frontend_root / "frontend" / "credits_Python.prompt").write_text( + "% frontend\n", encoding="utf-8" + ) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n" + " backend:\n paths: [\"backend/**\"]\n" + f" defaults:\n prompts_dir: \"{backend_root.as_posix()}\"\n" + " generate_output_path: \"backend/generated/\"\n" + " frontend:\n paths: [\"frontend/**\"]\n" + f" defaults:\n prompts_dir: \"{frontend_root.as_posix()}\"\n" + " generate_output_path: \"frontend/generated/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{ + "filename": "frontend/credits_Python.prompt", + "filepath": "backend/foreign/credits.py", + }]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir=str(backend_root), context_override="backend", + ) + + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "generated" / "credits.py" + ).resolve(strict=False) + assert not paths["code"].resolve(strict=False).as_posix().endswith( + "backend/foreign/credits.py" + ) + + +def test_get_pdd_file_paths_repo_root_output_still_rejects_sibling_target( + tmp_path, + monkeypatch, +): + """A repo-root current output cannot override explicit sibling ownership.""" + backend_prompts = tmp_path / "prompts" / "backend" + backend_prompts.mkdir(parents=True) + (backend_prompts / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n" + " backend:\n paths: [\"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"./\"\n" + " frontend:\n paths: [\"frontend/**\", \"prompts/frontend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/frontend\"\n" + " generate_output_path: \"frontend/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{ + "filename": "credits_Python.prompt", + "filepath": "frontend/credits.py", + }]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir="prompts/backend", context_override="backend", + ) + + assert not paths["code"].resolve(strict=False).as_posix().endswith( + "frontend/credits.py" + ) + + +def test_get_pdd_file_paths_custom_context_prefix_is_relative_to_active_root( + tmp_path, + monkeypatch, +): + """A custom broad root scopes context matches by its root-relative suffix.""" + for context in ("backend", "frontend"): + prompt_dir = tmp_path / "specs" / context + prompt_dir.mkdir(parents=True) + (prompt_dir / "credits_Python.prompt").write_text( + f"% {context}\n", encoding="utf-8" + ) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n" + " backend:\n paths: [\"backend/**\"]\n" + " defaults:\n prompts_dir: \"specs/backend\"\n" + " generate_output_path: \"backend/\"\n" + " frontend:\n paths: [\"frontend/**\"]\n" + " defaults:\n prompts_dir: \"specs/frontend\"\n" + " generate_output_path: \"frontend/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{ + "filename": "credits_Python.prompt", + "filepath": "frontend/credits.py", + }]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir="specs", context_override="frontend", + ) + + resolved = paths["prompt"].resolve(strict=False).as_posix() + assert "/specs/frontend/" in resolved + assert "/specs/backend/" not in resolved + + +def test_get_pdd_file_paths_does_not_reconstruct_escaping_direct_symlink( + tmp_path, + monkeypatch, +): + """Fallback must not return a direct symlink rejected by prompt discovery.""" + prompts_root = tmp_path / "prompts" + prompts_root.mkdir() + external = tmp_path / "external.prompt" + original = "% external (must remain unchanged)\n" + external.write_text(original, encoding="utf-8") + try: + (prompts_root / "credits_Python.prompt").symlink_to(external) + except OSError: + pytest.skip("file symlinks are unavailable") + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError, match="resolves outside prompts root"): + get_pdd_file_paths("credits", "python", prompts_dir="prompts") + + assert external.read_text(encoding="utf-8") == original + + +def test_get_pdd_file_paths_missing_custom_module_keeps_context_root( + tmp_path, + monkeypatch, +): + """A path-qualified new module lands under its custom context prompt root.""" + (tmp_path / "specs").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n" + " frontend:\n paths: [\"frontend/**\"]\n" + " defaults:\n prompts_dir: \"specs/frontend\"\n" + " generate_output_path: \"frontend/\"\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "frontend/foo", "python", prompts_dir="specs", context_override="frontend", + ) + + assert paths["prompt"].resolve(strict=False) == ( + tmp_path / "specs" / "frontend" / "foo_python.prompt" + ).resolve(strict=False) + code = paths["code"].resolve(strict=False).as_posix() + assert code.endswith("/frontend/foo.py") + assert "/frontend/frontend/" not in code + + +def test_get_pdd_file_paths_sibling_root_output_does_not_veto_current_target( + tmp_path, + monkeypatch, +): + """A sibling ``./`` output does not claim a current-context architecture path.""" + backend_prompts = tmp_path / "prompts" / "backend" + backend_prompts.mkdir(parents=True) + (backend_prompts / "foo_Python.prompt").write_text("% backend\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n" + " backend:\n paths: [\"backend/**\", \"prompts/backend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/backend\"\n" + " generate_output_path: \"backend/functions/\"\n" + " frontend:\n paths: [\"prompts/frontend/**\"]\n" + " defaults:\n prompts_dir: \"prompts/frontend\"\n" + " generate_output_path: \"./\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{ + "filename": "foo_Python.prompt", + "filepath": "backend/special/foo.py", + }]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "foo", "python", prompts_dir="prompts/backend", context_override="backend", + ) + + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "special" / "foo.py" + ).resolve(strict=False) + + +def test_get_pdd_file_paths_nested_escaping_symlink_is_hard_failure( + tmp_path, + monkeypatch, +): + """An unsafe recursive match cannot be downgraded to a new-module fallback.""" + prompts_root = tmp_path / "prompts" + nested = prompts_root / "nested" + nested.mkdir(parents=True) + external = tmp_path / "external.prompt" + original = "% external (must remain unchanged)\n" + external.write_text(original, encoding="utf-8") + try: + (nested / "credits_Python.prompt").symlink_to(external) + except OSError: + pytest.skip("file symlinks are unavailable") + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError, match="resolves outside prompts root"): + get_pdd_file_paths("credits", "python", prompts_dir="prompts") + + assert external.read_text(encoding="utf-8") == original + + +def test_get_pdd_file_paths_loads_territory_config_once_for_duplicate_rows( + tmp_path, + monkeypatch, +): + """Matching architecture rows share one context-territory config snapshot.""" + import sync_determine_operation as sync_determine_module + + backend_prompts = tmp_path / "prompts" / "backend" + backend_prompts.mkdir(parents=True) + (backend_prompts / "credits_Python.prompt").write_text("% backend\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + rows = [ + { + "filename": "stale/credits_Python.prompt", + "filepath": "frontend/credits.py", + } + for _ in range(500) + ] + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": rows}), encoding="utf-8" + ) + original_load = sync_determine_module._load_pddrc_config + original_owner = sync_determine_module._architecture_prompt_owner + loads = {"count": 0} + ownership_checks = {"count": 0} + + def counting_load(path): + loads["count"] += 1 + return original_load(path) + + def counting_owner(*args, **kwargs): + ownership_checks["count"] += 1 + return original_owner(*args, **kwargs) + + monkeypatch.setattr(sync_determine_module, "_load_pddrc_config", counting_load) + monkeypatch.setattr( + sync_determine_module, "_architecture_prompt_owner", counting_owner + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "credits", "python", prompts_dir="prompts/backend", context_override="backend", + ) + + assert not paths["code"].resolve(strict=False).as_posix().endswith( + "frontend/credits.py" + ) + assert loads["count"] <= 10 + assert ownership_checks["count"] <= 1 + + +def test_get_pdd_file_paths_rejects_missing_basename_traversal(tmp_path, monkeypatch): + """A missing module basename cannot escape prompt/output roots via ``..``.""" + (tmp_path / "prompts").mkdir() + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError, match="Unsafe prompt path"): + get_pdd_file_paths("../outside/foo", "python", prompts_dir="prompts") + + assert not (tmp_path / "outside" / "foo_python.prompt").exists() + + +def test_get_pdd_file_paths_nested_missing_custom_module_keeps_context_root( + tmp_path, + monkeypatch, +): + """Nested path-qualified new modules retain both context and nested segments.""" + (tmp_path / "specs").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n" + " frontend:\n paths: [\"frontend/**\"]\n" + " defaults:\n prompts_dir: \"specs/frontend\"\n" + " generate_output_path: \"frontend/\"\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "frontend/utils/foo", + "python", + prompts_dir="specs", + context_override="frontend", + ) + + assert paths["prompt"].resolve(strict=False) == ( + tmp_path / "specs" / "frontend" / "utils" / "foo_python.prompt" + ).resolve(strict=False) + code = paths["code"].resolve(strict=False).as_posix() + assert code.endswith("/frontend/utils/foo.py") + assert "/frontend/frontend/" not in code + test_path = paths["test"].resolve(strict=False).as_posix() + assert test_path.endswith("/utils/test_foo.py") + assert "/utils/utils/" not in test_path + + +def test_get_pdd_file_paths_ignores_unsafe_symlink_in_sibling_context( + tmp_path, + monkeypatch, +): + """An unsafe frontend candidate cannot block an explicit backend new module.""" + (tmp_path / "prompts" / "backend").mkdir(parents=True) + frontend = tmp_path / "prompts" / "frontend" + frontend.mkdir() + external = tmp_path / "external.prompt" + original = "% external (must remain unchanged)\n" + external.write_text(original, encoding="utf-8") + try: + (frontend / "foo_Python.prompt").symlink_to(external) + except OSError: + pytest.skip("file symlinks are unavailable") + _write_two_context_pddrc(tmp_path) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "foo", "python", prompts_dir="prompts", context_override="backend", + ) + + assert paths["prompt"].resolve(strict=False) == ( + tmp_path / "prompts" / "backend" / "foo_python.prompt" + ).resolve(strict=False) + assert external.read_text(encoding="utf-8") == original + + +def test_get_pdd_file_paths_rejects_language_traversal(tmp_path, monkeypatch): + """Language is one filename component and cannot traverse artifact roots.""" + (tmp_path / "prompts").mkdir() + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError, match="Unsafe prompt path"): + get_pdd_file_paths("foo", "../../../outside", prompts_dir="prompts") + + assert not (tmp_path.parent / "outside").exists() + + +def test_get_pdd_file_paths_active_unsafe_prompt_beats_safe_sibling( + tmp_path, + monkeypatch, +): + """A safe frontend prompt cannot mask an unsafe requested backend prompt.""" + backend = tmp_path / "prompts" / "backend" + frontend = tmp_path / "prompts" / "frontend" + backend.mkdir(parents=True) + frontend.mkdir() + external = tmp_path / "external.prompt" + original = "% external (must remain unchanged)\n" + external.write_text(original, encoding="utf-8") + try: + (backend / "foo_Python.prompt").symlink_to(external) + except OSError: + pytest.skip("file symlinks are unavailable") + (frontend / "foo_Python.prompt").write_text("% frontend\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError, match="resolves outside prompts root"): + get_pdd_file_paths( + "foo", "python", prompts_dir="prompts", context_override="backend", + ) + + assert external.read_text(encoding="utf-8") == original + + +def test_get_pdd_file_paths_explicit_context_does_not_borrow_safe_sibling( + tmp_path, + monkeypatch, +): + """A lone safe frontend prompt is not a fallback for explicit backend creation.""" + (tmp_path / "prompts" / "backend").mkdir(parents=True) + frontend = tmp_path / "prompts" / "frontend" + frontend.mkdir() + (frontend / "foo_Python.prompt").write_text("% frontend\n", encoding="utf-8") + _write_two_context_pddrc(tmp_path) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "foo", "python", prompts_dir="prompts", context_override="backend", + ) + + assert paths["prompt"].resolve(strict=False) == ( + tmp_path / "prompts" / "backend" / "foo_python.prompt" + ).resolve(strict=False) + + +def test_get_pdd_file_paths_flat_arch_hint_aligns_path_qualified_basename( + tmp_path, + monkeypatch, +): + """A flat hint cannot pair an unrelated directory's prompt with canonical code.""" + wrong = tmp_path / "prompts" / "afoo" + wrong.mkdir(parents=True) + (wrong / "page_Python.prompt").write_text("% wrong afoo page\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n" + " generate_output_path: \"src/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{ + "filename": "page_Python.prompt", + "filepath": "foo/page.py", + }]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths("foo/page", "python", prompts_dir="prompts") + + assert paths["prompt"].resolve(strict=False) == ( + tmp_path / "prompts" / "foo" / "page_python.prompt" + ).resolve(strict=False) + assert "/prompts/afoo/" not in paths["prompt"].resolve(strict=False).as_posix() + + +def test_get_pdd_file_paths_direct_fast_path_respects_explicit_context( + tmp_path, + monkeypatch, +): + """A root-level prompt is not a direct-path fallback for explicit backend.""" + (tmp_path / "prompts" / "backend").mkdir(parents=True) + (tmp_path / "prompts" / "foo_Python.prompt").write_text( + "% root-level sibling\n", encoding="utf-8" + ) + _write_two_context_pddrc(tmp_path) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "foo", "python", prompts_dir="prompts", context_override="backend", + ) + + assert paths["prompt"].resolve(strict=False) == ( + tmp_path / "prompts" / "backend" / "foo_python.prompt" + ).resolve(strict=False) + + +def test_get_pdd_file_paths_misaligned_direct_arch_join_is_not_returned( + tmp_path, + monkeypatch, +): + """A rejected flat direct join cannot be returned again as the helper fallback.""" + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "page_Python.prompt").write_text( + "% wrong root page\n", encoding="utf-8" + ) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n" + " generate_output_path: \"src/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{ + "filename": "page_Python.prompt", + "filepath": "foo/page.py", + }]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths("foo/page", "python", prompts_dir="prompts") + + assert paths["prompt"].resolve(strict=False) == ( + tmp_path / "prompts" / "foo" / "page_python.prompt" + ).resolve(strict=False) + assert paths["prompt"].resolve(strict=False) != ( + tmp_path / "prompts" / "page_Python.prompt" + ).resolve(strict=False) + + +def test_get_pdd_file_paths_nested_exact_arch_filename_still_aligns_filepath( + tmp_path, + monkeypatch, +): + """An exact nested filename cannot bypass path-qualified filepath alignment.""" + prompt = tmp_path / "prompts" / "foo" / "page_Python.prompt" + prompt.parent.mkdir(parents=True) + prompt.write_text("% foo page\n", encoding="utf-8") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n" + " generate_output_path: \"src/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{ + "filename": "foo/page_Python.prompt", + "filepath": "bar/page.py", + }]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths("foo/page", "python", prompts_dir="prompts") + + assert paths["prompt"].resolve(strict=False) == prompt.resolve(strict=False) + assert not paths["code"].resolve(strict=False).as_posix().endswith("bar/page.py") + + +def test_get_pdd_file_paths_nested_directories_match_case_insensitively( + tmp_path, + monkeypatch, +): + """Linux nested lookup reuses an existing differently-cased directory path.""" + prompt = tmp_path / "prompts" / "foo" / "page_Python.prompt" + prompt.parent.mkdir(parents=True) + prompt.write_text("% foo page\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths("Foo/Page", "python", prompts_dir="prompts") + + assert paths["prompt"].resolve(strict=False) == prompt.resolve(strict=False) + assert paths["prompt"].parts[-2:] == ("foo", "page_Python.prompt") + + +@pytest.mark.parametrize( + ("basename", "language"), + [ + (" foo", "python"), + ("foo ", "python"), + ("foo\x00bar", "python"), + ("foo", " python"), + ("foo", "python "), + ("foo", "py\x00thon"), + ("foo", "py\nthon"), + ("foo\u202ebar", "python"), + ("foo", "py\u2066thon"), + ("foo\u2028bar", "python"), + ("foo", "py\u2029thon"), + ], +) +def test_get_pdd_file_paths_rejects_noncanonical_or_control_input( + tmp_path, + monkeypatch, + basename, + language, +): + """Validation rejects controls and whitespace the caller would otherwise retain.""" + (tmp_path / "prompts").mkdir() + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError, match="Unsafe prompt path"): + get_pdd_file_paths(basename, language, prompts_dir="prompts") + + +def test_get_pdd_file_paths_unsafe_root_direct_candidate_does_not_block_context( + tmp_path, + monkeypatch, +): + """Containment is not evaluated for a direct candidate outside explicit context.""" + (tmp_path / "prompts" / "backend").mkdir(parents=True) + external = tmp_path / "external.prompt" + original = "% external (must remain unchanged)\n" + external.write_text(original, encoding="utf-8") + try: + (tmp_path / "prompts" / "foo_Python.prompt").symlink_to(external) + except OSError: + pytest.skip("file symlinks are unavailable") + _write_two_context_pddrc(tmp_path) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + "foo", "python", prompts_dir="prompts", context_override="backend", + ) + + assert paths["prompt"].resolve(strict=False) == ( + tmp_path / "prompts" / "backend" / "foo_python.prompt" + ).resolve(strict=False) + assert external.read_text(encoding="utf-8") == original + + +@pytest.mark.parametrize("basename", [".", "./", "foo/", "foo/.", "./foo", "foo//bar"]) +def test_get_pdd_file_paths_rejects_degenerate_basename( + tmp_path, + monkeypatch, + basename, +): + """Noncanonical or empty normalized basenames cannot create hidden artifacts.""" + (tmp_path / "prompts").mkdir() + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError, match="Unsafe prompt path"): + get_pdd_file_paths(basename, "python", prompts_dir="prompts") + + +@pytest.mark.parametrize("basename", ["foo\nFORGED", "foo\rFORGED", "foo\x1b[31m"]) +def test_get_pdd_file_paths_validates_before_logging_raw_input( + tmp_path, + monkeypatch, + caplog, + basename, +): + """Rejected control characters never reach the INFO log record.""" + (tmp_path / "prompts").mkdir() + monkeypatch.chdir(tmp_path) + caplog.set_level("INFO", logger="sync_determine_operation") + + with pytest.raises(UnsafePromptPathError, match="Unsafe prompt path"): + get_pdd_file_paths(basename, "python", prompts_dir="prompts") + + assert not caplog.records + + +@pytest.mark.parametrize("bad_language", ["py\ninjected", "py\x1b[31mred", "py\r\nx", "py\x07"]) +def test_get_pdd_file_paths_validates_language_before_logging(tmp_path, monkeypatch, caplog, bad_language): + """A control/newline/ANSI-bearing language never reaches an INFO log record before + it is rejected (log-injection guard for the language input, R14).""" + (tmp_path / "prompts").mkdir() + monkeypatch.chdir(tmp_path) + caplog.set_level("INFO", logger="sync_determine_operation") + + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("foo", bad_language, prompts_dir="prompts") + + assert not caplog.records + + +def test_get_pdd_file_paths_rejects_control_bearing_prompts_dir_before_logging( + tmp_path, + monkeypatch, + caplog, +): + """A control-bearing prompt root is neither resolved nor emitted into INFO logs.""" + monkeypatch.chdir(tmp_path) + caplog.set_level("INFO", logger="sync_determine_operation") + + with pytest.raises(UnsafePromptPathError, match="Unsafe prompt path"): + get_pdd_file_paths("foo", "python", prompts_dir="prompts\nFORGED") + + assert not caplog.records + + +@pytest.mark.parametrize( + "basename", + [ + "foo:bar", "foo?bar", "foo*bar", "foo|bar", 'foo"bar', + "foobar", "CON", "NUL", "COM1", "LPT9.txt", + "COM¹", "COM².txt", "LPT³", "dir/PRN.py", "AUX", + ], +) +def test_get_pdd_file_paths_rejects_windows_device_or_ads_basename( + tmp_path, + monkeypatch, + basename, +): + """Portable module identities cannot address NTFS streams or device names.""" + (tmp_path / "prompts").mkdir() + monkeypatch.chdir(tmp_path) + + with pytest.raises(UnsafePromptPathError, match="Unsafe prompt path"): + get_pdd_file_paths(basename, "python", prompts_dir="prompts") + + +def test_directory_index_case_collision_fallback_is_deterministic(tmp_path): + """Case-fold collisions have a stable fallback independent of scandir order.""" + import sync_determine_operation as sync_determine_module + + lower = tmp_path / "foo" + upper = tmp_path / "Foo" + lower.mkdir() + try: + upper.mkdir() + except FileExistsError: + pytest.skip("filesystem does not support case-distinct sibling directories") + if lower.samefile(upper): + pytest.skip("filesystem is case-insensitive") + + found = sync_determine_module._indexed_directory_child( + tmp_path, "FOO", directory=True + ) + + assert found == upper + + +@pytest.mark.parametrize( + "architecture_filepath", + [ + "CON.py", + "src/NUL.txt", + "src/foo:bar.py", + "src/foo?.py", + "src/foo|bar.py", + "src/foo\u202ebar.py", + "src/foo\u2028bar.py", + "src/COM¹.py", + "LPT³.txt", + ], +) +def test_contained_architecture_code_path_rejects_nonportable_components( + tmp_path, + architecture_filepath, +): + """Architecture outputs obey the same portable component rules as prompts.""" + assert _contained_architecture_code_path(tmp_path, architecture_filepath) is None + + +@pytest.mark.parametrize( + "noncanonical", + ["./foo_Python.prompt", "a//foo_Python.prompt", "a/./foo_Python.prompt", "foo_Python.prompt/", "."], +) +def test_safe_architecture_prompt_filename_rejects_noncanonical(noncanonical): + """A non-canonical architecture prompt filename (dot segments, duplicate/trailing + separators, or no components) is rejected so an alias cannot pass as a valid name and + a regenerated implementation cannot accept it while satisfying R1-R15 (R10).""" + import sync_determine_operation as sync_determine_module + + assert sync_determine_module._safe_architecture_prompt_filename(noncanonical) is None + + +@pytest.mark.parametrize( + "noncanonical", + [".", "./foo.py", "src/./foo.py", "src//foo.py", "src/foo/", "foo/."], +) +def test_contained_architecture_code_path_rejects_noncanonical(tmp_path, noncanonical): + """A non-canonical architecture output filepath (dot segments, duplicate/trailing + separators, or no components) is rejected so it cannot count as a valid distinct + output (R10 extended to output filepaths).""" + assert _contained_architecture_code_path(tmp_path, noncanonical) is None + + +def test_get_pdd_file_paths_unsafe_duplicate_does_not_shadow_valid_arch_row( + tmp_path, + monkeypatch, +): + """An unsafe first duplicate is skipped so a later valid mapping remains authoritative.""" + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "foo_Python.prompt").write_text( + "% foo\n", encoding="utf-8" + ) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n" + " generate_output_path: \"fallback/\"\n", + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "foo_Python.prompt", "filepath": "CON.py"}, + {"filename": "foo_Python.prompt", "filepath": "src/foo.py"}, + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths("foo", "python", prompts_dir="prompts") + + assert paths["code"].resolve(strict=False) == ( + tmp_path / "src" / "foo.py" + ).resolve(strict=False) + + +def test_get_pdd_file_paths_does_not_info_log_raw_unsafe_arch_filepath( + tmp_path, + monkeypatch, + caplog, +): + """Raw invalid architecture output is never emitted through the INFO path log.""" + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "foo_Python.prompt").write_text( + "% foo\n", encoding="utf-8" + ) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + "contexts:\n default:\n paths: [\"**\"]\n" + " defaults:\n prompts_dir: \"prompts\"\n" + " generate_output_path: \"fallback/\"\n", + encoding="utf-8", + ) + unsafe = "src/foo\u2028FORGED.py" + (tmp_path / "architecture.json").write_text( + json.dumps({"modules": [{ + "filename": "foo_Python.prompt", "filepath": unsafe, + }]}), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + caplog.set_level("INFO", logger="sync_determine_operation") + + paths = get_pdd_file_paths("foo", "python", prompts_dir="prompts") + + assert not paths["code"].resolve(strict=False).as_posix().endswith( + "foo\u2028FORGED.py" + ) + assert all(unsafe not in record.getMessage() for record in caplog.records) + + +def test_get_pdd_file_paths_rejects_symlink_architecture_escape(tmp_path, monkeypatch): + """A relative architecture path cannot escape through an existing symlink.""" + monkeypatch.chdir(tmp_path) + outside = tmp_path.parent / f"{tmp_path.name}-outside" + outside.mkdir() + try: + (tmp_path / "linked").symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("directory symlinks are unavailable") + _write_nested_architecture_project( + tmp_path, + prompts_dir="prompts/backend", + architecture_filename="backend/credits_Python.prompt", + architecture_filepath="linked/credits.py", + ) + + paths = get_pdd_file_paths( + "credits", + "python", + prompts_dir="prompts/backend", + context_override="backend", + ) + + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "functions" / "credits.py" + ).resolve(strict=False) + + +# --- Part 6: Auto-deps Infinite Loop Regression Tests --- + +class TestAutoDepsInfiniteLoopFix: + """Test the auto-deps infinite loop fix implemented to prevent continuous auto-deps operations.""" + + def test_auto_deps_to_generate_progression(self, pdd_test_environment): + """Test that after auto-deps completes, sync decides to run generate (not auto-deps again).""" + + # Create prompt file with dependencies + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) + prompt_content = """Create a YouTube client function. + +src/config.py +src/models.py + +Requirements: +- Function should discover new videos from YouTube channels +- Use the config and models from included dependencies +""" + prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + + # Create fingerprint showing auto-deps was just completed + fingerprint_data = { + "pdd_version": "0.0.46", + "timestamp": "2025-08-04T05:22:58.044203+00:00", + "command": "auto-deps", # This is the key - auto-deps was last completed + "prompt_hash": prompt_hash, # Use actual calculated hash + "code_hash": None, # Code file doesn't exist yet + "example_hash": None, + "test_hash": None + } + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, fingerprint_data) + + # Test the decision logic + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # CRITICAL: Should decide 'generate', not 'auto-deps' again + assert decision.operation == 'generate' + assert 'Auto-deps completed' in decision.reason + assert decision.details['previous_command'] == 'auto-deps' + assert decision.details['code_exists'] == False + + def test_auto_deps_infinite_loop_before_fix_scenario(self, pdd_test_environment): + """Test the exact scenario that caused infinite loop before the fix.""" + + # Create prompt file with dependencies (like youtube_client_python.prompt) + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) + prompt_content = """YouTube Client Module + +This module discovers new videos from configured YouTube channels. + +### Dependencies + + +src/config.py + + + +src/models.py + + +Requirements: +- Discover new videos from YouTube channels +- Process metadata for each video +""" + prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + + # Simulate the exact state from the sync log: auto-deps completed but code file missing + fingerprint_data = { + "pdd_version": "0.0.46", + "timestamp": "2025-08-04T05:07:29.753906+00:00", + "command": "auto-deps", + "prompt_hash": prompt_hash, # Use actual calculated hash + "code_hash": None, # This is the key issue - no code file exists + "example_hash": None, + "test_hash": None + } + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, fingerprint_data) + + # Before the fix: this would return 'auto-deps' and cause infinite loop + # After the fix: this should return 'generate' + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # Verify the fix + assert decision.operation == 'generate', f"Expected 'generate', got '{decision.operation}' - infinite loop fix failed" + assert decision.operation != 'auto-deps', "Should not return auto-deps again (infinite loop)" + assert 'Auto-deps completed' in decision.reason + assert decision.confidence == 0.90 # High confidence since this is deterministic + + def test_auto_deps_without_dependencies_still_works(self, pdd_test_environment): + """Test that normal auto-deps logic still works when prompt has no dependencies.""" + + # Create prompt file WITHOUT dependencies + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) + prompt_content = """Create a simple calculator function. + +Requirements: +- Function name: add +- Parameters: a, b (both numbers) +- Return: sum of a and b +""" + create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + + # No fingerprint (new unit scenario) + # Code file doesn't exist + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # Should go directly to generate since no dependencies detected + assert decision.operation == 'generate' + assert 'New prompt ready' in decision.reason + assert decision.details.get('has_dependencies', True) == False # No dependencies + + def test_auto_deps_first_time_with_dependencies(self, pdd_test_environment): + """Test that auto-deps is correctly chosen for new prompts with dependencies.""" + + # Create prompt file WITH dependencies + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) + prompt_content = """Create a data processor. + +context/database_example.py +https://example.com/api-docs + +Requirements: +- Process data using included database example +- Fetch API documentation from web +""" + create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + + # No fingerprint (new unit scenario) + # Code file doesn't exist + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # Should choose auto-deps for first time with dependencies + assert decision.operation == 'auto-deps' + assert 'New prompt with dependencies detected' in decision.reason + assert decision.details['has_dependencies'] == True + assert decision.details['fingerprint_found'] == False + + @patch('sync_determine_operation.construct_paths') + def test_auto_deps_regenerates_when_code_exists_from_previous_run(self, mock_construct, pdd_test_environment): + """Test that after auto-deps completes, generate runs even when code file exists from previous run. + + This is a regression test for a bug where: + 1. User changes prompt + 2. auto-deps runs (updates dependencies, saves fingerprint with new prompt hash) + 3. Code file exists from a previous generation (stale code) + 4. Next sync should run 'generate' to regenerate code with new prompt + + Bug: sync was skipping to 'crash' because code file existed, missing the regeneration step. + Fix: Added check for fingerprint.command == 'auto-deps' that triggers generate regardless + of whether code file exists. + """ + + # Create prompt file with dependencies + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) + prompt_content = """Generate a credit helper function. + +context/firebase_helpers.py +context/user_model.py + +Requirements: +- Deduct credits from user account +- Verify authentication before deducting +""" + prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + + # Create code, example, test files directly in pdd_test_environment (following test pattern) + old_code_hash = create_file(pdd_test_environment / f"{BASENAME}.py", "# OLD CODE\ndef old_func(): pass") + old_example_hash = create_file(pdd_test_environment / f"{BASENAME}_example.py", "# OLD EXAMPLE") + old_test_hash = create_file(pdd_test_environment / f"test_{BASENAME}.py", "# OLD TEST\ndef test_old(): pass") + + # Mock construct_paths to return correct file paths + mock_construct.return_value = ( + {}, {}, + { + 'code_file': str(pdd_test_environment / f"{BASENAME}.py"), + 'example_file': str(pdd_test_environment / f"{BASENAME}_example.py"), + 'test_file': str(pdd_test_environment / f"test_{BASENAME}.py") + }, + LANGUAGE + ) + + # Create fingerprint showing auto-deps JUST completed + # The fingerprint has the NEW prompt hash but OLD code/example/test hashes + fingerprint_data = { + "pdd_version": "0.0.88", + "timestamp": "2025-12-23T02:10:02.143829+00:00", + "command": "auto-deps", # auto-deps just completed + "prompt_hash": prompt_hash, # NEW prompt hash + "code_hash": old_code_hash, # OLD code hash (stale) + "example_hash": old_example_hash, # OLD example hash + "test_hash": old_test_hash # OLD test hash + } + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, fingerprint_data) + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # CRITICAL: Should decide 'generate' (not 'crash' or 'nothing') + # Even though all files exist, auto-deps just ran, so code needs regeneration + assert decision.operation == 'generate', \ + f"Expected 'generate', got '{decision.operation}'. " \ + f"Bug: after auto-deps, should regenerate code even when code file exists from previous run." + assert 'Auto-deps completed' in decision.reason + assert decision.details.get('regenerate_after_autodeps') == True + assert decision.details.get('code_exists') == True # Confirms code existed but we still regenerate + + @patch('sync_determine_operation.construct_paths') + def test_no_fingerprint_with_stale_run_report_should_generate(self, mock_construct, pdd_test_environment): + """Test that when fingerprint is deleted but run_report exists, sync treats it as fresh start. + + Regression test for bug where: + 1. User deletes fingerprint to force regeneration + 2. Stale run_report exists with test failures + 3. Expected: sync detects as fresh start → auto-deps/generate + 4. Actual (bug): sync sees run_report.tests_failed > 0 → runs fix + + IMPORTANT: Must create test file so the buggy 'fix' path at line 958 is triggered. + Without test file, the code skips 'fix' and accidentally returns correct result. + """ + # Create prompt with dependencies + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) + prompt_content = """Generate a helper function. + +context/helpers.py + +Requirements: +- Do something useful +""" + create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + + # CRITICAL: Create test file so buggy 'fix' path is triggered + # Line 958: if test_file and test_file.exists(): return 'fix' + create_file(pdd_test_environment / f"test_{BASENAME}.py", "def test_old(): pass") + + # Mock construct_paths to return test file path + mock_construct.return_value = ( + {}, {}, + {'test_file': str(pdd_test_environment / f"test_{BASENAME}.py")}, + LANGUAGE + ) + + # Create stale run_report with test failures + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "2025-12-23T03:00:00+00:00", + "exit_code": 1, + "tests_passed": 5, + "tests_failed": 2, + "coverage": 50.0, + "test_hash": "stale_hash" + }) + + # NO fingerprint exists (user deleted it) + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # Should treat as fresh start (auto-deps because prompt has dependencies) + # NOT 'fix' based on stale run_report + assert decision.operation == 'auto-deps', \ + f"Expected 'auto-deps', got '{decision.operation}'. " \ + f"Bug: stale run_report should be ignored when fingerprint is missing." + + @patch('sync_determine_operation.construct_paths') + def test_auto_deps_ignores_stale_run_report_with_low_coverage(self, mock_construct, pdd_test_environment): + """Test that after auto-deps completes, stale run_report with low coverage is ignored. + + Regression test for bug where: + 1. auto-deps completes (fingerprint.command == 'auto-deps') + 2. Stale run_report exists with low coverage (e.g., 77% below 90% target) + 3. Expected: sync returns 'generate' to regenerate code + 4. Actual (bug): sync sees low coverage in run_report → returns 'test_extend' + + The run_report is stale because it was from the PREVIOUS code generation, + not the new code that will be generated after auto-deps. + """ + # Create prompt with dependencies + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) + prompt_content = """Generate a helper function. + +context/helpers.py + +Requirements: +- Do something useful +""" + prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + + # Create code, example, test files (from previous run) + code_hash = create_file(pdd_test_environment / f"{BASENAME}.py", "# OLD CODE") + example_hash = create_file(pdd_test_environment / f"{BASENAME}_example.py", "# OLD EXAMPLE") + test_hash = create_file(pdd_test_environment / f"test_{BASENAME}.py", "def test_old(): pass") + + mock_construct.return_value = ( + {}, {}, + { + 'code_file': str(pdd_test_environment / f"{BASENAME}.py"), + 'example_file': str(pdd_test_environment / f"{BASENAME}_example.py"), + 'test_file': str(pdd_test_environment / f"test_{BASENAME}.py") + }, + LANGUAGE + ) + + # Create fingerprint showing auto-deps JUST completed + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "0.0.88", + "timestamp": "2025-12-23T03:00:00+00:00", + "command": "auto-deps", # auto-deps just completed + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) + + # Create STALE run_report with low coverage (from previous code generation) + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "2025-12-23T02:00:00+00:00", # Before auto-deps + "exit_code": 0, + "tests_passed": 6, + "tests_failed": 0, + "coverage": 77.0, # Below 90% target - would trigger test_extend if not ignored + "test_hash": test_hash + }) + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # Should return 'generate' because auto-deps just completed + # NOT 'test_extend' based on stale run_report's low coverage + assert decision.operation == 'generate', \ + f"Expected 'generate', got '{decision.operation}'. " \ + f"Bug: after auto-deps, stale run_report should be ignored." + assert 'Auto-deps completed' in decision.reason + assert decision.details.get('regenerate_after_autodeps') == True + +# --- Part 7: Edge Cases and Helper Function Tests --- +# These tests were consolidated from test_sync_edge_cases.py + +class TestValidateExpectedFiles: + """Test the validate_expected_files function.""" + + def test_validate_with_no_fingerprint(self): + """Test validation when no fingerprint is provided.""" + paths = { + 'code': Path('test.py'), + 'example': Path('test_example.py'), + 'test': Path('test_test.py') + } + + result = validate_expected_files(None, paths) + assert result == {} + + def test_validate_all_files_exist(self, tmp_path): + """Test validation when all expected files exist.""" + # Create test files + code_file = tmp_path / "test.py" + example_file = tmp_path / "test_example.py" + test_file = tmp_path / "test_test.py" + + code_file.write_text("print('hello')") + example_file.write_text("from test import *") + test_file.write_text("def test_func(): pass") + + paths = { + 'code': code_file, + 'example': example_file, + 'test': test_file + } + + fingerprint = Fingerprint( + pdd_version="0.0.41", + timestamp=datetime.now(timezone.utc).isoformat(), + command="test", + prompt_hash="prompt123", + code_hash="code456", + example_hash="example789", + test_hash="test012" + ) + + result = validate_expected_files(fingerprint, paths) + + assert result == { + 'code': True, + 'example': True, + 'test': True + } + + def test_validate_missing_files(self, tmp_path): + """Test validation when expected files are missing.""" + # Create only code file + code_file = tmp_path / "test.py" + example_file = tmp_path / "test_example.py" + test_file = tmp_path / "test_test.py" + + code_file.write_text("print('hello')") + # Don't create example and test files + + paths = { + 'code': code_file, + 'example': example_file, + 'test': test_file + } + + fingerprint = Fingerprint( + pdd_version="0.0.41", + timestamp=datetime.now(timezone.utc).isoformat(), + command="test", + prompt_hash="prompt123", + code_hash="code456", + example_hash="example789", + test_hash="test012" + ) + + result = validate_expected_files(fingerprint, paths) + + assert result == { + 'code': True, + 'example': False, + 'test': False + } + + +class TestHandleMissingExpectedFiles: + """Test the _handle_missing_expected_files function.""" + + def test_missing_code_file_with_prompt(self, tmp_path): + """Test recovery when code file is missing but prompt exists.""" + prompt_file = tmp_path / "test_python.prompt" + prompt_file.write_text("Create a simple function") + + paths = { + 'prompt': prompt_file, + 'code': tmp_path / "test.py", + 'example': tmp_path / "test_example.py", + 'test': tmp_path / "test_test.py" + } + + fingerprint = Fingerprint( + pdd_version="0.0.41", + timestamp=datetime.now(timezone.utc).isoformat(), + command="test", + prompt_hash="prompt123", + code_hash="code456", + example_hash=None, + test_hash=None + ) + + decision = _handle_missing_expected_files( + missing_files=['code'], + paths=paths, + fingerprint=fingerprint, + basename="test", + language="python", + prompts_dir="prompts" + ) + + assert decision.operation == 'generate' + assert 'Code file missing' in decision.reason + # The confidence value is set to 1.0 because the decision to generate + # a new code file is deterministic when the code file is missing, and + # all other required files (e.g., prompt) are present. + assert decision.confidence == 1.0 + def test_missing_test_file_with_skip_tests(self, tmp_path): + """Test recovery when test file is missing and skip_tests is True.""" + code_file = tmp_path / "test.py" + example_file = tmp_path / "test_example.py" + + code_file.write_text("def add(a, b): return a + b") + example_file.write_text("from test import add; print(add(1, 2))") + + paths = { + 'prompt': tmp_path / "test_python.prompt", + 'code': code_file, + 'example': example_file, + 'test': tmp_path / "test_test.py" + } + + fingerprint = Fingerprint( + pdd_version="0.0.41", + timestamp=datetime.now(timezone.utc).isoformat(), + command="test", + prompt_hash="prompt123", + code_hash="code456", + example_hash="example789", + test_hash="test012" + ) + + decision = _handle_missing_expected_files( + missing_files=['test'], + paths=paths, + fingerprint=fingerprint, + basename="test", + language="python", + prompts_dir="prompts", + skip_tests=True + ) + + assert decision.operation == 'nothing' + assert 'skip-tests specified' in decision.reason + assert decision.details['skip_tests'] is True + + def test_missing_example_file(self, tmp_path): + """Test recovery when example file is missing but code exists.""" + code_file = tmp_path / "test.py" + code_file.write_text("def add(a, b): return a + b") + + paths = { + 'prompt': tmp_path / "test_python.prompt", + 'code': code_file, + 'example': tmp_path / "test_example.py", + 'test': tmp_path / "test_test.py" + } + + fingerprint = Fingerprint( + pdd_version="0.0.41", + timestamp=datetime.now(timezone.utc).isoformat(), + command="test", + prompt_hash="prompt123", + code_hash="code456", + example_hash="example789", + test_hash=None + ) + + decision = _handle_missing_expected_files( + missing_files=['example'], + paths=paths, + fingerprint=fingerprint, + basename="test", + language="python", + prompts_dir="prompts" + ) + + assert decision.operation == 'example' + assert 'Example file missing' in decision.reason + + +class TestIsWorkflowComplete: + """Test the _is_workflow_complete function.""" + + def test_workflow_complete_without_skip_flags(self, tmp_path): + """Test workflow completion when all files exist and no skip flags.""" + code_file = tmp_path / "test.py" + example_file = tmp_path / "test_example.py" + test_file = tmp_path / "test_test.py" + + # Create all files + code_file.write_text("def add(a, b): return a + b") + example_file.write_text("from test import add") + test_file.write_text("def test_add(): pass") + + paths = { + 'code': code_file, + 'example': example_file, + 'test': test_file + } + + assert _is_workflow_complete(paths) is True + assert _is_workflow_complete(paths, skip_tests=False) is True + + def test_workflow_complete_with_skip_tests(self, tmp_path): + """Test workflow completion when test file missing but skip_tests=True.""" + code_file = tmp_path / "test.py" + example_file = tmp_path / "test_example.py" + + # Create only code and example files + code_file.write_text("def add(a, b): return a + b") + example_file.write_text("from test import add") + + paths = { + 'code': code_file, + 'example': example_file, + 'test': tmp_path / "test_test.py" # Doesn't exist + } + + assert _is_workflow_complete(paths) is False # Requires test file + assert _is_workflow_complete(paths, skip_tests=True) is True # Skip test requirement + + def test_workflow_complete_with_both_skip_flags_needs_no_run_report(self, tmp_path, monkeypatch): + """When tests and verify are skipped, file existence is enough.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + code_file = tmp_path / "test.py" + example_file = tmp_path / "test_example.py" + code_file.write_text("def add(a, b): return a + b") + example_file.write_text("from test import add") + + paths = { + 'code': code_file, + 'example': example_file, + 'test': tmp_path / "test_test.py" + } + + assert _is_workflow_complete( + paths, + skip_tests=True, + skip_verify=True, + basename="test", + language="python", + ) is True + + def test_workflow_incomplete(self, tmp_path): + """Test workflow is incomplete when required files are missing.""" + code_file = tmp_path / "test.py" + code_file.write_text("def add(a, b): return a + b") + + paths = { + 'code': code_file, + 'example': tmp_path / "test_example.py", # Doesn't exist + 'test': tmp_path / "test_test.py" # Doesn't exist + } + + assert _is_workflow_complete(paths) is False + assert _is_workflow_complete(paths, skip_tests=True) is False # Still needs example + + +class TestSyncDetermineOperationRegressionScenarios: + """Additional regression tests for sync_determine_operation edge cases.""" + + def test_missing_files_with_metadata_regression_scenario(self, tmp_path): + """Test the exact regression scenario: files deleted but metadata remains.""" + # Change to temp directory for the test + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + + # Create directory structure + (tmp_path / "prompts").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + + # Create prompt file + prompt_file = tmp_path / "prompts" / "simple_math_python.prompt" + prompt_file.write_text("""Create a Python module with a simple math function. + +Requirements: +- Function name: add +- Parameters: a, b (both numbers) +- Return: sum of a and b +""") + + # Create metadata (simulating previous successful sync) + meta_file = tmp_path / ".pdd" / "meta" / "simple_math_python.json" + meta_file.write_text(json.dumps({ + "pdd_version": "0.0.41", + "timestamp": datetime.now(timezone.utc).isoformat(), + "command": "test", + "prompt_hash": "abc123", + "code_hash": "def456", + "example_hash": "ghi789", + "test_hash": "jkl012" + }, indent=2)) + + # Files are deliberately missing (deleted like in regression test) + + # Test sync_determine_operation behavior + decision = sync_determine_operation( + basename="simple_math", + language="python", + target_coverage=90.0, + budget=10.0, + log_mode=False, + prompts_dir="prompts", + skip_tests=True, + skip_verify=False + ) + + # Should NOT return analyze_conflict anymore + assert decision.operation != 'analyze_conflict' + + # Should return appropriate recovery operation + assert decision.operation in ['generate', 'auto-deps'] + assert 'missing' in decision.reason.lower() or 'regenerate' in decision.reason.lower() + + finally: + os.chdir(original_cwd) + + def test_skip_flags_integration(self, tmp_path): + """Test that skip flags are properly integrated throughout the decision logic.""" + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + + # Create directory structure + (tmp_path / "prompts").mkdir() + + # Create prompt file + prompt_file = tmp_path / "prompts" / "test_python.prompt" + prompt_file.write_text("Create a simple function") + + # Test with skip_tests=True + decision = sync_determine_operation( + basename="test", + language="python", + target_coverage=90.0, + budget=10.0, + log_mode=False, + prompts_dir="prompts", + skip_tests=True, + skip_verify=False + ) + + # Should start normal workflow + assert decision.operation in ['generate', 'auto-deps'] + + finally: + os.chdir(original_cwd) + + +class TestGetPddFilePaths: + """Test get_pdd_file_paths function to prevent path resolution regression.""" + + def test_get_pdd_file_paths_respects_pddrc_when_prompt_missing(self, tmp_path, monkeypatch): + """Test that get_pdd_file_paths uses .pddrc configuration even when prompt doesn't exist. + + This test prevents regression of the bug where test files were looked for in the + current directory instead of the configured tests/ subdirectory. + """ + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + + # Create .pddrc configuration file + pddrc_content = """version: "1.0" +contexts: + regression: + paths: ["**"] + defaults: + test_output_path: "tests/" + example_output_path: "examples/" + default_language: "python" +""" + (tmp_path / ".pddrc").write_text(pddrc_content) + + # Create directory structure + (tmp_path / "prompts").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "examples").mkdir() + + # Mock construct_paths to return configured paths + def mock_construct_paths( + input_file_paths, + force, + quiet, + command, + command_options, + context_override=None, + path_resolution_mode=None, + **_ignored, + ): + # Simulate what construct_paths would return with .pddrc configuration + return ( + { + "test_output_path": "tests/", + "example_output_path": "examples/", + "generate_output_path": "./" + }, + {}, + {}, # output_paths is empty when called with empty input_file_paths + "python" + ) + + monkeypatch.setattr('sync_determine_operation.construct_paths', mock_construct_paths) + + # Test when prompt file doesn't exist - this is the regression scenario + basename = "test_unit" + language = "python" + paths = get_pdd_file_paths(basename, language, "prompts") + + # Verify paths respect configuration, not hardcoded to current directory + # The bug was that test file was "test_test_unit.py" instead of "tests/test_test_unit.py" + assert str(paths['test']) == "tests/test_test_unit.py", f"Test path should be in tests/ subdirectory, got: {paths['test']}" + assert str(paths['example']) == "examples/test_unit_example.py", f"Example path should be in examples/ subdirectory, got: {paths['example']}" + assert str(paths['code']) == "test_unit.py", f"Code path can be in current directory, got: {paths['code']}" + + # Verify the paths are Path objects + assert isinstance(paths['test'], Path) + assert isinstance(paths['example'], Path) + assert isinstance(paths['code'], Path) + assert isinstance(paths['prompt'], Path) + + finally: + os.chdir(original_cwd) + + def test_get_pdd_file_paths_uses_context_relative_basename_for_templates(self, tmp_path, monkeypatch): + pddrc_content = """version: "1.0" +contexts: + frontend-components: + paths: + - "frontend/components/**" + defaults: + default_language: "typescriptreact" + outputs: + prompt: + path: "prompts/frontend/components/{category}/{name}_{language}.prompt" + code: + path: "frontend/src/components/{category}/{name}/{name}.tsx" + example: + path: "context/frontend/{name}_example.tsx" + default: + defaults: + default_language: "python" +""" + (tmp_path / ".pddrc").write_text(pddrc_content) + + prompt_path = ( + tmp_path + / "prompts" + / "frontend" + / "components" + / "marketplace" + / "AssetCard_typescriptreact.prompt" + ) + prompt_path.parent.mkdir(parents=True, exist_ok=True) + prompt_path.write_text("Generate AssetCard component", encoding="utf-8") + + repo_root = Path(__file__).resolve().parents[1] + monkeypatch.setenv("PDD_PATH", str(repo_root)) + monkeypatch.chdir(tmp_path) + + paths = get_pdd_file_paths( + basename="frontend/components/marketplace/AssetCard", + language="typescriptreact", + prompts_dir="prompts", + context_override="frontend-components", + ) + + assert paths["prompt"].resolve() == prompt_path.resolve() + assert paths["code"].as_posix() == "frontend/src/components/marketplace/AssetCard/AssetCard.tsx" + assert paths["example"].as_posix() == "context/frontend/AssetCard_example.tsx" + + def test_get_pdd_file_paths_fallback_without_construct_paths(self, tmp_path, monkeypatch): + """Test that paths use configured directories even without .pddrc when prompt is missing. + + After the fix, even without .pddrc, construct_paths should provide + sensible defaults based on the PDD context detection. + """ + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + + # Create directory structure + (tmp_path / "prompts").mkdir() + + # Don't create the prompt file - trigger the fallback logic + basename = "test_unit" + language = "python" + + # Get paths without mocking - this uses construct_paths now + paths = get_pdd_file_paths(basename, language, "prompts") + + # After fix: paths should use PDD's default directory structure + # The exact paths depend on whether construct_paths detects a context + # In a bare directory, it might still use current directory as fallback + # But with .pddrc present, it should use configured paths + + # For a bare directory without .pddrc, current behavior is acceptable + # The important fix is that WITH .pddrc, paths are respected + assert isinstance(paths['test'], Path) + assert isinstance(paths['example'], Path) + assert isinstance(paths['code'], Path) + + finally: + os.chdir(original_cwd) + + @patch('sync_determine_operation.construct_paths') + def test_sync_operation_with_missing_prompt_respects_test_path(self, mock_construct, tmp_path): + """Test that sync_determine_operation doesn't fail when test file is in configured directory. + + This simulates the exact regression scenario where sync fails with + "No such file or directory: 'test_simple_math.py'" because it's looking + in the wrong directory. + """ + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + + # Create directory structure as per .pddrc + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / "prompts").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "examples").mkdir() + + # Create .pddrc file + pddrc_content = """version: "1.0" +contexts: + regression: + paths: ["**"] + defaults: + test_output_path: "tests/" + example_output_path: "examples/" +""" + (tmp_path / ".pddrc").write_text(pddrc_content) + + # Mock construct_paths to return .pddrc-configured paths + mock_construct.return_value = ( + {"test_output_path": "tests/"}, + {}, + { + "output": "tests/test_simple_math.py", + "test_file": "tests/test_simple_math.py", + "example_file": "examples/simple_math_example.py", + "code_file": "simple_math.py" + }, + "python" + ) + + # Don't create prompt file - this simulates the regression scenario + # The sync should still work and not look for test_simple_math.py in current dir + + decision = sync_determine_operation( basename="simple_math", language="python", target_coverage=90.0, budget=10.0, log_mode=False, prompts_dir="prompts", - skip_tests=True, + skip_tests=False, skip_verify=False ) - # Should NOT return analyze_conflict anymore - assert decision.operation != 'analyze_conflict' - - # Should return appropriate recovery operation - assert decision.operation in ['generate', 'auto-deps'] - assert 'missing' in decision.reason.lower() or 'regenerate' in decision.reason.lower() + # Verify no FileNotFoundError is raised + # The decision should handle missing files gracefully + assert isinstance(decision, SyncDecision) + # Should return an operation that makes sense for missing prompt + assert decision.operation in ['nothing', 'auto-deps', 'generate'] finally: os.chdir(original_cwd) - def test_skip_flags_integration(self, tmp_path): - """Test that skip flags are properly integrated throughout the decision logic.""" + def test_file_path_lookup_regression(self, tmp_path, monkeypatch): + """Test the exact regression scenario: file lookup after verify completes. + + This test simulates the exact error seen in sync regression where + after verify completes, something tries to read 'test_simple_math.py' + from the current directory instead of 'tests/test_simple_math.py'. + """ original_cwd = os.getcwd() + + # Store original module constants to restore them later + pdd_module = sys.modules['sync_determine_operation'] + original_pdd_dir = pdd_module.PDD_DIR + original_meta_dir = pdd_module.META_DIR + original_locks_dir = pdd_module.LOCKS_DIR + try: os.chdir(tmp_path) - # Create directory structure + # Set PDD_PATH environment variable for get_language function + monkeypatch.setenv("PDD_PATH", str(tmp_path)) + + # Create language mapping CSV files that get_language function needs + language_csv_content = """extension,language +.py,python +.js,javascript +.java,java +.cpp,cpp +.c,c +.go,go +.rs,rust +.rb,ruby +.php,php +.ts,typescript +.swift,swift +.kt,kotlin +.scala,scala +.clj,clojure +.hs,haskell +.ml,ocaml +.fs,fsharp +.ex,elixir +.erl,erlang +.pl,perl +.lua,lua +.r,r +.m,matlab +.jl,julia +.dart,dart +.groovy,groovy +.sh,bash +.ps1,powershell +.bat,batch +.cmd,batch +.vb,vb +.cs,csharp +.f,fortran +.f90,fortran +.pas,pascal +.asm,assembly +.s,assembly +.sol,solidity +.move,move +""" + (tmp_path / "language_extension_mapping.csv").write_text(language_csv_content) + + # Create data directory and language_format.csv + (tmp_path / "data").mkdir() + (tmp_path / "data" / "language_format.csv").write_text(language_csv_content) + + # Update module constants after changing directory + pdd_module.PDD_DIR = pdd_module.get_pdd_dir() + pdd_module.META_DIR = pdd_module.get_meta_dir() + pdd_module.LOCKS_DIR = pdd_module.get_locks_dir() + + # Create directory structure matching regression test (tmp_path / "prompts").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "examples").mkdir() + (tmp_path / ".pdd" / "meta").mkdir(parents=True) - # Create prompt file - prompt_file = tmp_path / "prompts" / "test_python.prompt" - prompt_file.write_text("Create a simple function") + # Create the files that exist after verify completes + (tmp_path / "prompts" / "simple_math_python.prompt").write_text("Create add function") + (tmp_path / "simple_math.py").write_text("def add(a, b): return a + b") + (tmp_path / "examples" / "simple_math_example.py").write_text("from simple_math import add") + (tmp_path / "simple_math_verify_results.log").write_text("Success") - # Test with skip_tests=True - decision = sync_determine_operation( - basename="test", - language="python", - target_coverage=90.0, - budget=10.0, - log_mode=False, - prompts_dir="prompts", - skip_tests=True, - skip_verify=False - ) + # Create .pddrc that specifies test path + pddrc_content = """version: "1.0" +contexts: + regression: + paths: ["**"] + defaults: + test_output_path: "tests/" + example_output_path: "examples/" +""" + (tmp_path / ".pddrc").write_text(pddrc_content) - # Should start normal workflow - assert decision.operation in ['generate', 'auto-deps'] + # The test file should be in tests/ directory according to .pddrc + # but the error shows it's being looked for in current directory + + # Use the already imported get_pdd_file_paths to avoid module conflicts + # get_pdd_file_paths was imported at the top of the file + + # Get file paths - this should respect .pddrc + paths = get_pdd_file_paths("simple_math", "python", "prompts") + + # This demonstrates the bug: trying to check if test file exists + # in the wrong location would cause the error + test_path = paths['test'] + + # The fix is now in place, so we should always get the correct path + # Verify that the path respects the .pddrc configuration + assert "tests/test_simple_math.py" in str(test_path) or "tests\\test_simple_math.py" in str(test_path), \ + f"Expected test path to be in tests/ subdirectory as per .pddrc, but got: {test_path}" + + # Verify the file lookup fails with the correct path (file doesn't exist) + try: + with open(test_path, 'r') as f: + f.read() + assert False, "Should have raised FileNotFoundError" + except FileNotFoundError as e: + error_msg = str(e) + assert "tests/test_simple_math.py" in error_msg or "tests\\test_simple_math.py" in error_msg, \ + f"Expected error to reference 'tests/test_simple_math.py', but got: {error_msg}" + + # After fix, the path should be 'tests/test_simple_math.py' + # and this error wouldn't occur if the file existed there finally: os.chdir(original_cwd) + + # Restore original module constants + pdd_module.PDD_DIR = original_pdd_dir + pdd_module.META_DIR = original_meta_dir + pdd_module.LOCKS_DIR = original_locks_dir + + +# --- Regression: Output path resolution under sync (integrated) --- + +def _write_pddrc_here() -> None: + content = ( + "contexts:\n" + " default:\n" + " defaults:\n" + " generate_output_path: pdd/\n" + " example_output_path: examples/\n" + " test_output_path: tests/\n" + ) + Path(".pdd").mkdir(parents=True, exist_ok=True) + Path(".pddrc").write_text(content, encoding="utf-8") + + +def _write_simple_prompt(basename: str = "simple_math", language: str = "python") -> None: + prompts_dir = Path("prompts") + prompts_dir.mkdir(parents=True, exist_ok=True) + (prompts_dir / f"{basename}_{language}.prompt").write_text( + """ +Write a simple add(a, b) function that returns a + b. +Also include a subtract(a, b) that returns a - b. +""".strip(), + encoding="utf-8", + ) + + +def test_get_pdd_file_paths_respects_pddrc_without_PDD_PATH(pdd_test_environment, monkeypatch): + _write_pddrc_here() + _write_simple_prompt() + monkeypatch.delenv("PDD_PATH", raising=False) + paths = get_pdd_file_paths(basename="simple_math", language="python", prompts_dir="prompts") + assert paths["code"].as_posix().endswith("pdd/simple_math.py"), f"Got: {paths['code']}" + + +def test_get_pdd_file_paths_respects_pddrc_with_PDD_PATH(pdd_test_environment, monkeypatch): + _write_pddrc_here() + _write_simple_prompt() + repo_root = Path(__file__).parent.parent + monkeypatch.setenv("PDD_PATH", str(repo_root)) + paths = get_pdd_file_paths(basename="simple_math", language="python", prompts_dir="prompts") + assert paths["code"].as_posix().endswith("pdd/simple_math.py") + assert paths["example"].as_posix().endswith("examples/simple_math_example.py") + assert paths["test"].as_posix().endswith("tests/test_simple_math.py") + + +def test_get_pdd_file_paths_with_subdirectory_basename(pdd_test_environment, monkeypatch): + """A path-qualified basename keeps its subdirectory under the configured dir (#1677). + + For basename='core/cloud' with no architecture entry and .pddrc paths ending in /: + - generate_output_path: pdd/ → code: pdd/core/cloud.py + - test_output_path: tests/ → test: tests/core/test_cloud.py + - example_output_path: examples/ → example: examples/core/cloud_example.py + + Issue #1677: the basename's directory (`core/`) is preserved so two modules sharing + a leaf (`core/cloud`, `aws/cloud`) don't collapse onto one `pdd/cloud.py`. Any + segment the configured directory already provides is de-duplicated (it is NOT + re-prefixed to `pdd/pdd/...`). A context whose prompts_dir already maps the + directory keeps using its generate_output_path directly (see + test_explicit_output_paths). + """ + _write_pddrc_here() + + # Create prompt file in subdirectory + prompts_core_dir = Path("prompts") / "core" + prompts_core_dir.mkdir(parents=True, exist_ok=True) + (prompts_core_dir / "cloud_python.prompt").write_text("Write a cloud module") + + repo_root = Path(__file__).parent.parent + monkeypatch.setenv("PDD_PATH", str(repo_root)) + + paths = get_pdd_file_paths(basename="core/cloud", language="python", prompts_dir="prompts") + + # The basename's subdirectory (core/) is preserved under each configured dir. + code_path = paths["code"].as_posix() + test_path = paths["test"].as_posix() + example_path = paths["example"].as_posix() + + assert code_path.endswith("pdd/core/cloud.py"), \ + f"Expected path ending with 'pdd/core/cloud.py', got {code_path}" + assert test_path.endswith("tests/core/test_cloud.py"), \ + f"Expected path ending with 'tests/core/test_cloud.py', got {test_path}" + assert example_path.endswith("examples/core/cloud_example.py"), \ + f"Expected path ending with 'examples/core/cloud_example.py', got {example_path}" + + +def test_get_pdd_file_paths_no_path_duplication_with_deep_prompts_dir(tmp_path, monkeypatch): + """ + Regression test for Issue #237: Path duplication when prompts_dir is a deep path. + + When sync_main passes prompt_file_path.parent as prompts_dir (e.g., + 'prompts/frontend/app/admin/discount-codes'), and basename contains the same + path (e.g., 'frontend/app/admin/discount-codes/page'), the resulting prompt_path + should NOT have the path segment duplicated. + + Bug: prompts/frontend/.../page_typescriptreact.prompt was being constructed as + prompts/frontend/.../frontend/.../page_typescriptreact.prompt + """ + monkeypatch.chdir(tmp_path) + + # Create deep directory structure + deep_prompts_dir = tmp_path / "prompts" / "frontend" / "app" / "admin" / "discount-codes" + deep_prompts_dir.mkdir(parents=True) + + # Create the prompt file where it should be + prompt_file = deep_prompts_dir / "page_typescriptreact.prompt" + prompt_file.write_text("Test prompt") + + # Call with the deep prompts_dir (as sync_main would after commit 960de48d) + paths = get_pdd_file_paths( + basename="frontend/app/admin/discount-codes/page", + language="typescriptreact", + prompts_dir=str(deep_prompts_dir), # Deep path, not just "prompts" + ) + + # The prompt path should be the actual file, NOT have duplicated segments + prompt_path = paths.get("prompt") + assert prompt_path is not None + + # Key assertion: path should NOT contain the segment twice + path_str = str(prompt_path) + assert path_str.count("frontend/app/admin/discount-codes") == 1, \ + f"Path has duplicated segment: {path_str}" + + # Should resolve to the actual file + assert prompt_path.exists(), f"Prompt path does not exist: {prompt_path}" + + +def test_get_pdd_file_paths_no_duplication_when_prompts_dir_is_absolute_with_subdirectory(tmp_path, monkeypatch): + """ + Regression test: prompt path duplication when prompts_dir is an absolute path + that already contains the context's subdirectory. + + Bug scenario (from downstream_project recruiting modules): + - .pddrc context has prompts_dir: "prompts/recruiting" + - sync_main discovers a prompt via template and passes the absolute parent as + prompts_dir, e.g. "/abs/path/prompts/recruiting" + - _resolve_prompts_root returns it unchanged (already absolute) + - The prefix logic extracts "recruiting" from prompts_dir config and prepends + it AGAIN, producing "/abs/path/prompts/recruiting/recruiting/mod_python.prompt" + - The prompt file is then not found because the doubled path doesn't exist + + The bug is in the early prompt_path construction (before the construct_paths + fallback). When the outputs config does NOT include a prompt template, the + corrupted prompt_path is passed as fallback to _generate_paths_from_templates + and used directly. + + Expected: The prompt path should be "/abs/path/prompts/recruiting/mod_python.prompt" + (no duplicated "recruiting" directory segment). + """ + monkeypatch.chdir(tmp_path) + + # Directory structure mimicking downstream_project recruiting + prompts_recruiting = tmp_path / "prompts" / "recruiting" + prompts_recruiting.mkdir(parents=True) + + # Create the prompt file at the correct location + prompt_file = prompts_recruiting / "recruiting_nurture_models_python.prompt" + prompt_file.write_text("Build nurture models") + + # .pddrc with prompts_dir: "prompts/recruiting" but NO prompt path in outputs. + # This forces the fallback to use the initial prompt_path built by the prefix logic. + (tmp_path / ".pddrc").write_text( + 'contexts:\n' + ' recruiting_nurture_models:\n' + ' paths: ["backend/functions/recruiting/nurture/**"]\n' + ' defaults:\n' + ' prompts_dir: "prompts/recruiting"\n' + ' generate_output_path: "backend/functions/recruiting/nurture/"\n' + ' outputs:\n' + ' code:\n' + ' path: "backend/functions/recruiting/nurture/recruiting_nurture_models.py"\n' + ' test:\n' + ' path: "backend/tests/recruiting/test_recruiting_nurture_models.py"\n' + ) + + # Call with ABSOLUTE prompts_dir (as sync_main does after template discovery) + paths = get_pdd_file_paths( + basename="recruiting_nurture_models", + language="python", + prompts_dir=str(prompts_recruiting), # absolute, already includes "recruiting" + context_override="recruiting_nurture_models", + ) + + prompt_path = paths.get("prompt") + assert prompt_path is not None + + # Key assertion: the "recruiting" directory must NOT be duplicated in the path. + # Note: "recruiting/recruiting_nurture_models..." is fine (dir/filename), but + # "recruiting/recruiting/" (two consecutive directory segments) is the bug. + path_str = str(prompt_path) + assert "recruiting/recruiting/" not in path_str, \ + f"Path has duplicated 'recruiting' directory segment: {path_str}" + + # Should resolve to the actual file + assert prompt_path.exists(), f"Prompt path does not exist: {prompt_path}" + + +# --- Regression Tests: All Files Exist But Workflow Incomplete --- + +class TestAllFilesExistWorkflowIncomplete: + """ + Regression tests for bugs where test file exists but workflow is incomplete. + + The crash/verify/test logic at line 1074-1137 only runs when test is MISSING. + These tests verify correct behavior when all files exist but workflow is incomplete. + + Bug scenarios: + - BUG 4: All files exist + NO run_report → should return 'crash' + - BUG 1: All files exist + run_report.exit_code != 0 → should return 'crash' + - BUG 2: All files exist + run_report OK + command='crash' → should return 'verify' + - Sanity: All files exist + run_report OK + command='test' → should return 'nothing' + """ + + @pytest.fixture + def all_files_env(self, tmp_path): + """Setup: All PDD files exist with matching fingerprint.""" + original_cwd = Path.cwd() + os.chdir(tmp_path) + + # Setup base directories + pdd_dir = tmp_path / ".pdd" + meta_dir = pdd_dir / "meta" + locks_dir = pdd_dir / "locks" + prompts_dir = tmp_path / "prompts" + + for d in [meta_dir, locks_dir, prompts_dir]: + d.mkdir(parents=True, exist_ok=True) + + # Update module-level path constants BEFORE calling get_pdd_file_paths + pdd_module = sys.modules['sync_determine_operation'] + pdd_module.PDD_DIR = pdd_module.get_pdd_dir() + pdd_module.META_DIR = pdd_module.get_meta_dir() + pdd_module.LOCKS_DIR = pdd_module.get_locks_dir() + + # Create prompt file first (required for get_pdd_file_paths) + prompt_file = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_file.write_text("Create add function") + + # Get the expected file paths from the sync_determine_operation module + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") + + # Create files at the paths the module expects + code_file = paths['code'] + code_file.parent.mkdir(parents=True, exist_ok=True) + code_file.write_text("def add(a, b): return a + b") + + example_file = paths['example'] + example_file.parent.mkdir(parents=True, exist_ok=True) + example_file.write_text("add(1, 2) == 3") + + test_file = paths['test'] + test_file.parent.mkdir(parents=True, exist_ok=True) + test_file.write_text("def test_add(): assert add(1, 2) == 3") + + yield { + 'tmp_path': tmp_path, + 'meta_dir': meta_dir, + 'prompt': prompt_file, + 'code': code_file, + 'example': example_file, + 'test': test_file + } + + # Restore original working directory + os.chdir(original_cwd) + pdd_module.PDD_DIR = pdd_module.get_pdd_dir() + pdd_module.META_DIR = pdd_module.get_meta_dir() + pdd_module.LOCKS_DIR = pdd_module.get_locks_dir() + + def _create_fingerprint(self, env, command='test'): + """Helper to create fingerprint with given command.""" + fp_file = env['meta_dir'] / f"{BASENAME}_{LANGUAGE}.json" + fp_file.write_text(json.dumps({ + "pdd_version": "1.0.0", + "timestamp": "2025-01-01T00:00:00+00:00", + "command": command, + "prompt_hash": calculate_sha256(env['prompt']), + "code_hash": calculate_sha256(env['code']), + "example_hash": calculate_sha256(env['example']), + "test_hash": calculate_sha256(env['test']) + })) + + def _create_run_report(self, env, exit_code=0): + """Helper to create run_report with given exit_code.""" + rr_file = env['meta_dir'] / f"{BASENAME}_{LANGUAGE}_run.json" + rr_file.write_text(json.dumps({ + "timestamp": "2025-01-01T00:00:00+00:00", + "exit_code": exit_code, + "tests_passed": 5 if exit_code == 0 else 0, + "tests_failed": 0 if exit_code == 0 else 1, + "coverage": 95.0 if exit_code == 0 else 0.0 + })) + + def test_bug4_no_run_report_returns_crash(self, all_files_env): + """BUG 4: All files exist, NO run_report → should return 'crash'.""" + env = all_files_env + self._create_fingerprint(env, command='generate') + # NO run_report - this is the key scenario + + decision = sync_determine_operation( + basename=BASENAME, + language=LANGUAGE, + target_coverage=TARGET_COVERAGE, + log_mode=True + ) + + assert decision.operation == 'crash', ( + f"BUG 4: Expected 'crash' when all files exist but no run_report, " + f"got '{decision.operation}' with reason: {decision.reason}" + ) + + def test_bug1_exit_code_nonzero_returns_crash(self, all_files_env): + """BUG 1: All files exist, run_report.exit_code != 0 → should return 'crash'.""" + env = all_files_env + self._create_fingerprint(env, command='crash') + self._create_run_report(env, exit_code=1) # Code crashed! + + decision = sync_determine_operation( + basename=BASENAME, + language=LANGUAGE, + target_coverage=TARGET_COVERAGE, + log_mode=True + ) + assert decision.operation == 'crash', ( + f"BUG 1: Expected 'crash' when exit_code=1, " + f"got '{decision.operation}' with reason: {decision.reason}" + ) -class TestGetPddFilePaths: - """Test get_pdd_file_paths function to prevent path resolution regression.""" - - def test_get_pdd_file_paths_respects_pddrc_when_prompt_missing(self, tmp_path, monkeypatch): - """Test that get_pdd_file_paths uses .pddrc configuration even when prompt doesn't exist. - - This test prevents regression of the bug where test files were looked for in the - current directory instead of the configured tests/ subdirectory. + def test_bug2_verify_not_run_returns_verify(self, all_files_env): + """BUG 2: All files exist, run_report OK, command='crash' → should return 'verify'.""" + env = all_files_env + self._create_fingerprint(env, command='crash') # Verify hasn't run yet + self._create_run_report(env, exit_code=0) + + decision = sync_determine_operation( + basename=BASENAME, + language=LANGUAGE, + target_coverage=TARGET_COVERAGE, + log_mode=True + ) + + assert decision.operation == 'verify', ( + f"BUG 2: Expected 'verify' when command='crash' (verify not run yet), " + f"got '{decision.operation}' with reason: {decision.reason}" + ) + + def test_complete_workflow_returns_nothing(self, all_files_env): + """Sanity check: When workflow is truly complete, should return 'nothing'.""" + env = all_files_env + self._create_fingerprint(env, command='test') # Workflow complete + self._create_run_report(env, exit_code=0) + + decision = sync_determine_operation( + basename=BASENAME, + language=LANGUAGE, + target_coverage=TARGET_COVERAGE, + log_mode=True + ) + + assert decision.operation == 'nothing', ( + f"Expected 'nothing' when workflow complete, " + f"got '{decision.operation}' with reason: {decision.reason}" + ) + +# --- Part 6: PDD Doctrine - Derived Artifacts Tests --- + +@patch('sync_determine_operation.construct_paths') +def test_no_conflict_when_only_derived_artifacts_change(mock_construct, pdd_test_environment): + """ + Test that when only derived artifacts (code + example) change but prompt is UNCHANGED, + this should NOT be treated as a conflict per PDD doctrine. + + PDD Doctrine: Prompt is the source of truth. Code, example, and test are derived artifacts. + If prompt is unchanged, changes to derived artifacts are NOT conflicts - they're + interrupted workflows that should continue. + + Bug: sync_determine_operation returns 'analyze_conflict' when len(changes) > 1, + without checking if prompt is in the changes list. + """ + prompts_dir = pdd_test_environment / "prompts" + + # Create all files with specific content + prompt_content = "unchanged prompt content" + code_content = "modified code content" + example_content = "modified example content" + + prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) + create_file(pdd_test_environment / f"{BASENAME}.py", code_content) + create_file(pdd_test_environment / f"{BASENAME}_example.py", example_content) + + mock_construct.return_value = ( + {}, + {}, + {'generate_output_path': str(pdd_test_environment / f"{BASENAME}.py")}, + LANGUAGE + ) + + # Create fingerprint where: + # - prompt_hash MATCHES current file (prompt unchanged) + # - code_hash DIFFERS from current file (code changed) + # - example_hash DIFFERS from current file (example changed) + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "1.0", + "timestamp": "2024-01-01T00:00:00", + "command": "verify", + "prompt_hash": prompt_hash, # MATCHES - prompt unchanged + "code_hash": "old_code_hash_differs", # DIFFERS - code changed + "example_hash": "old_example_hash_differs", # DIFFERS - example changed + "test_hash": None + }) + + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + + # KEY ASSERTION: Should NOT return analyze_conflict when prompt is unchanged + assert decision.operation != 'analyze_conflict', \ + f"Should not return analyze_conflict when only derived artifacts changed. " \ + f"Got: {decision.operation}, reason: {decision.reason}, details: {decision.details}" + + # Should continue the workflow with an appropriate operation (not conflict) + # verify is appropriate since code/example changed and need validation + assert decision.operation in ['verify', 'crash', 'update'], \ + f"Expected workflow continuation operation, got: {decision.operation}" + + # If details are provided, verify prompt was not flagged as changed + if decision.details: + assert decision.details.get('prompt_changed', False) == False, \ + "prompt_changed should be False when only derived artifacts changed" + + +# ============================================================================= +# Stale Run Report Regression Tests +# ============================================================================= +# Bug Summary (discovered in admin_get_users): +# - pdd sync returns 'nothing' when run_report is stale (older than fingerprint) +# - run_report shows tests_failed=0 but actual tests have failures +# - The fingerprint.test_hash was updated but run_report was NOT invalidated + + +class TestStaleRunReportRegression: + """ + Regression tests for stale run_report bug. + + Bug scenario: + - run_report.timestamp: 2025-12-10 (old, shows tests_failed=0) + - fingerprint.timestamp: 2025-12-12 (new, test_hash was updated) + - Actual tests would fail + - sync incorrectly returned 'nothing' + """ + + def test_stale_run_report_detected_when_test_hash_differs(self, pdd_test_environment): """ - original_cwd = os.getcwd() - try: - os.chdir(tmp_path) - - # Create .pddrc configuration file - pddrc_content = """version: "1.0" -contexts: - regression: - paths: ["**"] - defaults: - test_output_path: "tests/" - example_output_path: "examples/" - default_language: "python" -""" - (tmp_path / ".pddrc").write_text(pddrc_content) - - # Create directory structure - (tmp_path / "prompts").mkdir() - (tmp_path / "tests").mkdir() - (tmp_path / "examples").mkdir() - - # Mock construct_paths to return configured paths - def mock_construct_paths( - input_file_paths, - force, - quiet, - command, - command_options, - context_override=None, - path_resolution_mode=None, - **_ignored, - ): - # Simulate what construct_paths would return with .pddrc configuration - return ( - { - "test_output_path": "tests/", - "example_output_path": "examples/", - "generate_output_path": "./" - }, - {}, - {}, # output_paths is empty when called with empty input_file_paths - "python" - ) - - monkeypatch.setattr('sync_determine_operation.construct_paths', mock_construct_paths) - - # Test when prompt file doesn't exist - this is the regression scenario - basename = "test_unit" - language = "python" - paths = get_pdd_file_paths(basename, language, "prompts") - - # Verify paths respect configuration, not hardcoded to current directory - # The bug was that test file was "test_test_unit.py" instead of "tests/test_test_unit.py" - assert str(paths['test']) == "tests/test_test_unit.py", f"Test path should be in tests/ subdirectory, got: {paths['test']}" - assert str(paths['example']) == "examples/test_unit_example.py", f"Example path should be in examples/ subdirectory, got: {paths['example']}" - assert str(paths['code']) == "test_unit.py", f"Code path can be in current directory, got: {paths['code']}" - - # Verify the paths are Path objects - assert isinstance(paths['test'], Path) - assert isinstance(paths['example'], Path) - assert isinstance(paths['code'], Path) - assert isinstance(paths['prompt'], Path) - - finally: - os.chdir(original_cwd) + Sync should detect stale run_report and NOT return 'nothing'. + + When fingerprint.test_hash matches current test file but run_report is stale, + sync should trigger 'test', not 'nothing'. + """ + tmp_path = pdd_test_environment + + # Create additional directories needed for this test + Path("src").mkdir(exist_ok=True) + Path("tests").mkdir(exist_ok=True) + Path("examples").mkdir(exist_ok=True) + + # Create all files + prompt_path = tmp_path / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt\nGenerate a test module") + + code_path = tmp_path / "src" / f"{BASENAME}.py" + code_hash = create_file(code_path, "def foo(): pass") + + example_path = tmp_path / "examples" / f"{BASENAME}_example.py" + example_hash = create_file(example_path, "# example usage") + + test_path = tmp_path / "tests" / f"test_{BASENAME}.py" + test_hash = create_file(test_path, "def test_fail(): assert False") + + # Create STALE run_report (Dec 10, claims tests pass, no test_hash) + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-10T08:33:52.589258+00:00", + "exit_code": 0, + "tests_passed": 1, + "tests_failed": 0, + "coverage": 95.0 + }) + + # Create NEWER fingerprint (Dec 12) + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.81", + "timestamp": "2025-12-12T00:39:11.061591+00:00", + "command": "verify", + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash, + }) + + mock_paths = { + 'prompt': prompt_path, + 'code': code_path, + 'example': example_path, + 'test': test_path, + } + + with patch('sync_determine_operation.construct_paths') as mock_construct, \ + patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: + mock_construct.return_value = ( + {'prompt_file': str(prompt_path)}, + {'output': str(code_path)}, + {'output': str(test_path)}, + {'output': str(example_path)} + ) + mock_get_paths.return_value = mock_paths - def test_get_pdd_file_paths_uses_context_relative_basename_for_templates(self, tmp_path, monkeypatch): - pddrc_content = """version: "1.0" -contexts: - frontend-components: - paths: - - "frontend/components/**" - defaults: - default_language: "typescriptreact" - outputs: - prompt: - path: "prompts/frontend/components/{category}/{name}_{language}.prompt" - code: - path: "frontend/src/components/{category}/{name}/{name}.tsx" - example: - path: "context/frontend/{name}_example.tsx" - default: - defaults: - default_language: "python" -""" - (tmp_path / ".pddrc").write_text(pddrc_content) + decision = sync_determine_operation( + basename=BASENAME, + language=LANGUAGE, + target_coverage=TARGET_COVERAGE, + prompts_dir="prompts", + skip_tests=False, + skip_verify=False, + ) - prompt_path = ( - tmp_path - / "prompts" - / "frontend" - / "components" - / "marketplace" - / "AssetCard_typescriptreact.prompt" + # FIX: When run_report is stale, sync returns 'test' to re-validate + assert decision.operation != 'nothing', ( + f"Sync returned 'nothing' with stale run_report!\n" + f"Expected: 'test' to re-validate\n" + f"Actual: '{decision.operation}' - {decision.reason}" ) - prompt_path.parent.mkdir(parents=True, exist_ok=True) - prompt_path.write_text("Generate AssetCard component", encoding="utf-8") - repo_root = Path(__file__).resolve().parents[1] - monkeypatch.setenv("PDD_PATH", str(repo_root)) - monkeypatch.chdir(tmp_path) + def test_workflow_not_complete_when_run_report_is_stale(self, pdd_test_environment): + """ + _is_workflow_complete() should return False when run_report is stale. - paths = get_pdd_file_paths( - basename="frontend/components/marketplace/AssetCard", - language="typescriptreact", - prompts_dir="prompts", - context_override="frontend-components", + When fingerprint.timestamp > run_report.timestamp, workflow should NOT be complete. + """ + tmp_path = pdd_test_environment + + Path("src").mkdir(exist_ok=True) + Path("tests").mkdir(exist_ok=True) + Path("examples").mkdir(exist_ok=True) + + code_path = tmp_path / "src" / f"{BASENAME}.py" + code_hash = create_file(code_path, "def foo(): pass") + + example_path = tmp_path / "examples" / f"{BASENAME}_example.py" + example_hash = create_file(example_path, "# example") + + test_path = tmp_path / "tests" / f"test_{BASENAME}.py" + test_hash = create_file(test_path, "def test_fail(): assert False") + + paths = { + 'code': code_path, + 'example': example_path, + 'test': test_path + } + + # STALE run_report (Dec 10) + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-10T08:33:52.589258+00:00", + "exit_code": 0, + "tests_passed": 1, + "tests_failed": 0, + "coverage": 0.0 + }) + + # NEWER fingerprint (Dec 12) + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.81", + "timestamp": "2025-12-12T00:39:11.061591+00:00", + "command": "verify", + "prompt_hash": "abc123", + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash, + }) + + result = _is_workflow_complete( + paths=paths, + skip_tests=False, + skip_verify=False, + basename=BASENAME, + language=LANGUAGE ) - assert paths["prompt"].resolve() == prompt_path.resolve() - assert paths["code"].as_posix() == "frontend/src/components/marketplace/AssetCard/AssetCard.tsx" - assert paths["example"].as_posix() == "context/frontend/AssetCard_example.tsx" - - def test_get_pdd_file_paths_fallback_without_construct_paths(self, tmp_path, monkeypatch): - """Test that paths use configured directories even without .pddrc when prompt is missing. - - After the fix, even without .pddrc, construct_paths should provide - sensible defaults based on the PDD context detection. + assert result == False, ( + "_is_workflow_complete() returned True with stale run_report.\n" + "run_report.timestamp: 2025-12-10, fingerprint.timestamp: 2025-12-12\n" + "The run_report predates the fingerprint, so workflow should not be complete." + ) + + def test_run_report_tests_failed_triggers_fix(self, pdd_test_environment): """ - original_cwd = os.getcwd() - try: - os.chdir(tmp_path) - - # Create directory structure - (tmp_path / "prompts").mkdir() - - # Don't create the prompt file - trigger the fallback logic - basename = "test_unit" - language = "python" - - # Get paths without mocking - this uses construct_paths now - paths = get_pdd_file_paths(basename, language, "prompts") - - # After fix: paths should use PDD's default directory structure - # The exact paths depend on whether construct_paths detects a context - # In a bare directory, it might still use current directory as fallback - # But with .pddrc present, it should use configured paths - - # For a bare directory without .pddrc, current behavior is acceptable - # The important fix is that WITH .pddrc, paths are respected - assert isinstance(paths['test'], Path) - assert isinstance(paths['example'], Path) - assert isinstance(paths['code'], Path) - - finally: - os.chdir(original_cwd) - - @patch('sync_determine_operation.construct_paths') - def test_sync_operation_with_missing_prompt_respects_test_path(self, mock_construct, tmp_path): - """Test that sync_determine_operation doesn't fail when test file is in configured directory. - - This simulates the exact regression scenario where sync fails with - "No such file or directory: 'test_simple_math.py'" because it's looking - in the wrong directory. + Sanity check: When run_report.tests_failed > 0, sync should return 'fix'. """ - original_cwd = os.getcwd() - try: - os.chdir(tmp_path) - - # Create directory structure as per .pddrc - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - (tmp_path / ".pdd" / "locks").mkdir(parents=True) - (tmp_path / "prompts").mkdir() - (tmp_path / "tests").mkdir() - (tmp_path / "examples").mkdir() - - # Create .pddrc file - pddrc_content = """version: "1.0" -contexts: - regression: - paths: ["**"] - defaults: - test_output_path: "tests/" - example_output_path: "examples/" -""" - (tmp_path / ".pddrc").write_text(pddrc_content) - - # Mock construct_paths to return .pddrc-configured paths + tmp_path = pdd_test_environment + + Path("src").mkdir(exist_ok=True) + Path("tests").mkdir(exist_ok=True) + Path("examples").mkdir(exist_ok=True) + + prompt_path = tmp_path / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# prompt") + + code_path = tmp_path / "src" / f"{BASENAME}.py" + code_hash = create_file(code_path, "def foo(): pass") + + example_path = tmp_path / "examples" / f"{BASENAME}_example.py" + example_hash = create_file(example_path, "# example") + + test_path = tmp_path / "tests" / f"test_{BASENAME}.py" + test_hash = create_file(test_path, "def test_fail(): assert False") + + # run_report with tests_failed > 0 + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-12T00:00:00.000000+00:00", + "exit_code": 1, + "tests_passed": 10, + "tests_failed": 3, + "coverage": 0.0 + }) + + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.81", + "timestamp": "2025-12-12T00:39:11.061591+00:00", + "command": "verify", + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash, + }) + + mock_paths = { + 'prompt': prompt_path, + 'code': code_path, + 'example': example_path, + 'test': test_path, + } + + with patch('sync_determine_operation.construct_paths') as mock_construct, \ + patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: mock_construct.return_value = ( - {"test_output_path": "tests/"}, - {}, - { - "output": "tests/test_simple_math.py", - "test_file": "tests/test_simple_math.py", - "example_file": "examples/simple_math_example.py", - "code_file": "simple_math.py" - }, - "python" + {'prompt_file': str(prompt_path)}, + {'output': str(code_path)}, + {'output': str(test_path)}, + {'output': str(example_path)} ) - - # Don't create prompt file - this simulates the regression scenario - # The sync should still work and not look for test_simple_math.py in current dir - + mock_get_paths.return_value = mock_paths + decision = sync_determine_operation( - basename="simple_math", - language="python", - target_coverage=90.0, - budget=10.0, - log_mode=False, + basename=BASENAME, + language=LANGUAGE, + target_coverage=TARGET_COVERAGE, prompts_dir="prompts", skip_tests=False, - skip_verify=False + skip_verify=False, ) - - # Verify no FileNotFoundError is raised - # The decision should handle missing files gracefully - assert isinstance(decision, SyncDecision) - # Should return an operation that makes sense for missing prompt - assert decision.operation in ['nothing', 'auto-deps', 'generate'] - - finally: - os.chdir(original_cwd) - - def test_file_path_lookup_regression(self, tmp_path, monkeypatch): - """Test the exact regression scenario: file lookup after verify completes. - - This test simulates the exact error seen in sync regression where - after verify completes, something tries to read 'test_simple_math.py' - from the current directory instead of 'tests/test_simple_math.py'. + + assert decision.operation == 'fix', ( + f"Expected 'fix' when tests_failed > 0, got '{decision.operation}'" + ) + + +class TestFalsePositiveSuccessBugRegression: + """ + Regression tests for GitHub issue #210: False positive success when skip_verify=True. + + Bug scenario (from core dump): + - User runs sync, 'example' operation runs + - fingerprint saved with command='example' + - run_report created with exit_code=0, tests_failed=0, test_hash=None (from example run, not actual tests) + - User adds new unit tests that would fail + - User runs `pdd sync --skip-verify` + - Sync incorrectly reports success without running tests + + Root causes: + 1. run_report.test_hash was None, causing staleness detection to fail + 2. _is_workflow_complete() didn't check if tests were actually run when skip_verify=True + """ + + def test_skip_verify_with_example_command_should_not_be_complete(self, pdd_test_environment): """ - original_cwd = os.getcwd() - - # Store original module constants to restore them later - pdd_module = sys.modules['sync_determine_operation'] - original_pdd_dir = pdd_module.PDD_DIR - original_meta_dir = pdd_module.META_DIR - original_locks_dir = pdd_module.LOCKS_DIR - - try: - os.chdir(tmp_path) - - # Set PDD_PATH environment variable for get_language function - monkeypatch.setenv("PDD_PATH", str(tmp_path)) - - # Create language mapping CSV files that get_language function needs - language_csv_content = """extension,language -.py,python -.js,javascript -.java,java -.cpp,cpp -.c,c -.go,go -.rs,rust -.rb,ruby -.php,php -.ts,typescript -.swift,swift -.kt,kotlin -.scala,scala -.clj,clojure -.hs,haskell -.ml,ocaml -.fs,fsharp -.ex,elixir -.erl,erlang -.pl,perl -.lua,lua -.r,r -.m,matlab -.jl,julia -.dart,dart -.groovy,groovy -.sh,bash -.ps1,powershell -.bat,batch -.cmd,batch -.vb,vb -.cs,csharp -.f,fortran -.f90,fortran -.pas,pascal -.asm,assembly -.s,assembly -.sol,solidity -.move,move -""" - (tmp_path / "language_extension_mapping.csv").write_text(language_csv_content) - - # Create data directory and language_format.csv - (tmp_path / "data").mkdir() - (tmp_path / "data" / "language_format.csv").write_text(language_csv_content) - - # Update module constants after changing directory - pdd_module.PDD_DIR = pdd_module.get_pdd_dir() - pdd_module.META_DIR = pdd_module.get_meta_dir() - pdd_module.LOCKS_DIR = pdd_module.get_locks_dir() - - # Create directory structure matching regression test - (tmp_path / "prompts").mkdir() - (tmp_path / "tests").mkdir() - (tmp_path / "examples").mkdir() - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - - # Create the files that exist after verify completes - (tmp_path / "prompts" / "simple_math_python.prompt").write_text("Create add function") - (tmp_path / "simple_math.py").write_text("def add(a, b): return a + b") - (tmp_path / "examples" / "simple_math_example.py").write_text("from simple_math import add") - (tmp_path / "simple_math_verify_results.log").write_text("Success") - - # Create .pddrc that specifies test path - pddrc_content = """version: "1.0" -contexts: - regression: - paths: ["**"] - defaults: - test_output_path: "tests/" - example_output_path: "examples/" -""" - (tmp_path / ".pddrc").write_text(pddrc_content) - - # The test file should be in tests/ directory according to .pddrc - # but the error shows it's being looked for in current directory - - # Use the already imported get_pdd_file_paths to avoid module conflicts - # get_pdd_file_paths was imported at the top of the file - - # Get file paths - this should respect .pddrc - paths = get_pdd_file_paths("simple_math", "python", "prompts") - - # This demonstrates the bug: trying to check if test file exists - # in the wrong location would cause the error - test_path = paths['test'] - - # The fix is now in place, so we should always get the correct path - # Verify that the path respects the .pddrc configuration - assert "tests/test_simple_math.py" in str(test_path) or "tests\\test_simple_math.py" in str(test_path), \ - f"Expected test path to be in tests/ subdirectory as per .pddrc, but got: {test_path}" - - # Verify the file lookup fails with the correct path (file doesn't exist) - try: - with open(test_path, 'r') as f: - f.read() - assert False, "Should have raised FileNotFoundError" - except FileNotFoundError as e: - error_msg = str(e) - assert "tests/test_simple_math.py" in error_msg or "tests\\test_simple_math.py" in error_msg, \ - f"Expected error to reference 'tests/test_simple_math.py', but got: {error_msg}" - - # After fix, the path should be 'tests/test_simple_math.py' - # and this error wouldn't occur if the file existed there - - finally: - os.chdir(original_cwd) - - # Restore original module constants - pdd_module.PDD_DIR = original_pdd_dir - pdd_module.META_DIR = original_meta_dir - pdd_module.LOCKS_DIR = original_locks_dir + When fingerprint.command='example' and skip_verify=True, workflow should NOT be complete + because tests haven't been run yet. + This is the core bug: skip_verify=True bypassed the verify check, but the test check + was also effectively bypassed because the code only checked for verify completion. + """ + tmp_path = pdd_test_environment -# --- Regression: Output path resolution under sync (integrated) --- + Path("src").mkdir(exist_ok=True) + Path("tests").mkdir(exist_ok=True) + Path("examples").mkdir(exist_ok=True) -def _write_pddrc_here() -> None: - content = ( - "contexts:\n" - " default:\n" - " defaults:\n" - " generate_output_path: pdd/\n" - " example_output_path: examples/\n" - " test_output_path: tests/\n" - ) - Path(".pdd").mkdir(parents=True, exist_ok=True) - Path(".pddrc").write_text(content, encoding="utf-8") + # Create all files + prompt_path = tmp_path / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt") + code_path = tmp_path / "src" / f"{BASENAME}.py" + code_hash = create_file(code_path, "def foo(): pass") -def _write_simple_prompt(basename: str = "simple_math", language: str = "python") -> None: - prompts_dir = Path("prompts") - prompts_dir.mkdir(parents=True, exist_ok=True) - (prompts_dir / f"{basename}_{language}.prompt").write_text( + example_path = tmp_path / "examples" / f"{BASENAME}_example.py" + example_hash = create_file(example_path, "# example") + + test_path = tmp_path / "tests" / f"test_{BASENAME}.py" + test_hash = create_file(test_path, "def test_fail(): assert False # Would fail if run") + + # Create run_report from example run (not actual tests) + # This mimics what happens after 'crash' check when example passes + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-18T22:00:00.000000+00:00", # Newer than fingerprint + "exit_code": 0, + "tests_passed": 1, # This is from example, not actual unit tests + "tests_failed": 0, + "coverage": 0.0 + # test_hash is None (missing) - this was part of the bug + }) + + # Create fingerprint with command='example' (tests haven't been run) + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.86", + "timestamp": "2025-12-18T21:59:51.000000+00:00", # Older than run_report + "command": "example", # Key: this is NOT 'test', 'fix', or 'update' + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash, + }) + + mock_paths = { + 'prompt': prompt_path, + 'code': code_path, + 'example': example_path, + 'test': test_path, + } + + # Test _is_workflow_complete directly with skip_verify=True + result = _is_workflow_complete( + paths=mock_paths, + skip_tests=False, + skip_verify=True, # Key: skip_verify=True but tests should still be required + basename=BASENAME, + language=LANGUAGE + ) + + assert result == False, ( + "_is_workflow_complete() returned True with skip_verify=True and command='example'.\n" + "Bug: Tests haven't been run yet (fingerprint.command='example'), " + "but workflow was considered complete.\n" + "Expected: False (tests need to run)" + ) + + def test_sync_returns_test_operation_when_tests_not_run(self, pdd_test_environment): + """ + When skip_verify=True but tests haven't been run, sync should return 'test' or 'crash' + operation, NOT 'nothing'. + + This reproduces the exact scenario from GitHub issue #210. """ -Write a simple add(a, b) function that returns a + b. -Also include a subtract(a, b) that returns a - b. -""".strip(), - encoding="utf-8", - ) + tmp_path = pdd_test_environment + Path("src").mkdir(exist_ok=True) + Path("tests").mkdir(exist_ok=True) + Path("examples").mkdir(exist_ok=True) -def test_get_pdd_file_paths_respects_pddrc_without_PDD_PATH(pdd_test_environment, monkeypatch): - _write_pddrc_here() - _write_simple_prompt() - monkeypatch.delenv("PDD_PATH", raising=False) - paths = get_pdd_file_paths(basename="simple_math", language="python", prompts_dir="prompts") - assert paths["code"].as_posix().endswith("pdd/simple_math.py"), f"Got: {paths['code']}" + # Create all files + prompt_path = tmp_path / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt") + code_path = tmp_path / "src" / f"{BASENAME}.py" + code_hash = create_file(code_path, "def foo(): pass") -def test_get_pdd_file_paths_respects_pddrc_with_PDD_PATH(pdd_test_environment, monkeypatch): - _write_pddrc_here() - _write_simple_prompt() - repo_root = Path(__file__).parent.parent - monkeypatch.setenv("PDD_PATH", str(repo_root)) - paths = get_pdd_file_paths(basename="simple_math", language="python", prompts_dir="prompts") - assert paths["code"].as_posix().endswith("pdd/simple_math.py") - assert paths["example"].as_posix().endswith("examples/simple_math_example.py") - assert paths["test"].as_posix().endswith("tests/test_simple_math.py") + example_path = tmp_path / "examples" / f"{BASENAME}_example.py" + example_hash = create_file(example_path, "# example") + test_path = tmp_path / "tests" / f"test_{BASENAME}.py" + test_hash = create_file(test_path, "def test_fail(): assert False") -def test_get_pdd_file_paths_with_subdirectory_basename(pdd_test_environment, monkeypatch): - """A path-qualified basename keeps its subdirectory under the configured dir (#1677). + # Create run_report mimicking example success (not actual tests) + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-18T22:00:00.000000+00:00", + "exit_code": 0, + "tests_passed": 1, + "tests_failed": 0, + "coverage": 0.0 + }) - For basename='core/cloud' with no architecture entry and .pddrc paths ending in /: - - generate_output_path: pdd/ → code: pdd/core/cloud.py - - test_output_path: tests/ → test: tests/core/test_cloud.py - - example_output_path: examples/ → example: examples/core/cloud_example.py + # Fingerprint with command='example' - tests never ran + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.86", + "timestamp": "2025-12-18T21:59:51.000000+00:00", + "command": "example", + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash, + }) - Issue #1677: the basename's directory (`core/`) is preserved so two modules sharing - a leaf (`core/cloud`, `aws/cloud`) don't collapse onto one `pdd/cloud.py`. Any - segment the configured directory already provides is de-duplicated (it is NOT - re-prefixed to `pdd/pdd/...`). A context whose prompts_dir already maps the - directory keeps using its generate_output_path directly (see - test_explicit_output_paths). - """ - _write_pddrc_here() + mock_paths = { + 'prompt': prompt_path, + 'code': code_path, + 'example': example_path, + 'test': test_path, + } - # Create prompt file in subdirectory - prompts_core_dir = Path("prompts") / "core" - prompts_core_dir.mkdir(parents=True, exist_ok=True) - (prompts_core_dir / "cloud_python.prompt").write_text("Write a cloud module") + with patch('sync_determine_operation.construct_paths') as mock_construct, \ + patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: + mock_construct.return_value = ( + {'prompt_file': str(prompt_path)}, + {'output': str(code_path)}, + {'output': str(test_path)}, + {'output': str(example_path)} + ) + mock_get_paths.return_value = mock_paths - repo_root = Path(__file__).parent.parent - monkeypatch.setenv("PDD_PATH", str(repo_root)) + decision = sync_determine_operation( + basename=BASENAME, + language=LANGUAGE, + target_coverage=TARGET_COVERAGE, + prompts_dir="prompts", + skip_tests=False, + skip_verify=True, # Skip verify but tests should still run + ) - paths = get_pdd_file_paths(basename="core/cloud", language="python", prompts_dir="prompts") + assert decision.operation != 'nothing', ( + f"Bug reproduced: Sync returned 'nothing' with skip_verify=True and command='example'.\n" + f"Expected: 'crash', 'verify', or 'test' (workflow should continue)\n" + f"Actual: '{decision.operation}' - {decision.reason}\n" + f"This is GitHub issue #210: False positive success" + ) - # The basename's subdirectory (core/) is preserved under each configured dir. - code_path = paths["code"].as_posix() - test_path = paths["test"].as_posix() - example_path = paths["example"].as_posix() + # Should be either 'crash' (to validate example), 'verify' (if not skipped), 'test', or 'test_extend' + assert decision.operation in ['crash', 'verify', 'test', 'test_extend'], ( + f"Expected 'crash', 'verify', 'test', or 'test_extend' to continue workflow, got '{decision.operation}'" + ) - assert code_path.endswith("pdd/core/cloud.py"), \ - f"Expected path ending with 'pdd/core/cloud.py', got {code_path}" - assert test_path.endswith("tests/core/test_cloud.py"), \ - f"Expected path ending with 'tests/core/test_cloud.py', got {test_path}" - assert example_path.endswith("examples/core/cloud_example.py"), \ - f"Expected path ending with 'examples/core/cloud_example.py', got {example_path}" +# --- Bug Fix Tests: Prompt Changes Should Take Priority Over Runtime Signals --- -def test_get_pdd_file_paths_no_path_duplication_with_deep_prompts_dir(tmp_path, monkeypatch): +def test_prompt_change_detected_even_after_crash_workflow(pdd_test_environment): """ - Regression test for Issue #237: Path duplication when prompts_dir is a deep path. - - When sync_main passes prompt_file_path.parent as prompts_dir (e.g., - 'prompts/frontend/app/admin/discount-codes'), and basename contains the same - path (e.g., 'frontend/app/admin/discount-codes/page'), the resulting prompt_path - should NOT have the path segment duplicated. + BUG: When fingerprint.command == 'crash' and run_report.exit_code == 0, + the sync should still detect prompt changes and trigger regeneration, + not blindly continue to 'verify'. - Bug: prompts/frontend/.../page_typescriptreact.prompt was being constructed as - prompts/frontend/.../frontend/.../page_typescriptreact.prompt + This tests the fix for: prompt changes being ignored when runtime signals + cause early returns in sync_determine_operation. """ - monkeypatch.chdir(tmp_path) + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) - # Create deep directory structure - deep_prompts_dir = tmp_path / "prompts" / "frontend" / "app" / "admin" / "discount-codes" - deep_prompts_dir.mkdir(parents=True) + # Create prompt with NEW content (different from fingerprint hash) + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + new_prompt_hash = create_file(prompt_path, "NEW PROMPT CONTENT - changed!") - # Create the prompt file where it should be - prompt_file = deep_prompts_dir / "page_typescriptreact.prompt" - prompt_file.write_text("Test prompt") + # Create code/example/test files first so the fingerprint represents a + # prompt-only change rather than a prompt+derived conflict. + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") + code_hash = create_file(paths['code'], "def add(a, b): return a + b") + example_hash = create_file(paths['example'], "print(add(1, 2))") + test_hash = create_file(paths['test'], "def test_add(): assert add(1, 2) == 3") - # Call with the deep prompts_dir (as sync_main would after commit 960de48d) - paths = get_pdd_file_paths( - basename="frontend/app/admin/discount-codes/page", - language="typescriptreact", - prompts_dir=str(deep_prompts_dir), # Deep path, not just "prompts" - ) + # Create fingerprint with OLD prompt hash and command='crash' + old_prompt_hash = "old_hash_that_differs_from_current" + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "1.0.0", + "timestamp": "2025-01-01T00:00:00Z", + "command": "crash", # Previous command was crash + "prompt_hash": old_prompt_hash, # Different from current! + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) - # The prompt path should be the actual file, NOT have duplicated segments - prompt_path = paths.get("prompt") - assert prompt_path is not None + # Create run report showing crash fix succeeded + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "2025-01-01T00:00:00Z", + "exit_code": 0, # Success - crash was fixed + "tests_passed": 1, + "tests_failed": 0, + "coverage": 95.0 + }) - # Key assertion: path should NOT contain the segment twice - path_str = str(prompt_path) - assert path_str.count("frontend/app/admin/discount-codes") == 1, \ - f"Path has duplicated segment: {path_str}" + decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE) - # Should resolve to the actual file - assert prompt_path.exists(), f"Prompt path does not exist: {prompt_path}" + # Should detect prompt change and regenerate, NOT continue to verify + assert decision.operation in ('generate', 'auto-deps'), \ + f"Expected 'generate' or 'auto-deps' due to prompt change, got '{decision.operation}'" + assert 'prompt' in decision.reason.lower(), \ + f"Reason should mention prompt change: {decision.reason}" -def test_get_pdd_file_paths_no_duplication_when_prompts_dir_is_absolute_with_subdirectory(tmp_path, monkeypatch): +def test_prompt_change_progresses_generate_verify_test_then_complete(pdd_test_environment): """ - Regression test: prompt path duplication when prompts_dir is an absolute path - that already contains the context's subdirectory. - - Bug scenario (from downstream_project recruiting modules): - - .pddrc context has prompts_dir: "prompts/recruiting" - - sync_main discovers a prompt via template and passes the absolute parent as - prompts_dir, e.g. "/abs/path/prompts/recruiting" - - _resolve_prompts_root returns it unchanged (already absolute) - - The prefix logic extracts "recruiting" from prompts_dir config and prepends - it AGAIN, producing "/abs/path/prompts/recruiting/recruiting/mod_python.prompt" - - The prompt file is then not found because the doubled path doesn't exist - - The bug is in the early prompt_path construction (before the construct_paths - fallback). When the outputs config does NOT include a prompt template, the - corrupted prompt_path is passed as fallback to _generate_paths_from_templates - and used directly. - - Expected: The prompt path should be "/abs/path/prompts/recruiting/mod_python.prompt" - (no duplicated "recruiting" directory segment). + Regression guard for prompt-only changes: sync should regenerate first, then + require verify, then require test before considering the workflow complete. """ - monkeypatch.chdir(tmp_path) + tmp_path = pdd_test_environment + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir(exist_ok=True) - # Directory structure mimicking downstream_project recruiting - prompts_recruiting = tmp_path / "prompts" / "recruiting" - prompts_recruiting.mkdir(parents=True) + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + old_prompt_hash = create_file(prompt_path, "ORIGINAL PROMPT CONTENT") - # Create the prompt file at the correct location - prompt_file = prompts_recruiting / "recruiting_nurture_models_python.prompt" - prompt_file.write_text("Build nurture models") + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) + code_hash = create_file(paths['code'], "def add(a, b):\n return a + b\n") + example_hash = create_file(paths['example'], "print(add(1, 2))\n") + test_hash = create_file(paths['test'], "def test_add():\n assert add(1, 2) == 3\n") - # .pddrc with prompts_dir: "prompts/recruiting" but NO prompt path in outputs. - # This forces the fallback to use the initial prompt_path built by the prefix logic. - (tmp_path / ".pddrc").write_text( - 'contexts:\n' - ' recruiting_nurture_models:\n' - ' paths: ["backend/functions/recruiting/nurture/**"]\n' - ' defaults:\n' - ' prompts_dir: "prompts/recruiting"\n' - ' generate_output_path: "backend/functions/recruiting/nurture/"\n' - ' outputs:\n' - ' code:\n' - ' path: "backend/functions/recruiting/nurture/recruiting_nurture_models.py"\n' - ' test:\n' - ' path: "backend/tests/recruiting/test_recruiting_nurture_models.py"\n' - ) + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + + # Start from a fully completed workflow for the old prompt version. + create_fingerprint_file(fp_path, { + "pdd_version": "1.0.0", + "timestamp": "2025-01-01T00:00:00Z", + "command": "test", + "prompt_hash": old_prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) + create_run_report_file(rr_path, { + "timestamp": "2025-01-01T00:01:00Z", + "exit_code": 0, + "tests_passed": 5, + "tests_failed": 0, + "coverage": 95.0, + "test_hash": test_hash + }) - # Call with ABSOLUTE prompts_dir (as sync_main does after template discovery) - paths = get_pdd_file_paths( - basename="recruiting_nurture_models", - language="python", - prompts_dir=str(prompts_recruiting), # absolute, already includes "recruiting" - context_override="recruiting_nurture_models", + # User edits only the prompt. Sync should restart with generation. + new_prompt_hash = create_file(prompt_path, "UPDATED PROMPT CONTENT") + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) + assert decision.operation == 'generate', ( + f"Expected prompt-only change to restart at 'generate', got '{decision.operation}'" ) - prompt_path = paths.get("prompt") - assert prompt_path is not None + # Simulate successful generate: hashes now match the new prompt, but verify has + # not run for this generation yet. + create_fingerprint_file(fp_path, { + "pdd_version": "1.0.0", + "timestamp": "2025-01-01T00:02:00Z", + "command": "generate", + "prompt_hash": new_prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) + assert decision.operation == 'verify', ( + f"Expected post-generate sync to require 'verify', got '{decision.operation}'" + ) - # Key assertion: the "recruiting" directory must NOT be duplicated in the path. - # Note: "recruiting/recruiting_nurture_models..." is fine (dir/filename), but - # "recruiting/recruiting/" (two consecutive directory segments) is the bug. - path_str = str(prompt_path) - assert "recruiting/recruiting/" not in path_str, \ - f"Path has duplicated 'recruiting' directory segment: {path_str}" + # Simulate successful verify: sync should still require tests before completion. + create_fingerprint_file(fp_path, { + "pdd_version": "1.0.0", + "timestamp": "2025-01-01T00:03:00Z", + "command": "verify", + "prompt_hash": new_prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) + create_run_report_file(rr_path, { + "timestamp": "2025-01-01T00:03:30Z", + "exit_code": 0, + "tests_passed": 5, + "tests_failed": 0, + "coverage": 95.0, + "test_hash": test_hash + }) + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) + assert decision.operation == 'test', ( + f"Expected post-verify sync to require 'test', got '{decision.operation}'" + ) - # Should resolve to the actual file - assert prompt_path.exists(), f"Prompt path does not exist: {prompt_path}" + # After tests complete successfully for the new prompt version, sync is done. + create_fingerprint_file(fp_path, { + "pdd_version": "1.0.0", + "timestamp": "2025-01-01T00:04:00Z", + "command": "test", + "prompt_hash": new_prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) + create_run_report_file(rr_path, { + "timestamp": "2025-01-01T00:04:30Z", + "exit_code": 0, + "tests_passed": 5, + "tests_failed": 0, + "coverage": 95.0, + "test_hash": test_hash + }) + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) + assert decision.operation == 'nothing', ( + f"Expected workflow to be complete after test succeeds, got '{decision.operation}'" + ) -# --- Regression Tests: All Files Exist But Workflow Incomplete --- +# --- GitHub Issue #349: Infinite Loop Bug Tests --- -class TestAllFilesExistWorkflowIncomplete: +class TestInfiniteLoopBugIssue349: """ - Regression tests for bugs where test file exists but workflow is incomplete. + Regression tests for GitHub issue #349: PDD sync doesn't exit fix loop when fix completed. - The crash/verify/test logic at line 1074-1137 only runs when test is MISSING. - These tests verify correct behavior when all files exist but workflow is incomplete. + Bug scenario: + - Tests pass successfully (tests_passed > 0, tests_failed == 0) + - But pytest returns non-zero exit code (e.g., from pytest-cov warnings) + - Current code sees exit_code != 0 and returns 'fix' or 'crash' + - This creates an infinite loop: fix completes, tests pass, but exit_code != 0 triggers another fix - Bug scenarios: - - BUG 4: All files exist + NO run_report → should return 'crash' - - BUG 1: All files exist + run_report.exit_code != 0 → should return 'crash' - - BUG 2: All files exist + run_report OK + command='crash' → should return 'verify' - - Sanity: All files exist + run_report OK + command='test' → should return 'nothing' + Root causes identified: + 1. sync_determine_operation checks exit_code != 0 BEFORE checking test results + 2. _is_workflow_complete returns False when exit_code != 0, even if tests pass + 3. No handling for "tests pass but tooling returned non-zero exit code" scenario + + Fix approach: + - When tests_passed > 0 and tests_failed == 0, treat as success regardless of exit_code + - exit_code != 0 with passing tests indicates tooling issues (pytest-cov, etc.), not test failures """ - @pytest.fixture - def all_files_env(self, tmp_path): - """Setup: All PDD files exist with matching fingerprint.""" - original_cwd = Path.cwd() - os.chdir(tmp_path) + def test_nonzero_exit_code_with_passing_tests_should_not_trigger_fix(self, pdd_test_environment): + """ + Core bug test: When tests pass (tests_passed > 0, tests_failed == 0) but exit_code != 0, + should NOT return 'fix' or 'crash' - workflow should consider tests as passing. - # Setup base directories - pdd_dir = tmp_path / ".pdd" - meta_dir = pdd_dir / "meta" - locks_dir = pdd_dir / "locks" + This reproduces the infinite loop from issue #349 where pytest-cov or other tooling + returns non-zero exit code even when all tests pass. + """ + tmp_path = pdd_test_environment prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir(exist_ok=True) - for d in [meta_dir, locks_dir, prompts_dir]: - d.mkdir(parents=True, exist_ok=True) - - # Update module-level path constants BEFORE calling get_pdd_file_paths - pdd_module = sys.modules['sync_determine_operation'] - pdd_module.PDD_DIR = pdd_module.get_pdd_dir() - pdd_module.META_DIR = pdd_module.get_meta_dir() - pdd_module.LOCKS_DIR = pdd_module.get_locks_dir() - - # Create prompt file first (required for get_pdd_file_paths) - prompt_file = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_file.write_text("Create add function") - - # Get the expected file paths from the sync_determine_operation module - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") - - # Create files at the paths the module expects - code_file = paths['code'] - code_file.parent.mkdir(parents=True, exist_ok=True) - code_file.write_text("def add(a, b): return a + b") + # Create all required files + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt for issue 349") - example_file = paths['example'] - example_file.parent.mkdir(parents=True, exist_ok=True) - example_file.write_text("add(1, 2) == 3") + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) + code_hash = create_file(paths['code'], "def add(a, b): return a + b") + example_hash = create_file(paths['example'], "print(add(1, 2))") + test_hash = create_file(paths['test'], "def test_add(): assert add(1, 2) == 3") - test_file = paths['test'] - test_file.parent.mkdir(parents=True, exist_ok=True) - test_file.write_text("def test_add(): assert add(1, 2) == 3") + # Create fingerprint indicating 'fix' was the last operation + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.126", + "timestamp": "2025-12-20T10:00:00.000000+00:00", + "command": "fix", # Last command was fix + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) - yield { - 'tmp_path': tmp_path, - 'meta_dir': meta_dir, - 'prompt': prompt_file, - 'code': code_file, - 'example': example_file, - 'test': test_file - } + # Create run_report showing: + # - tests_passed > 0: tests actually passed + # - tests_failed == 0: no test failures + # - exit_code != 0: but pytest returned non-zero (e.g., pytest-cov warning) + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-20T10:01:00.000000+00:00", + "exit_code": 1, # Non-zero exit code (e.g., from pytest-cov) + "tests_passed": 5, # Tests actually passed! + "tests_failed": 0, # No failures! + "coverage": 95.0, + "test_hash": test_hash + }) - # Restore original working directory - os.chdir(original_cwd) - pdd_module.PDD_DIR = pdd_module.get_pdd_dir() - pdd_module.META_DIR = pdd_module.get_meta_dir() - pdd_module.LOCKS_DIR = pdd_module.get_locks_dir() + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) - def _create_fingerprint(self, env, command='test'): - """Helper to create fingerprint with given command.""" - fp_file = env['meta_dir'] / f"{BASENAME}_{LANGUAGE}.json" - fp_file.write_text(json.dumps({ - "pdd_version": "1.0.0", - "timestamp": "2025-01-01T00:00:00+00:00", - "command": command, - "prompt_hash": calculate_sha256(env['prompt']), - "code_hash": calculate_sha256(env['code']), - "example_hash": calculate_sha256(env['example']), - "test_hash": calculate_sha256(env['test']) - })) + # Bug: Currently returns 'fix' or 'crash' because exit_code != 0 + # Expected: Should return 'nothing' or 'all_synced' because tests actually pass + assert decision.operation not in ('fix', 'crash'), ( + f"BUG #349: exit_code=1 with passing tests should NOT trigger '{decision.operation}'.\n" + f"tests_passed=5, tests_failed=0, exit_code=1 (from tooling, not test failures)\n" + f"This causes infinite loop: fix completes → tests pass → exit_code != 0 → fix again\n" + f"Got reason: {decision.reason}" + ) - def _create_run_report(self, env, exit_code=0): - """Helper to create run_report with given exit_code.""" - rr_file = env['meta_dir'] / f"{BASENAME}_{LANGUAGE}_run.json" - rr_file.write_text(json.dumps({ - "timestamp": "2025-01-01T00:00:00+00:00", - "exit_code": exit_code, - "tests_passed": 5 if exit_code == 0 else 0, - "tests_failed": 0 if exit_code == 0 else 1, - "coverage": 95.0 if exit_code == 0 else 0.0 - })) + def test_is_workflow_complete_with_passing_tests_and_nonzero_exit_code(self, pdd_test_environment): + """ + Test _is_workflow_complete directly: should return True when tests pass + even if exit_code is non-zero (tooling issue, not test failure). + """ + tmp_path = pdd_test_environment + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir(exist_ok=True) - def test_bug4_no_run_report_returns_crash(self, all_files_env): - """BUG 4: All files exist, NO run_report → should return 'crash'.""" - env = all_files_env - self._create_fingerprint(env, command='generate') - # NO run_report - this is the key scenario + # Create files + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt") - decision = sync_determine_operation( - basename=BASENAME, - language=LANGUAGE, - target_coverage=TARGET_COVERAGE, - log_mode=True - ) + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) + code_hash = create_file(paths['code'], "def foo(): pass") + example_hash = create_file(paths['example'], "foo()") + test_hash = create_file(paths['test'], "def test_foo(): pass") - assert decision.operation == 'crash', ( - f"BUG 4: Expected 'crash' when all files exist but no run_report, " - f"got '{decision.operation}' with reason: {decision.reason}" - ) + # Fingerprint shows workflow completed 'fix' or 'test' + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.126", + "timestamp": "2025-12-20T10:00:00.000000+00:00", + "command": "fix", + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) - def test_bug1_exit_code_nonzero_returns_crash(self, all_files_env): - """BUG 1: All files exist, run_report.exit_code != 0 → should return 'crash'.""" - env = all_files_env - self._create_fingerprint(env, command='crash') - self._create_run_report(env, exit_code=1) # Code crashed! + # Run report: tests pass but exit_code != 0 + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-20T10:01:00.000000+00:00", + "exit_code": 1, # Pytest-cov or similar returned non-zero + "tests_passed": 10, # But tests passed! + "tests_failed": 0, + "coverage": 90.0, + "test_hash": test_hash + }) - decision = sync_determine_operation( + result = _is_workflow_complete( + paths=paths, + skip_tests=False, + skip_verify=False, basename=BASENAME, - language=LANGUAGE, - target_coverage=TARGET_COVERAGE, - log_mode=True + language=LANGUAGE ) - assert decision.operation == 'crash', ( - f"BUG 1: Expected 'crash' when exit_code=1, " - f"got '{decision.operation}' with reason: {decision.reason}" + # Bug: Currently returns False because exit_code != 0 + # Expected: Should return True because tests_passed > 0 and tests_failed == 0 + assert result == True, ( + "BUG #349: _is_workflow_complete() returns False when exit_code=1 with passing tests.\n" + "This causes the infinite loop: workflow never considered 'complete' despite tests passing.\n" + "tests_passed=10, tests_failed=0, exit_code=1 (tooling issue)" ) - def test_bug2_verify_not_run_returns_verify(self, all_files_env): - """BUG 2: All files exist, run_report OK, command='crash' → should return 'verify'.""" - env = all_files_env - self._create_fingerprint(env, command='crash') # Verify hasn't run yet - self._create_run_report(env, exit_code=0) + def test_zero_exit_code_zero_tests_passed_should_trigger_action(self, pdd_test_environment): + """ + Regression guard: When exit_code=0 but tests_passed=0, should NOT consider workflow complete + with 'nothing' - but 'all_synced' is acceptable for unparseable test output scenarios. + This ensures we don't infinite loop but also don't silently skip tests. + """ + tmp_path = pdd_test_environment + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir(exist_ok=True) - decision = sync_determine_operation( - basename=BASENAME, - language=LANGUAGE, - target_coverage=TARGET_COVERAGE, - log_mode=True - ) + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt") - assert decision.operation == 'verify', ( - f"BUG 2: Expected 'verify' when command='crash' (verify not run yet), " - f"got '{decision.operation}' with reason: {decision.reason}" - ) + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) + code_hash = create_file(paths['code'], "def foo(): pass") + example_hash = create_file(paths['example'], "foo()") + test_hash = create_file(paths['test'], "def test_foo(): pass") - def test_complete_workflow_returns_nothing(self, all_files_env): - """Sanity check: When workflow is truly complete, should return 'nothing'.""" - env = all_files_env - self._create_fingerprint(env, command='test') # Workflow complete - self._create_run_report(env, exit_code=0) + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.126", + "timestamp": "2025-12-20T10:00:00.000000+00:00", + "command": "fix", + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) + + # exit_code=0 but no tests actually ran (tests_passed=0) + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-20T10:01:00.000000+00:00", + "exit_code": 0, # Success exit code + "tests_passed": 0, # But no tests passed - suspicious! + "tests_failed": 0, + "coverage": 0.0, + "test_hash": test_hash + }) decision = sync_determine_operation( - basename=BASENAME, - language=LANGUAGE, - target_coverage=TARGET_COVERAGE, - log_mode=True + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) ) - assert decision.operation == 'nothing', ( - f"Expected 'nothing' when workflow complete, " - f"got '{decision.operation}' with reason: {decision.reason}" + # When exit_code=0 and tests_passed=0 and tests_failed=0, this indicates unparseable output + # The fix accepts this as 'all_synced' to prevent infinite test loops (especially for non-Python) + # We just ensure it doesn't return 'nothing' (the hashes-match shortcut path) + assert decision.operation != 'nothing', ( + f"exit_code=0 but tests_passed=0 should not use 'nothing' shortcut, got '{decision.operation}'\n" + f"No tests ran successfully, so workflow needs attention." ) -# --- Part 6: PDD Doctrine - Derived Artifacts Tests --- + def test_unparseable_test_output_for_non_python_accepts_as_complete(self, pdd_test_environment): + """ + Bug fix test: For non-Python languages, when test output can't be parsed + (tests_passed=0, tests_failed=0, coverage=0.0 but exit_code=0), + accept as complete rather than infinitely regenerating tests. -@patch('sync_determine_operation.construct_paths') -def test_no_conflict_when_only_derived_artifacts_change(mock_construct, pdd_test_environment): - """ - Test that when only derived artifacts (code + example) change but prompt is UNCHANGED, - this should NOT be treated as a conflict per PDD doctrine. + This was the root cause of the TypeScript infinite loop bug: + 1. pdd sync generates TypeScript tests + 2. npm test runs, exits with 0 (success) + 3. Output parsing fails to extract test counts (tests_passed=0, tests_failed=0) + 4. sync_determine_operation sees coverage=0.0 < target and returns 'test' + 5. Loop repeats infinitely + """ + tmp_path = pdd_test_environment + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir(exist_ok=True) - PDD Doctrine: Prompt is the source of truth. Code, example, and test are derived artifacts. - If prompt is unchanged, changes to derived artifacts are NOT conflicts - they're - interrupted workflows that should continue. + # Use TypeScript for this test + basename = "prisma_client" + language = "typescript" - Bug: sync_determine_operation returns 'analyze_conflict' when len(changes) > 1, - without checking if prompt is in the changes list. - """ - prompts_dir = pdd_test_environment / "prompts" + prompt_path = prompts_dir / f"{basename}_{language}.prompt" + prompt_hash = create_file(prompt_path, "// TypeScript prompt") - # Create all files with specific content - prompt_content = "unchanged prompt content" - code_content = "modified code content" - example_content = "modified example content" + paths = get_pdd_file_paths(basename, language, prompts_dir=str(prompts_dir)) + code_hash = create_file(paths['code'], "export const foo = () => 'hello';") + example_hash = create_file(paths['example'], "import { foo } from './code'; console.log(foo());") + test_hash = create_file(paths['test'], "test('foo', () => { expect(foo()).toBe('hello'); });") - prompt_hash = create_file(prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt", prompt_content) - create_file(pdd_test_environment / f"{BASENAME}.py", code_content) - create_file(pdd_test_environment / f"{BASENAME}_example.py", example_content) + create_fingerprint_file(get_meta_dir() / f"{basename}_{language}.json", { + "pdd_version": "0.0.126", + "timestamp": "2025-12-20T10:00:00.000000+00:00", + "command": "test", # Last command was test + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) - mock_construct.return_value = ( - {}, - {}, - {'generate_output_path': str(pdd_test_environment / f"{BASENAME}.py")}, - LANGUAGE - ) + # Simulates unparseable test output from npm test: + # - exit_code=0 (tests passed) + # - tests_passed=0 (couldn't parse output) + # - tests_failed=0 (couldn't parse output) + # - coverage=0.0 (couldn't parse output) + create_run_report_file(get_meta_dir() / f"{basename}_{language}_run.json", { + "timestamp": "2025-12-20T10:01:00.000000+00:00", + "exit_code": 0, + "tests_passed": 0, + "tests_failed": 0, + "coverage": 0.0, + "test_hash": test_hash + }) - # Create fingerprint where: - # - prompt_hash MATCHES current file (prompt unchanged) - # - code_hash DIFFERS from current file (code changed) - # - example_hash DIFFERS from current file (example changed) - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "1.0", - "timestamp": "2024-01-01T00:00:00", - "command": "verify", - "prompt_hash": prompt_hash, # MATCHES - prompt unchanged - "code_hash": "old_code_hash_differs", # DIFFERS - code changed - "example_hash": "old_example_hash_differs", # DIFFERS - example changed - "test_hash": None - }) + decision = sync_determine_operation( + basename, language, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, prompts_dir=str(prompts_dir)) + # Should NOT return 'test' - that would cause infinite loop + # Should return 'all_synced' because exit_code=0 indicates success + assert decision.operation != 'test', ( + f"BUG: Unparseable test output with exit_code=0 should NOT return 'test', got '{decision.operation}'\n" + f"This causes infinite test generation loop for non-Python languages.\n" + f"Reason: {decision.reason}" + ) + assert decision.operation == 'all_synced', ( + f"Expected 'all_synced' for unparseable but successful tests, got '{decision.operation}'\n" + f"Reason: {decision.reason}" + ) - # KEY ASSERTION: Should NOT return analyze_conflict when prompt is unchanged - assert decision.operation != 'analyze_conflict', \ - f"Should not return analyze_conflict when only derived artifacts changed. " \ - f"Got: {decision.operation}, reason: {decision.reason}, details: {decision.details}" + def test_nonzero_exit_code_with_actual_failures_should_trigger_fix(self, pdd_test_environment): + """ + Regression guard: When exit_code != 0 AND tests_failed > 0, should still trigger fix. + This ensures the bug fix doesn't break legitimate failure handling. + """ + tmp_path = pdd_test_environment + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir(exist_ok=True) + + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt") + + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) + code_hash = create_file(paths['code'], "def foo(): pass") + example_hash = create_file(paths['example'], "foo()") + test_hash = create_file(paths['test'], "def test_foo(): assert False") - # Should continue the workflow with an appropriate operation (not conflict) - # verify is appropriate since code/example changed and need validation - assert decision.operation in ['verify', 'crash', 'update'], \ - f"Expected workflow continuation operation, got: {decision.operation}" + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.126", + "timestamp": "2025-12-20T10:00:00.000000+00:00", + "command": "fix", + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) - # If details are provided, verify prompt was not flagged as changed - if decision.details: - assert decision.details.get('prompt_changed', False) == False, \ - "prompt_changed should be False when only derived artifacts changed" + # exit_code != 0 AND tests actually failed - this is a real failure + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2025-12-20T10:01:00.000000+00:00", + "exit_code": 1, # Non-zero exit code + "tests_passed": 3, + "tests_failed": 2, # Actual test failures! + "coverage": 60.0, + "test_hash": test_hash + }) + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) -# ============================================================================= -# Stale Run Report Regression Tests -# ============================================================================= -# Bug Summary (discovered in admin_get_users): -# - pdd sync returns 'nothing' when run_report is stale (older than fingerprint) -# - run_report shows tests_failed=0 but actual tests have failures -# - The fingerprint.test_hash was updated but run_report was NOT invalidated + # Real test failures should still trigger fix + assert decision.operation == 'fix', ( + f"exit_code=1 with tests_failed=2 should trigger 'fix', got '{decision.operation}'\n" + f"Real test failures must still be handled." + ) -class TestStaleRunReportRegression: - """ - Regression tests for stale run_report bug. +# --- Bug #573: _is_workflow_complete accepts coverage=0.0 with passing tests --- - Bug scenario: - - run_report.timestamp: 2025-12-10 (old, shows tests_failed=0) - - fingerprint.timestamp: 2025-12-12 (new, test_hash was updated) - - Actual tests would fail - - sync incorrectly returned 'nothing' +class TestZeroCoverageBugIssue573: + """ + Regression tests for GitHub issue #573: _is_workflow_complete() returns True + when tests_passed > 0 and tests_failed == 0, even if coverage is 0.0. + This is a defense-in-depth gap — the function should check coverage against + a minimum threshold. """ - def test_stale_run_report_detected_when_test_hash_differs(self, pdd_test_environment): + def test_is_workflow_complete_rejects_zero_coverage_with_passing_tests(self, pdd_test_environment): """ - Sync should detect stale run_report and NOT return 'nothing'. + Bug #573 (Test 4): _is_workflow_complete should return False when + coverage=0.0 despite tests_passed > 0 and tests_failed == 0. - When fingerprint.test_hash matches current test file but run_report is stale, - sync should trigger 'test', not 'nothing'. + The Bug #349 fix at sync_determine_operation.py:1264 treats + (tests_passed > 0 and tests_failed == 0) as success without checking + coverage. This allows coverage=0.0 to be accepted as workflow complete. """ tmp_path = pdd_test_environment + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir(exist_ok=True) - # Create additional directories needed for this test - Path("src").mkdir(exist_ok=True) - Path("tests").mkdir(exist_ok=True) - Path("examples").mkdir(exist_ok=True) - - # Create all files - prompt_path = tmp_path / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt\nGenerate a test module") - - code_path = tmp_path / "src" / f"{BASENAME}.py" - code_hash = create_file(code_path, "def foo(): pass") + # Create files + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt") - example_path = tmp_path / "examples" / f"{BASENAME}_example.py" - example_hash = create_file(example_path, "# example usage") + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) + code_hash = create_file(paths['code'], "def foo(): pass") + example_hash = create_file(paths['example'], "foo()") + test_hash = create_file(paths['test'], "def test_foo(): pass") - test_path = tmp_path / "tests" / f"test_{BASENAME}.py" - test_hash = create_file(test_path, "def test_fail(): assert False") + # Fingerprint shows workflow completed 'test' + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { + "pdd_version": "0.0.156", + "timestamp": "2026-02-23T00:00:00Z", + "command": "test", + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash + }) - # Create STALE run_report (Dec 10, claims tests pass, no test_hash) + # Run report: tests pass but coverage is 0.0 + # This reproduces the issue where sys.modules stubs mask import errors create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-10T08:33:52.589258+00:00", + "timestamp": "2026-02-23T00:01:00Z", "exit_code": 0, - "tests_passed": 1, + "tests_passed": 62, "tests_failed": 0, - "coverage": 95.0 + "coverage": 0.0, + "test_hash": test_hash }) - # Create NEWER fingerprint (Dec 12) + result = _is_workflow_complete( + paths=paths, + skip_tests=False, + skip_verify=False, + basename=BASENAME, + language=LANGUAGE + ) + + # Bug #573: Currently returns True because is_success check at line 1264 + # only checks tests_passed > 0 and tests_failed == 0, ignoring coverage. + # After fix: Should return False because coverage=0.0 indicates the tests + # are not actually measuring the module under test. + assert result is False, ( + "Bug #573: _is_workflow_complete() returns True when coverage=0.0 " + "with 62 passing tests. This is a defense-in-depth gap — " + "coverage=0.0 means tests are not exercising the module " + "(likely due to sys.modules stub masking broken imports)." + ) + + def test_sync_determine_operation_returns_test_extend_for_zero_coverage(self, pdd_test_environment): + """ + Bug #573 (Test 5): sync_determine_operation should return 'test_extend' + when tests pass but coverage is 0.0. This validates that the detection + side works correctly (it does — the bug is in the orchestration layer + that overrides this signal). + """ + tmp_path = pdd_test_environment + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir(exist_ok=True) + + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_hash = create_file(prompt_path, "# Test prompt") + + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) + code_hash = create_file(paths['code'], "def foo(): pass") + example_hash = create_file(paths['example'], "foo()") + test_hash = create_file(paths['test'], "def test_foo(): pass") + create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.81", - "timestamp": "2025-12-12T00:39:11.061591+00:00", - "command": "verify", + "pdd_version": "0.0.156", + "timestamp": "2026-02-23T00:00:00Z", + "command": "test", "prompt_hash": prompt_hash, "code_hash": code_hash, "example_hash": example_hash, - "test_hash": test_hash, + "test_hash": test_hash }) - mock_paths = { - 'prompt': prompt_path, - 'code': code_path, - 'example': example_path, - 'test': test_path, - } + # Run report: tests pass, exit_code=0, but coverage is 0.0 + create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { + "timestamp": "2026-02-23T00:01:00Z", + "exit_code": 0, + "tests_passed": 62, + "tests_failed": 0, + "coverage": 0.0, + "test_hash": test_hash + }) - with patch('sync_determine_operation.construct_paths') as mock_construct, \ - patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: - mock_construct.return_value = ( - {'prompt_file': str(prompt_path)}, - {'output': str(code_path)}, - {'output': str(test_path)}, - {'output': str(example_path)} - ) - mock_get_paths.return_value = mock_paths + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) - decision = sync_determine_operation( - basename=BASENAME, - language=LANGUAGE, - target_coverage=TARGET_COVERAGE, - prompts_dir="prompts", - skip_tests=False, - skip_verify=False, - ) + # The detection side should correctly identify low coverage and return + # test_extend. This test validates that sync_determine_operation catches + # the problem even though the orchestration layer currently overrides it. + assert decision.operation == 'test_extend', ( + f"Expected 'test_extend' for coverage=0.0 with passing tests, " + f"got '{decision.operation}'. sync_determine_operation should detect " + f"that coverage 0.0 < target {TARGET_COVERAGE} and request test extension." + ) - # FIX: When run_report is stale, sync returns 'test' to re-validate - assert decision.operation != 'nothing', ( - f"Sync returned 'nothing' with stale run_report!\n" - f"Expected: 'test' to re-validate\n" - f"Actual: '{decision.operation}' - {decision.reason}" + +# --- GitHub Issue #522: Fingerprint ignores dependencies --- + +class TestFingerprintIncludeDependencies: + """ + Regression tests for GitHub issue #522: sync fingerprint ignores + dependencies. When an included file changes but the top-level .prompt file + doesn't, sync should detect the change and regenerate. + + Approach 2: Store include dependency paths + hashes in the fingerprint JSON + so changes are detected even after auto-deps strips tags. + """ + + def test_extract_include_deps_finds_xml_includes(self, pdd_test_environment): + """extract_include_deps should find tags and hash the files.""" + prompts_dir = pdd_test_environment / "prompts" + dep_file = pdd_test_environment / "shared_types.py" + create_file(dep_file, "class User:\n name: str\n") + + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, "Create a helper.\nshared_types.py\n") + + deps = extract_include_deps(prompt_path) + assert len(deps) == 1, f"Expected 1 include dep, got {len(deps)}" + assert str(dep_file) in deps or any("shared_types.py" in k for k in deps) + + @pytest.mark.parametrize( + "attributed_include", + [ + 'utils.py', + 'utils.py', + 'utils.py', + ], + ids=["select-attr", "query-attr", "select-plus-mode"], + ) + def test_extract_include_deps_finds_attributed_includes( + self, pdd_test_environment, attributed_include + ): + """extract_include_deps must match attributed forms. + + ``auto_include`` emits ```` and + ```` directives; if the extractor's regex only + matched bare ```` the fingerprint would lose those deps + and dependency changes would go undetected by sync. + """ + prompts_dir = pdd_test_environment / "prompts" + dep_file = pdd_test_environment / "utils.py" + create_file(dep_file, "def helper(): pass\n") + + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, f"Build module.\n{attributed_include}\n") + + deps = extract_include_deps(prompt_path) + assert len(deps) == 1, ( + f"Expected 1 dep for {attributed_include!r}, got {deps!r}" + ) + assert any("utils.py" in k for k in deps), ( + f"Expected utils.py in deps keys, got {list(deps)}" ) - def test_workflow_not_complete_when_run_report_is_stale(self, pdd_test_environment): - """ - _is_workflow_complete() should return False when run_report is stale. + def test_extract_include_deps_finds_backtick_includes(self, pdd_test_environment): + """extract_include_deps should find `````` backtick includes.""" + prompts_dir = pdd_test_environment / "prompts" + dep_file = pdd_test_environment / "utils.py" + create_file(dep_file, "def helper(): pass\n") - When fingerprint.timestamp > run_report.timestamp, workflow should NOT be complete. - """ - tmp_path = pdd_test_environment + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, "Build module.\n``````\n") - Path("src").mkdir(exist_ok=True) - Path("tests").mkdir(exist_ok=True) - Path("examples").mkdir(exist_ok=True) + deps = extract_include_deps(prompt_path) + assert len(deps) == 1, f"Expected 1 backtick include dep, got {len(deps)}" - code_path = tmp_path / "src" / f"{BASENAME}.py" - code_hash = create_file(code_path, "def foo(): pass") + def test_extract_include_deps_empty_when_no_includes(self, pdd_test_environment): + """extract_include_deps should return empty dict for prompts without includes.""" + prompts_dir = pdd_test_environment / "prompts" + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, "Simple prompt with no includes.\n") - example_path = tmp_path / "examples" / f"{BASENAME}_example.py" - example_hash = create_file(example_path, "# example") + deps = extract_include_deps(prompt_path) + assert deps == {}, f"Expected empty dict, got {deps}" - test_path = tmp_path / "tests" / f"test_{BASENAME}.py" - test_hash = create_file(test_path, "def test_fail(): assert False") + def test_calculate_prompt_hash_with_stored_deps(self, pdd_test_environment): + """calculate_prompt_hash should use stored deps when prompt has no include tags.""" + prompts_dir = pdd_test_environment / "prompts" + dep_file = pdd_test_environment / "shared_types.py" + create_file(dep_file, "class User:\n name: str\n") - paths = { - 'code': code_path, - 'example': example_path, - 'test': test_path - } + # Prompt WITHOUT include tags (simulates post-auto-deps state) + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, "Create a helper using User class.\n") - # STALE run_report (Dec 10) - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-10T08:33:52.589258+00:00", - "exit_code": 0, - "tests_passed": 1, - "tests_failed": 0, - "coverage": 0.0 - }) + stored_deps = {str(dep_file): calculate_sha256(dep_file)} - # NEWER fingerprint (Dec 12) - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.81", - "timestamp": "2025-12-12T00:39:11.061591+00:00", - "command": "verify", - "prompt_hash": "abc123", - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash, - }) + # Hash with stored deps should differ from hash without + hash_without = calculate_prompt_hash(prompt_path) + hash_with = calculate_prompt_hash(prompt_path, stored_deps=stored_deps) - result = _is_workflow_complete( - paths=paths, - skip_tests=False, - skip_verify=False, - basename=BASENAME, - language=LANGUAGE + assert hash_without != hash_with, ( + "Hash with stored deps should differ from hash without — " + "stored deps should contribute to the composite hash" ) - assert result == False, ( - "_is_workflow_complete() returned True with stale run_report.\n" - "run_report.timestamp: 2025-12-10, fingerprint.timestamp: 2025-12-12\n" - "The run_report predates the fingerprint, so workflow should not be complete." + def test_legacy_include_hash_uses_prompt_dir_then_process_cwd( + self, pdd_test_environment, monkeypatch + ): + prompt = pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" + dependency = pdd_test_environment / "shared.py" + create_file(dependency, "VALUE = 1\n") + create_file(prompt, "Build it.\nshared.py\n") + expected = hashlib.sha256(prompt.read_bytes() + dependency.read_bytes()).hexdigest() + hashes = [] + for cwd in (pdd_test_environment, pdd_test_environment.parent): + monkeypatch.chdir(cwd) + hashes.append(calculate_prompt_hash(prompt)) + assert hashes == [expected, hashlib.sha256(prompt.read_bytes()).hexdigest()] + + def test_legacy_include_hash_sorts_and_deduplicates_dependencies( + self, pdd_test_environment + ): + prompt = pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" + first = pdd_test_environment / "a.py" + second = pdd_test_environment / "b.py" + create_file(first, "A = 1\n") + create_file(second, "B = 1\n") + create_file( + prompt, + "Build.\nb.py\na.py\n" + "a.py\n", ) + expected = hashlib.sha256( + prompt.read_bytes() + first.read_bytes() + second.read_bytes() + ).hexdigest() + assert calculate_prompt_hash(prompt, hash_version=1) == expected - def test_run_report_tests_failed_triggers_fix(self, pdd_test_environment): - """ - Sanity check: When run_report.tests_failed > 0, sync should return 'fix'. - """ - tmp_path = pdd_test_environment + def test_legacy_v1_preserves_pre_versioned_include_grammar_and_missing_files( + self, pdd_test_environment + ): + prompt = pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" + body_dep = prompt.parent / "body.py" + attr_dep = prompt.parent / "attribute.py" + create_file(body_dep, "BODY = 1\n") + create_file(attr_dep, "ATTRIBUTE = 1\n") + create_file( + prompt, + "body.py\n" + "\n" + "*.py\n" + "missing.py\n", + ) + expected = hashlib.sha256(prompt.read_bytes() + body_dep.read_bytes()).hexdigest() + assert calculate_prompt_hash(prompt, hash_version=1) == expected - Path("src").mkdir(exist_ok=True) - Path("tests").mkdir(exist_ok=True) - Path("examples").mkdir(exist_ok=True) + def test_legacy_v1_stored_dependencies_skip_missing_and_keep_key_order( + self, pdd_test_environment + ): + prompt = pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" + first = pdd_test_environment / "a.py" + second = pdd_test_environment / "b.py" + create_file(prompt, "No includes.\n") + create_file(first, "A = 1\n") + create_file(second, "B = 1\n") + stored = {str(second): "old", str(pdd_test_environment / "missing.py"): "old", str(first): "old"} + expected = hashlib.sha256(prompt.read_bytes() + first.read_bytes() + second.read_bytes()).hexdigest() + assert calculate_prompt_hash(prompt, stored_deps=stored, hash_version=1) == expected - prompt_path = tmp_path / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# prompt") + def test_calculate_prompt_hash_detects_dep_change_via_stored_deps(self, pdd_test_environment): + """When a stored dep file changes, the composite hash must change.""" + prompts_dir = pdd_test_environment / "prompts" + dep_file = pdd_test_environment / "shared_types.py" + create_file(dep_file, "class User:\n name: str\n") - code_path = tmp_path / "src" / f"{BASENAME}.py" - code_hash = create_file(code_path, "def foo(): pass") + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, "Create a helper using User class.\n") - example_path = tmp_path / "examples" / f"{BASENAME}_example.py" - example_hash = create_file(example_path, "# example") + stored_deps = {str(dep_file): calculate_sha256(dep_file)} + hash_before = calculate_prompt_hash(prompt_path, stored_deps=stored_deps) - test_path = tmp_path / "tests" / f"test_{BASENAME}.py" - test_hash = create_file(test_path, "def test_fail(): assert False") + # Change the dependency file + create_file(dep_file, "class User:\n name: str\n email: str\n") - # run_report with tests_failed > 0 - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-12T00:00:00.000000+00:00", - "exit_code": 1, - "tests_passed": 10, - "tests_failed": 3, - "coverage": 0.0 - }) + hash_after = calculate_prompt_hash(prompt_path, stored_deps=stored_deps) - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.81", - "timestamp": "2025-12-12T00:39:11.061591+00:00", - "command": "verify", - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash, - }) + assert hash_before != hash_after, ( + "Composite prompt hash must change when a stored dependency file changes, " + "even when the prompt itself has no tags" + ) - mock_paths = { - 'prompt': prompt_path, - 'code': code_path, - 'example': example_path, - 'test': test_path, - } + def test_calculate_prompt_hash_anchors_relative_stored_deps_to_explicit_root( + self, pdd_test_environment, monkeypatch + ): + """Stored relative dependency keys must not be interpreted from process CWD.""" + prompts_dir = pdd_test_environment / "prompts" + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, "Create a helper using project docs.\n") + project_dep = pdd_test_environment / "docs" / "contract.md" + create_file(project_dep, "trusted project dependency\n") - with patch('sync_determine_operation.construct_paths') as mock_construct, \ - patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: - mock_construct.return_value = ( - {'prompt_file': str(prompt_path)}, - {'output': str(code_path)}, - {'output': str(test_path)}, - {'output': str(example_path)} - ) - mock_get_paths.return_value = mock_paths + nested = pdd_test_environment / "nested" + alternate_dep = nested / "docs" / "contract.md" + create_file(alternate_dep, "wrong nested dependency\n") + monkeypatch.chdir(nested) - decision = sync_determine_operation( - basename=BASENAME, - language=LANGUAGE, - target_coverage=TARGET_COVERAGE, - prompts_dir="prompts", - skip_tests=False, - skip_verify=False, - ) + stored_deps = {"docs/contract.md": calculate_sha256(project_dep)} + anchored_hash = calculate_prompt_hash( + prompt_path, + stored_deps=stored_deps, + dependency_root=pdd_test_environment, + ) + create_file(alternate_dep, "changed wrong nested dependency\n") + anchored_hash_after_alternate_change = calculate_prompt_hash( + prompt_path, + stored_deps=stored_deps, + dependency_root=pdd_test_environment, + ) - assert decision.operation == 'fix', ( - f"Expected 'fix' when tests_failed > 0, got '{decision.operation}'" + assert anchored_hash == anchored_hash_after_alternate_change + + def test_fingerprint_stores_include_deps(self, pdd_test_environment): + """Fingerprint dataclass should correctly store and serialize include_deps.""" + fp = Fingerprint( + pdd_version="0.0.156", + timestamp="2026-02-23T00:00:00Z", + command="test", + prompt_hash="abc", + code_hash="def", + example_hash="ghi", + test_hash="jkl", + include_deps={"shared_types.py": "hash1", "utils.py": "hash2"}, ) + from dataclasses import asdict + d = asdict(fp) + assert d["include_deps"] == {"shared_types.py": "hash1", "utils.py": "hash2"} + def test_fingerprint_include_deps_backward_compat(self, pdd_test_environment): + """Old fingerprint files without include_deps should load with include_deps=None.""" + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "0.0.145", + "timestamp": "2026-01-01T00:00:00Z", + "command": "generate", + "prompt_hash": "abc", + "code_hash": "def", + "example_hash": "ghi", + "test_hash": "jkl", + }) -class TestFalsePositiveSuccessBugRegression: - """ - Regression tests for GitHub issue #210: False positive success when skip_verify=True. + fp = read_fingerprint(BASENAME, LANGUAGE) + assert fp is not None + assert fp.include_deps is None, "Old fingerprints should have include_deps=None" - Bug scenario (from core dump): - - User runs sync, 'example' operation runs - - fingerprint saved with command='example' - - run_report created with exit_code=0, tests_failed=0, test_hash=None (from example run, not actual tests) - - User adds new unit tests that would fail - - User runs `pdd sync --skip-verify` - - Sync incorrectly reports success without running tests + def test_fingerprint_with_include_deps_loads_correctly(self, pdd_test_environment): + """Fingerprint with include_deps field should load correctly.""" + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "0.0.156", + "timestamp": "2026-02-23T00:00:00Z", + "command": "generate", + "prompt_hash": "abc", + "code_hash": "def", + "example_hash": "ghi", + "test_hash": "jkl", + "include_deps": {"shared.py": "hash1"}, + }) - Root causes: - 1. run_report.test_hash was None, causing staleness detection to fail - 2. _is_workflow_complete() didn't check if tests were actually run when skip_verify=True - """ + fp = read_fingerprint(BASENAME, LANGUAGE) + assert fp is not None + assert fp.include_deps == {"shared.py": "hash1"} - def test_skip_verify_with_example_command_should_not_be_complete(self, pdd_test_environment): + def test_included_file_change_triggers_regeneration(self, pdd_test_environment): """ - When fingerprint.command='example' and skip_verify=True, workflow should NOT be complete - because tests haven't been run yet. - - This is the core bug: skip_verify=True bypassed the verify check, but the test check - was also effectively bypassed because the code only checked for verify completion. + Primary bug reproduction (Greg's scenario): After auto-deps strips + tags, changing the included file should still trigger regeneration via stored deps. """ - tmp_path = pdd_test_environment - - Path("src").mkdir(exist_ok=True) - Path("tests").mkdir(exist_ok=True) - Path("examples").mkdir(exist_ok=True) + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) - # Create all files - prompt_path = tmp_path / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt") + # Create dependency file + dep_file = pdd_test_environment / "shared_types.py" + create_file(dep_file, "class User:\n def __init__(self, name): self.name = name\n") - code_path = tmp_path / "src" / f"{BASENAME}.py" - code_hash = create_file(code_path, "def foo(): pass") + # Prompt WITH includes (first sync) + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + prompt_content_with_tags = "Create a helper.\nshared_types.py\n" + create_file(prompt_path, prompt_content_with_tags) - example_path = tmp_path / "examples" / f"{BASENAME}_example.py" - example_hash = create_file(example_path, "# example") + # Calculate what hash the first sync would have saved + first_sync_hash = calculate_prompt_hash(prompt_path) + first_sync_deps = extract_include_deps(prompt_path) - test_path = tmp_path / "tests" / f"test_{BASENAME}.py" - test_hash = create_file(test_path, "def test_fail(): assert False # Would fail if run") + # Simulate auto-deps stripping the include tags (rewrites .prompt) + prompt_content_stripped = "Create a helper.\nclass User:\n def __init__(self, name): self.name = name\n" + create_file(prompt_path, prompt_content_stripped) - # Create run_report from example run (not actual tests) - # This mimics what happens after 'crash' check when example passes - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-18T22:00:00.000000+00:00", # Newer than fingerprint - "exit_code": 0, - "tests_passed": 1, # This is from example, not actual unit tests - "tests_failed": 0, - "coverage": 0.0 - # test_hash is None (missing) - this was part of the bug + # Create fingerprint from "first sync" with stored deps + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "0.0.156", + "timestamp": "2026-02-23T00:00:00Z", + "command": "test", + "prompt_hash": first_sync_hash, + "code_hash": None, + "example_hash": None, + "test_hash": None, + "include_deps": first_sync_deps, }) - # Create fingerprint with command='example' (tests haven't been run) - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.86", - "timestamp": "2025-12-18T21:59:51.000000+00:00", # Older than run_report - "command": "example", # Key: this is NOT 'test', 'fix', or 'update' - "prompt_hash": prompt_hash, + # Create code/example/test files + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") + code_hash = create_file(paths['code'], "def helper(): pass") + example_hash = create_file(paths['example'], "helper()") + test_hash = create_file(paths['test'], "def test_helper(): pass") + + # Update fingerprint with file hashes + create_fingerprint_file(fp_path, { + "pdd_version": "0.0.156", + "timestamp": "2026-02-23T00:00:00Z", + "command": "test", + "prompt_hash": first_sync_hash, "code_hash": code_hash, "example_hash": example_hash, "test_hash": test_hash, + "include_deps": first_sync_deps, }) - mock_paths = { - 'prompt': prompt_path, - 'code': code_path, - 'example': example_path, - 'test': test_path, - } + # Create passing run report + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "2026-02-23T00:00:00Z", + "exit_code": 0, + "tests_passed": 5, + "tests_failed": 0, + "coverage": 95.0, + }) - # Test _is_workflow_complete directly with skip_verify=True - result = _is_workflow_complete( - paths=mock_paths, - skip_tests=False, - skip_verify=True, # Key: skip_verify=True but tests should still be required - basename=BASENAME, - language=LANGUAGE - ) + # NOW change the included file (this is the bug trigger) + create_file(dep_file, "class User:\n def __init__(self, name, age, email): pass\n") - assert result == False, ( - "_is_workflow_complete() returned True with skip_verify=True and command='example'.\n" - "Bug: Tests haven't been run yet (fingerprint.command='example'), " - "but workflow was considered complete.\n" - "Expected: False (tests need to run)" + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) ) - def test_sync_returns_test_operation_when_tests_not_run(self, pdd_test_environment): - """ - When skip_verify=True but tests haven't been run, sync should return 'test' or 'crash' - operation, NOT 'nothing'. - - This reproduces the exact scenario from GitHub issue #210. - """ - tmp_path = pdd_test_environment - - Path("src").mkdir(exist_ok=True) - Path("tests").mkdir(exist_ok=True) - Path("examples").mkdir(exist_ok=True) + assert decision.operation in ('generate', 'auto-deps'), ( + f"Expected 'generate' or 'auto-deps' because included file changed " + f"(via stored deps), but got '{decision.operation}'. " + f"Stored include_deps in fingerprint must detect dependency changes " + f"even when auto-deps has stripped tags from the prompt." + ) - # Create all files - prompt_path = tmp_path / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt") + def test_no_change_no_false_positive_with_stored_deps(self, pdd_test_environment): + """When nothing changes, stored deps must not cause false positive regeneration.""" + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) - code_path = tmp_path / "src" / f"{BASENAME}.py" - code_hash = create_file(code_path, "def foo(): pass") + dep_file = pdd_test_environment / "shared_types.py" + create_file(dep_file, "class User:\n pass\n") - example_path = tmp_path / "examples" / f"{BASENAME}_example.py" - example_hash = create_file(example_path, "# example") + # Prompt WITH includes + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, "Create a helper.\nshared_types.py\n") - test_path = tmp_path / "tests" / f"test_{BASENAME}.py" - test_hash = create_file(test_path, "def test_fail(): assert False") + prompt_hash = calculate_prompt_hash(prompt_path) + include_deps = extract_include_deps(prompt_path) - # Create run_report mimicking example success (not actual tests) - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-18T22:00:00.000000+00:00", - "exit_code": 0, - "tests_passed": 1, - "tests_failed": 0, - "coverage": 0.0 - }) + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") + code_hash = create_file(paths['code'], "def helper(): pass") + example_hash = create_file(paths['example'], "helper()") + test_hash = create_file(paths['test'], "def test_helper(): pass") - # Fingerprint with command='example' - tests never ran - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.86", - "timestamp": "2025-12-18T21:59:51.000000+00:00", - "command": "example", + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "0.0.156", + "timestamp": "2026-02-23T00:00:00Z", + "command": "test", "prompt_hash": prompt_hash, "code_hash": code_hash, "example_hash": example_hash, "test_hash": test_hash, + "include_deps": include_deps, }) - mock_paths = { - 'prompt': prompt_path, - 'code': code_path, - 'example': example_path, - 'test': test_path, - } - - with patch('sync_determine_operation.construct_paths') as mock_construct, \ - patch('sync_determine_operation.get_pdd_file_paths') as mock_get_paths: - mock_construct.return_value = ( - {'prompt_file': str(prompt_path)}, - {'output': str(code_path)}, - {'output': str(test_path)}, - {'output': str(example_path)} - ) - mock_get_paths.return_value = mock_paths - - decision = sync_determine_operation( - basename=BASENAME, - language=LANGUAGE, - target_coverage=TARGET_COVERAGE, - prompts_dir="prompts", - skip_tests=False, - skip_verify=True, # Skip verify but tests should still run - ) + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "2026-02-23T00:00:00Z", + "exit_code": 0, + "tests_passed": 5, + "tests_failed": 0, + "coverage": 95.0, + }) - assert decision.operation != 'nothing', ( - f"Bug reproduced: Sync returned 'nothing' with skip_verify=True and command='example'.\n" - f"Expected: 'crash', 'verify', or 'test' (workflow should continue)\n" - f"Actual: '{decision.operation}' - {decision.reason}\n" - f"This is GitHub issue #210: False positive success" + # Nothing changed + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) ) - # Should be either 'crash' (to validate example), 'verify' (if not skipped), 'test', or 'test_extend' - assert decision.operation in ['crash', 'verify', 'test', 'test_extend'], ( - f"Expected 'crash', 'verify', 'test', or 'test_extend' to continue workflow, got '{decision.operation}'" + assert decision.operation not in ('generate', 'auto-deps'), ( + f"Expected no regeneration when nothing changed, got '{decision.operation}'. " + f"Stored include_deps must not cause false positives." ) + def test_one_of_multiple_deps_changes(self, pdd_test_environment): + """When one of multiple stored deps changes, sync should detect it.""" + prompts_dir = pdd_test_environment / "prompts" + prompts_dir.mkdir(exist_ok=True) -# --- Bug Fix Tests: Prompt Changes Should Take Priority Over Runtime Signals --- - -def test_prompt_change_detected_even_after_crash_workflow(pdd_test_environment): - """ - BUG: When fingerprint.command == 'crash' and run_report.exit_code == 0, - the sync should still detect prompt changes and trigger regeneration, - not blindly continue to 'verify'. + dep1 = pdd_test_environment / "types.py" + dep2 = pdd_test_environment / "utils.py" + create_file(dep1, "class Foo: pass") + create_file(dep2, "def bar(): pass") - This tests the fix for: prompt changes being ignored when runtime signals - cause early returns in sync_determine_operation. - """ - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) + prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" + create_file(prompt_path, "Build module.\ntypes.py\nutils.py\n") - # Create prompt with NEW content (different from fingerprint hash) - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - new_prompt_hash = create_file(prompt_path, "NEW PROMPT CONTENT - changed!") + prompt_hash = calculate_prompt_hash(prompt_path) + include_deps = extract_include_deps(prompt_path) - # Create code/example/test files first so the fingerprint represents a - # prompt-only change rather than a prompt+derived conflict. - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") - code_hash = create_file(paths['code'], "def add(a, b): return a + b") - example_hash = create_file(paths['example'], "print(add(1, 2))") - test_hash = create_file(paths['test'], "def test_add(): assert add(1, 2) == 3") + paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") + code_hash = create_file(paths['code'], "def module(): pass") + example_hash = create_file(paths['example'], "module()") + test_hash = create_file(paths['test'], "def test_module(): pass") - # Create fingerprint with OLD prompt hash and command='crash' - old_prompt_hash = "old_hash_that_differs_from_current" - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, { - "pdd_version": "1.0.0", - "timestamp": "2025-01-01T00:00:00Z", - "command": "crash", # Previous command was crash - "prompt_hash": old_prompt_hash, # Different from current! - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) + fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" + create_fingerprint_file(fp_path, { + "pdd_version": "0.0.156", + "timestamp": "2026-02-23T00:00:00Z", + "command": "test", + "prompt_hash": prompt_hash, + "code_hash": code_hash, + "example_hash": example_hash, + "test_hash": test_hash, + "include_deps": include_deps, + }) - # Create run report showing crash fix succeeded - rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" - create_run_report_file(rr_path, { - "timestamp": "2025-01-01T00:00:00Z", - "exit_code": 0, # Success - crash was fixed - "tests_passed": 1, - "tests_failed": 0, - "coverage": 95.0 - }) + rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + create_run_report_file(rr_path, { + "timestamp": "2026-02-23T00:00:00Z", + "exit_code": 0, + "tests_passed": 1, + "tests_failed": 0, + "coverage": 90.0, + }) - decision = sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE) + # Change only dep2 + create_file(dep2, "def bar(): return 42") - # Should detect prompt change and regenerate, NOT continue to verify - assert decision.operation in ('generate', 'auto-deps'), \ - f"Expected 'generate' or 'auto-deps' due to prompt change, got '{decision.operation}'" - assert 'prompt' in decision.reason.lower(), \ - f"Reason should mention prompt change: {decision.reason}" + decision = sync_determine_operation( + BASENAME, LANGUAGE, TARGET_COVERAGE, + prompts_dir=str(prompts_dir) + ) + assert decision.operation in ('generate', 'auto-deps'), ( + f"Expected regeneration when one of multiple included files changed, " + f"got '{decision.operation}'." + ) -def test_prompt_change_progresses_generate_verify_test_then_complete(pdd_test_environment): - """ - Regression guard for prompt-only changes: sync should regenerate first, then - require verify, then require test before considering the workflow complete. - """ - tmp_path = pdd_test_environment - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - old_prompt_hash = create_file(prompt_path, "ORIGINAL PROMPT CONTENT") +# --------------------------------------------------------------------------- +# Bug: _generate_paths_from_templates missing 'code' fallback (#826) +# --------------------------------------------------------------------------- - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) - code_hash = create_file(paths['code'], "def add(a, b):\n return a + b\n") - example_hash = create_file(paths['example'], "print(add(1, 2))\n") - test_hash = create_file(paths['test'], "def test_add():\n assert add(1, 2) == 3\n") +class TestGeneratePathsFromTemplatesCodeFallback: + """When .pddrc outputs config defines 'prompt' but not 'code', the returned + dict must still have a 'code' key. Otherwise sync_orchestration crashes with + KeyError: 'code'. - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" + Regression test for promptdriven/example_app#826: the frontend catch-all + context has outputs.prompt but no outputs.code, causing page syncs to crash. + """ - # Start from a fully completed workflow for the old prompt version. - create_fingerprint_file(fp_path, { - "pdd_version": "1.0.0", - "timestamp": "2025-01-01T00:00:00Z", - "command": "test", - "prompt_hash": old_prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) - create_run_report_file(rr_path, { - "timestamp": "2025-01-01T00:01:00Z", - "exit_code": 0, - "tests_passed": 5, - "tests_failed": 0, - "coverage": 95.0, - "test_hash": test_hash - }) + def test_code_key_always_present(self): + """_generate_paths_from_templates must return a 'code' key even when + outputs config only defines 'prompt'.""" + from pdd.sync_determine_operation import _generate_paths_from_templates - # User edits only the prompt. Sync should restart with generation. - new_prompt_hash = create_file(prompt_path, "UPDATED PROMPT CONTENT") - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) - assert decision.operation == 'generate', ( - f"Expected prompt-only change to restart at 'generate', got '{decision.operation}'" - ) + outputs_config = { + "prompt": {"path": "prompts/frontend/{dir_prefix}{name}_{language}.prompt"}, + # NOTE: no 'code' output defined — this is the bug trigger + } + result = _generate_paths_from_templates( + basename="app/dashboard/page", + language="typescriptreact", + extension="tsx", + outputs_config=outputs_config, + prompt_path="prompts/frontend/app/dashboard/page_TypescriptReact.prompt", + ) - # Simulate successful generate: hashes now match the new prompt, but verify has - # not run for this generation yet. - create_fingerprint_file(fp_path, { - "pdd_version": "1.0.0", - "timestamp": "2025-01-01T00:02:00Z", - "command": "generate", - "prompt_hash": new_prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) - assert decision.operation == 'verify', ( - f"Expected post-generate sync to require 'verify', got '{decision.operation}'" - ) + assert "code" in result, ( + f"'code' key missing from result: {list(result.keys())}. " + "sync_orchestration accesses pdd_files['code'] directly and will " + "crash with KeyError if this key is absent." + ) + assert "page" in str(result["code"]), ( + f"Code path should contain the module name 'page', got: {result['code']}" + ) - # Simulate successful verify: sync should still require tests before completion. - create_fingerprint_file(fp_path, { - "pdd_version": "1.0.0", - "timestamp": "2025-01-01T00:03:00Z", - "command": "verify", - "prompt_hash": new_prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) - create_run_report_file(rr_path, { - "timestamp": "2025-01-01T00:03:30Z", - "exit_code": 0, - "tests_passed": 5, - "tests_failed": 0, - "coverage": 95.0, - "test_hash": test_hash - }) - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) - assert decision.operation == 'test', ( - f"Expected post-verify sync to require 'test', got '{decision.operation}'" - ) + def test_code_key_present_with_generate_output_path(self): + """When generate_output_path is available, use it for the code fallback.""" + from pdd.sync_determine_operation import _generate_paths_from_templates - # After tests complete successfully for the new prompt version, sync is done. - create_fingerprint_file(fp_path, { - "pdd_version": "1.0.0", - "timestamp": "2025-01-01T00:04:00Z", - "command": "test", - "prompt_hash": new_prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) - create_run_report_file(rr_path, { - "timestamp": "2025-01-01T00:04:30Z", - "exit_code": 0, - "tests_passed": 5, - "tests_failed": 0, - "coverage": 95.0, - "test_hash": test_hash - }) - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) - assert decision.operation == 'nothing', ( - f"Expected workflow to be complete after test succeeds, got '{decision.operation}'" - ) + outputs_config = { + "prompt": {"path": "prompts/frontend/{dir_prefix}{name}_{language}.prompt"}, + } + result = _generate_paths_from_templates( + basename="app/dashboard/page", + language="typescriptreact", + extension="tsx", + outputs_config=outputs_config, + prompt_path="prompts/frontend/app/dashboard/page_TypescriptReact.prompt", + ) + assert "code" in result + # Should use dir_prefix + name pattern + code_str = str(result["code"]) + assert "page.tsx" in code_str, f"Expected page.tsx in code path, got: {code_str}" -# --- GitHub Issue #349: Infinite Loop Bug Tests --- -class TestInfiniteLoopBugIssue349: - """ - Regression tests for GitHub issue #349: PDD sync doesn't exit fix loop when fix completed. +# ============================================================================= +# Issue #1048: Glob patterns must escape brackets in basenames +# ============================================================================= - Bug scenario: - - Tests pass successfully (tests_passed > 0, tests_failed == 0) - - But pytest returns non-zero exit code (e.g., from pytest-cov warnings) - - Current code sees exit_code != 0 and returns 'fix' or 'crash' - - This creates an infinite loop: fix completes, tests pass, but exit_code != 0 triggers another fix - Root causes identified: - 1. sync_determine_operation checks exit_code != 0 BEFORE checking test results - 2. _is_workflow_complete returns False when exit_code != 0, even if tests pass - 3. No handling for "tests pass but tooling returned non-zero exit code" scenario +class TestIssue1048GlobEscapingInDetermineOperation: + """Tests that glob patterns in sync_determine_operation correctly handle + bracket characters in basenames by using glob.escape().""" - Fix approach: - - When tests_passed > 0 and tests_failed == 0, treat as success regardless of exit_code - - exit_code != 0 with passing tests indicates tooling issues (pytest-cov, etc.), not test failures - """ + def test_check_example_success_history_with_bracket_basename(self, tmp_path): + """_check_example_success_history glob pattern must escape brackets in _safe_basename output. - def test_nonzero_exit_code_with_passing_tests_should_not_trigger_fix(self, pdd_test_environment): + Bug: _safe_basename('frontend/[id]') -> 'frontend_[id]', then + meta_dir.glob('frontend_[id]_python_run*.json') interprets [id] as char class. """ - Core bug test: When tests pass (tests_passed > 0, tests_failed == 0) but exit_code != 0, - should NOT return 'fix' or 'crash' - workflow should consider tests as passing. + from sync_determine_operation import _check_example_success_history, _safe_basename - This reproduces the infinite loop from issue #349 where pytest-cov or other tooling - returns non-zero exit code even when all tests pass. - """ - tmp_path = pdd_test_environment - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir(exist_ok=True) + assert _safe_basename("frontend/[id]") == "frontend_[id]" - # Create all required files - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt for issue 349") + meta_dir = tmp_path / ".pdd" / "meta" + meta_dir.mkdir(parents=True, exist_ok=True) - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) - code_hash = create_file(paths['code'], "def add(a, b): return a + b") - example_hash = create_file(paths['example'], "print(add(1, 2))") - test_hash = create_file(paths['test'], "def test_add(): assert add(1, 2) == 3") + report_file = meta_dir / "frontend_[id]_python_run_001.json" + report_file.write_text('{"exit_code": 0}') - # Create fingerprint indicating 'fix' was the last operation - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.126", - "timestamp": "2025-12-20T10:00:00.000000+00:00", - "command": "fix", # Last command was fix - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) + with patch("sync_determine_operation.get_meta_dir", return_value=meta_dir), \ + patch("sync_determine_operation.read_fingerprint", return_value=None), \ + patch("sync_determine_operation.read_run_report", return_value=None): - # Create run_report showing: - # - tests_passed > 0: tests actually passed - # - tests_failed == 0: no test failures - # - exit_code != 0: but pytest returned non-zero (e.g., pytest-cov warning) - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-20T10:01:00.000000+00:00", - "exit_code": 1, # Non-zero exit code (e.g., from pytest-cov) - "tests_passed": 5, # Tests actually passed! - "tests_failed": 0, # No failures! - "coverage": 95.0, - "test_hash": test_hash - }) + result = _check_example_success_history("frontend/[id]", "python") - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) + assert result is True, \ + "Bug #1048: _check_example_success_history can't find run report because " \ + "glob interprets [id] as character class matching 'i' or 'd'" - # Bug: Currently returns 'fix' or 'crash' because exit_code != 0 - # Expected: Should return 'nothing' or 'all_synced' because tests actually pass - assert decision.operation not in ('fix', 'crash'), ( - f"BUG #349: exit_code=1 with passing tests should NOT trigger '{decision.operation}'.\n" - f"tests_passed=5, tests_failed=0, exit_code=1 (from tooling, not test failures)\n" - f"This causes infinite loop: fix completes → tests pass → exit_code != 0 → fix again\n" - f"Got reason: {decision.reason}" - ) + def test_get_pdd_file_paths_primary_test_glob_with_brackets(self, tmp_path, monkeypatch): + """get_pdd_file_paths primary path: test glob must escape brackets in name_part. - def test_is_workflow_complete_with_passing_tests_and_nonzero_exit_code(self, pdd_test_environment): - """ - Test _is_workflow_complete directly: should return True when tests pass - even if exit_code is non-zero (tooling issue, not test failure). + Bug: _extract_name_part('[id]') returns ('', '[id]'), then + test_dir.glob('test_[id]*.py') interprets [id] as char class. + The fallback returns [test_path] (1 file) masking the glob failure. + We create 2 test files so only proper glob finds both. """ - tmp_path = pdd_test_environment + monkeypatch.chdir(tmp_path) + prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir(exist_ok=True) + prompts_dir.mkdir() + (prompts_dir / "[id]_python.prompt").write_text("# prompt") - # Create files - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt") + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + # Create TWO matching test files — fallback only returns 1 + test_file_1 = tests_dir / "test_[id].py" + test_file_1.write_text("def test_1(): pass") + test_file_2 = tests_dir / "test_[id]_extra.py" + test_file_2.write_text("def test_2(): pass") - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) - code_hash = create_file(paths['code'], "def foo(): pass") - example_hash = create_file(paths['example'], "foo()") - test_hash = create_file(paths['test'], "def test_foo(): pass") + with patch("sync_determine_operation.construct_paths") as mock_cp: + def side_effect(*args, **kwargs): + cmd = kwargs.get("command", "sync") + if cmd == "test": + return ( + {"prompts_dir": str(prompts_dir), "tests_dir": str(tests_dir)}, + {"prompt_file": "content"}, + {"output": str(tests_dir / "test_[id].py")}, + "python", + ) + return ( + {"prompts_dir": str(prompts_dir), "tests_dir": str(tests_dir)}, + {"prompt_file": "content"}, + { + "output": str(tmp_path / "src" / "[id].py"), + "generate_output_path": str(tmp_path / "src" / "[id].py"), + "test_output_path": str(tests_dir / "test_[id].py"), + "example_output_path": str(tmp_path / "examples" / "[id]_example.py"), + }, + "python", + ) + mock_cp.side_effect = side_effect - # Fingerprint shows workflow completed 'fix' or 'test' - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.126", - "timestamp": "2025-12-20T10:00:00.000000+00:00", - "command": "fix", - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) + result = get_pdd_file_paths("[id]", "python", prompts_dir=str(prompts_dir)) - # Run report: tests pass but exit_code != 0 - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-20T10:01:00.000000+00:00", - "exit_code": 1, # Pytest-cov or similar returned non-zero - "tests_passed": 10, # But tests passed! - "tests_failed": 0, - "coverage": 90.0, - "test_hash": test_hash - }) + test_files = result.get("test_files", []) + test_file_names = [Path(f).name for f in test_files] - result = _is_workflow_complete( - paths=paths, - skip_tests=False, - skip_verify=False, - basename=BASENAME, - language=LANGUAGE - ) + # Both test files should be found by glob. The fallback only returns 1. + assert "test_[id].py" in test_file_names, \ + f"Bug #1048: glob missed test_[id].py. Found: {test_file_names}" + assert "test_[id]_extra.py" in test_file_names, \ + f"Bug #1048: glob missed test_[id]_extra.py because [id] was treated as char class " \ + f"(fallback masked the bug by returning only test_path). Found: {test_file_names}" - # Bug: Currently returns False because exit_code != 0 - # Expected: Should return True because tests_passed > 0 and tests_failed == 0 - assert result == True, ( - "BUG #349: _is_workflow_complete() returns False when exit_code=1 with passing tests.\n" - "This causes the infinite loop: workflow never considered 'complete' despite tests passing.\n" - "tests_passed=10, tests_failed=0, exit_code=1 (tooling issue)" - ) + def test_get_pdd_file_paths_fallback_glob_with_brackets(self, tmp_path, monkeypatch): + """get_pdd_file_paths exception fallback: test glob must also escape brackets. - def test_zero_exit_code_zero_tests_passed_should_trigger_action(self, pdd_test_environment): + With construct_paths raising, the fallback globs in CWD. + We create 2 test files to detect the glob failure (fallback returns only 1). """ - Regression guard: When exit_code=0 but tests_passed=0, should NOT consider workflow complete - with 'nothing' - but 'all_synced' is acceptable for unparseable test output scenarios. - This ensures we don't infinite loop but also don't silently skip tests. + monkeypatch.chdir(tmp_path) + + # Create TWO test files in CWD + test_file_1 = tmp_path / "test_[id].py" + test_file_1.write_text("def test_1(): pass") + test_file_2 = tmp_path / "test_[id]_extra.py" + test_file_2.write_text("def test_2(): pass") + + with patch("sync_determine_operation.construct_paths", side_effect=Exception("force fallback")): + result = get_pdd_file_paths("[id]", "python", prompts_dir=str(tmp_path)) + + test_files = result.get("test_files", []) + test_file_names = [Path(f).name for f in test_files] + + assert "test_[id]_extra.py" in test_file_names, \ + f"Bug #1048: fallback glob missed test_[id]_extra.py because [id] was treated as char class. Found: {test_file_names}" + + def test_get_pdd_file_paths_prompt_missing_glob_with_brackets(self, tmp_path, monkeypatch): + """get_pdd_file_paths prompt-missing fallback: test glob must escape brackets. + + When prompt doesn't exist on disk, the function still tries to find test files. + We create 2 test files to detect the glob failure. """ - tmp_path = pdd_test_environment - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir(exist_ok=True) + monkeypatch.chdir(tmp_path) + + # NO prompt file exists — triggers the prompt-missing path + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + test_file_1 = tests_dir / "test_[id].py" + test_file_1.write_text("def test_1(): pass") + test_file_2 = tests_dir / "test_[id]_extra.py" + test_file_2.write_text("def test_2(): pass") + + with patch("sync_determine_operation.construct_paths") as mock_cp: + mock_cp.return_value = ( + {"prompts_dir": str(tmp_path / "prompts"), "tests_dir": str(tests_dir)}, + {"prompt_file": "content"}, + { + "generate_output_path": str(tmp_path / "src" / "[id].py"), + "test_output_path": str(tests_dir / "test_[id].py"), + "example_output_path": str(tmp_path / "examples" / "[id]_example.py"), + }, + "python", + ) + + result = get_pdd_file_paths("[id]", "python", prompts_dir=str(tmp_path / "prompts")) + + test_files = result.get("test_files", []) + test_file_names = [Path(f).name for f in test_files] - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt") + assert "test_[id]_extra.py" in test_file_names, \ + f"Bug #1048: prompt-missing glob missed test_[id]_extra.py. Found: {test_file_names}" - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) - code_hash = create_file(paths['code'], "def foo(): pass") - example_hash = create_file(paths['example'], "foo()") - test_hash = create_file(paths['test'], "def test_foo(): pass") - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.126", - "timestamp": "2025-12-20T10:00:00.000000+00:00", - "command": "fix", - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) +# ============================================================================ +# Issue #1169: get_pdd_file_paths fails for nested subdirectories + case mismatch +# ============================================================================ - # exit_code=0 but no tests actually ran (tests_passed=0) - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-20T10:01:00.000000+00:00", - "exit_code": 0, # Success exit code - "tests_passed": 0, # But no tests passed - suspicious! - "tests_failed": 0, - "coverage": 0.0, - "test_hash": test_hash - }) +from pdd.sync_determine_operation import ( + _case_insensitive_path_lookup, + _resolve_prompt_path_from_architecture, +) - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) - # When exit_code=0 and tests_passed=0 and tests_failed=0, this indicates unparseable output - # The fix accepts this as 'all_synced' to prevent infinite test loops (especially for non-Python) - # We just ensure it doesn't return 'nothing' (the hashes-match shortcut path) - assert decision.operation != 'nothing', ( - f"exit_code=0 but tests_passed=0 should not use 'nothing' shortcut, got '{decision.operation}'\n" - f"No tests ran successfully, so workflow needs attention." - ) +def test_case_insensitive_path_lookup_returns_on_disk_casing_when_alias_exists(tmp_path, monkeypatch): + """Case-insensitive filesystems must not preserve the caller's wrong casing.""" + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir() + actual_prompt = prompts_dir / "api_pipeline_compositions_route_TypeScript.prompt" + actual_prompt.write_text("Generate API route") + lower_candidate = prompts_dir / "api_pipeline_compositions_route_typescript.prompt" - def test_unparseable_test_output_for_non_python_accepts_as_complete(self, pdd_test_environment): - """ - Bug fix test: For non-Python languages, when test output can't be parsed - (tests_passed=0, tests_failed=0, coverage=0.0 but exit_code=0), - accept as complete rather than infinitely regenerating tests. + original_exists = Path.exists - This was the root cause of the TypeScript infinite loop bug: - 1. pdd sync generates TypeScript tests - 2. npm test runs, exits with 0 (success) - 3. Output parsing fails to extract test counts (tests_passed=0, tests_failed=0) - 4. sync_determine_operation sees coverage=0.0 < target and returns 'test' - 5. Loop repeats infinitely - """ - tmp_path = pdd_test_environment - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir(exist_ok=True) + def fake_exists(path): + if path == lower_candidate: + return True + return original_exists(path) - # Use TypeScript for this test - basename = "prisma_client" - language = "typescript" + monkeypatch.setattr(Path, "exists", fake_exists) - prompt_path = prompts_dir / f"{basename}_{language}.prompt" - prompt_hash = create_file(prompt_path, "// TypeScript prompt") + assert _case_insensitive_path_lookup(lower_candidate) == actual_prompt - paths = get_pdd_file_paths(basename, language, prompts_dir=str(prompts_dir)) - code_hash = create_file(paths['code'], "export const foo = () => 'hello';") - example_hash = create_file(paths['example'], "import { foo } from './code'; console.log(foo());") - test_hash = create_file(paths['test'], "test('foo', () => { expect(foo()).toBe('hello'); });") - create_fingerprint_file(get_meta_dir() / f"{basename}_{language}.json", { - "pdd_version": "0.0.126", - "timestamp": "2025-12-20T10:00:00.000000+00:00", - "command": "test", # Last command was test - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) +def test_get_pdd_file_paths_preserves_existing_mixed_case_prompt_suffix(tmp_path, monkeypatch): + """CI drift path should return the Git-tracked mixed-case prompt path.""" + monkeypatch.chdir(tmp_path) - # Simulates unparseable test output from npm test: - # - exit_code=0 (tests passed) - # - tests_passed=0 (couldn't parse output) - # - tests_failed=0 (couldn't parse output) - # - coverage=0.0 (couldn't parse output) - create_run_report_file(get_meta_dir() / f"{basename}_{language}_run.json", { - "timestamp": "2025-12-20T10:01:00.000000+00:00", - "exit_code": 0, - "tests_passed": 0, - "tests_failed": 0, - "coverage": 0.0, - "test_hash": test_hash - }) + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir() + actual_prompt = prompts_dir / "api_pipeline_compositions_route_TypeScript.prompt" + actual_prompt.write_text("Generate API route") + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) - decision = sync_determine_operation( - basename, language, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) + paths = get_pdd_file_paths( + "api_pipeline_compositions_route", "typescript", "prompts" + ) - # Should NOT return 'test' - that would cause infinite loop - # Should return 'all_synced' because exit_code=0 indicates success - assert decision.operation != 'test', ( - f"BUG: Unparseable test output with exit_code=0 should NOT return 'test', got '{decision.operation}'\n" - f"This causes infinite test generation loop for non-Python languages.\n" - f"Reason: {decision.reason}" - ) - assert decision.operation == 'all_synced', ( - f"Expected 'all_synced' for unparseable but successful tests, got '{decision.operation}'\n" - f"Reason: {decision.reason}" - ) + assert paths["prompt"] == actual_prompt.relative_to(tmp_path) - def test_nonzero_exit_code_with_actual_failures_should_trigger_fix(self, pdd_test_environment): - """ - Regression guard: When exit_code != 0 AND tests_failed > 0, should still trigger fix. - This ensures the bug fix doesn't break legitimate failure handling. - """ - tmp_path = pdd_test_environment - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir(exist_ok=True) - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt") +def test_get_pdd_file_paths_nested_subdir_case_mismatch(tmp_path, monkeypatch): + """Bug #1169: get_pdd_file_paths must find prompts in nested subdirs with case mismatch. - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) - code_hash = create_file(paths['code'], "def foo(): pass") - example_hash = create_file(paths['example'], "foo()") - test_hash = create_file(paths['test'], "def test_foo(): assert False") + Production scenario: prompt at prompts/src/clients/firestore_client_Python.prompt + but get_pdd_file_paths("firestore_client", "python", "prompts") constructs + prompts/firestore_client_python.prompt (wrong case, wrong dir). + """ + monkeypatch.chdir(tmp_path) - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.126", - "timestamp": "2025-12-20T10:00:00.000000+00:00", - "command": "fix", - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) + # Create nested directory structure matching the issue's reproduction + nested_dir = tmp_path / "prompts" / "src" / "clients" + nested_dir.mkdir(parents=True) + actual_prompt = nested_dir / "firestore_client_Python.prompt" + actual_prompt.write_text("Generate Firestore client") - # exit_code != 0 AND tests actually failed - this is a real failure - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2025-12-20T10:01:00.000000+00:00", - "exit_code": 1, # Non-zero exit code - "tests_passed": 3, - "tests_failed": 2, # Actual test failures! - "coverage": 60.0, - "test_hash": test_hash - }) + # Create minimal .pdd structure + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) + paths = get_pdd_file_paths("firestore_client", "python", "prompts") - # Real test failures should still trigger fix - assert decision.operation == 'fix', ( - f"exit_code=1 with tests_failed=2 should trigger 'fix', got '{decision.operation}'\n" - f"Real test failures must still be handled." - ) + # BUG: Current code constructs prompts/firestore_client_python.prompt (wrong case + wrong dir) + # After fix, should resolve to the actual file in the nested subdirectory + assert paths['prompt'].exists(), ( + f"Bug #1169: prompt not found. Got path: {paths['prompt']}. " + f"Expected to find: {actual_prompt}" + ) -# --- Bug #573: _is_workflow_complete accepts coverage=0.0 with passing tests --- +def test_get_pdd_file_paths_architecture_json_nested_subdir(tmp_path, monkeypatch): + """Bug #1169: architecture.json lookup must resolve prompts in nested subdirs. -class TestZeroCoverageBugIssue573: - """ - Regression tests for GitHub issue #573: _is_workflow_complete() returns True - when tests_passed > 0 and tests_failed == 0, even if coverage is 0.0. - This is a defense-in-depth gap — the function should check coverage against - a minimum threshold. + When architecture.json has filename: "firestore_client_Python.prompt", + _resolve_prompt_path_from_architecture naively joins to prompts_root (flat), + missing the actual file in prompts/src/clients/. """ + monkeypatch.chdir(tmp_path) - def test_is_workflow_complete_rejects_zero_coverage_with_passing_tests(self, pdd_test_environment): - """ - Bug #573 (Test 4): _is_workflow_complete should return False when - coverage=0.0 despite tests_passed > 0 and tests_failed == 0. - - The Bug #349 fix at sync_determine_operation.py:1264 treats - (tests_passed > 0 and tests_failed == 0) as success without checking - coverage. This allows coverage=0.0 to be accepted as workflow complete. - """ - tmp_path = pdd_test_environment - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir(exist_ok=True) + # Create nested prompt file + nested_dir = tmp_path / "prompts" / "src" / "clients" + nested_dir.mkdir(parents=True) + actual_prompt = nested_dir / "firestore_client_Python.prompt" + actual_prompt.write_text("Generate Firestore client") - # Create files - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt") + # Create code file + code_dir = tmp_path / "src" / "clients" + code_dir.mkdir(parents=True) + (code_dir / "firestore_client.py").write_text("# client code") - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) - code_hash = create_file(paths['code'], "def foo(): pass") - example_hash = create_file(paths['example'], "foo()") - test_hash = create_file(paths['test'], "def test_foo(): pass") + # Create architecture.json with filename (not full path) + arch = { + "modules": [{ + "filename": "firestore_client_Python.prompt", + "filepath": "src/clients/firestore_client.py" + }] + } + (tmp_path / "architecture.json").write_text(json.dumps(arch)) - # Fingerprint shows workflow completed 'test' - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.156", - "timestamp": "2026-02-23T00:00:00Z", - "command": "test", - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) + # Create minimal .pdd structure + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) - # Run report: tests pass but coverage is 0.0 - # This reproduces the issue where sys.modules stubs mask import errors - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2026-02-23T00:01:00Z", - "exit_code": 0, - "tests_passed": 62, - "tests_failed": 0, - "coverage": 0.0, - "test_hash": test_hash - }) + paths = get_pdd_file_paths("firestore_client", "python", "prompts") - result = _is_workflow_complete( - paths=paths, - skip_tests=False, - skip_verify=False, - basename=BASENAME, - language=LANGUAGE - ) + # BUG: _resolve_prompt_path_from_architecture joins prompts_root + filename → flat path + # Then _case_insensitive_prompt_lookup searches only prompts/, not prompts/src/clients/ + assert paths['prompt'].exists(), ( + f"Bug #1169: prompt not found via architecture.json path. Got: {paths['prompt']}. " + f"Expected to resolve to: {actual_prompt}" + ) + # Verify code path resolves correctly from architecture.json + assert "firestore_client.py" in str(paths['code']) - # Bug #573: Currently returns True because is_success check at line 1264 - # only checks tests_passed > 0 and tests_failed == 0, ignoring coverage. - # After fix: Should return False because coverage=0.0 indicates the tests - # are not actually measuring the module under test. - assert result is False, ( - "Bug #573: _is_workflow_complete() returns True when coverage=0.0 " - "with 62 passing tests. This is a defense-in-depth gap — " - "coverage=0.0 means tests are not exercising the module " - "(likely due to sys.modules stub masking broken imports)." - ) - def test_sync_determine_operation_returns_test_extend_for_zero_coverage(self, pdd_test_environment): - """ - Bug #573 (Test 5): sync_determine_operation should return 'test_extend' - when tests pass but coverage is 0.0. This validates that the detection - side works correctly (it does — the bug is in the orchestration layer - that overrides this signal). - """ - tmp_path = pdd_test_environment - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir(exist_ok=True) +def test_get_pdd_file_paths_nested_subdir_logging_shows_exists_true(tmp_path, monkeypatch, caplog): + """Bug #1169: Logging must show exists=True when prompt is in nested subdir. - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_hash = create_file(prompt_path, "# Test prompt") + Production logs showed 'exists=False' for every sync attempt on nested prompts. + After fix, the log should show exists=True. + """ + import logging + monkeypatch.chdir(tmp_path) - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir=str(prompts_dir)) - code_hash = create_file(paths['code'], "def foo(): pass") - example_hash = create_file(paths['example'], "foo()") - test_hash = create_file(paths['test'], "def test_foo(): pass") + # Create nested prompt file + nested_dir = tmp_path / "prompts" / "src" / "clients" + nested_dir.mkdir(parents=True) + actual_prompt = nested_dir / "firestore_client_Python.prompt" + actual_prompt.write_text("Generate Firestore client") - create_fingerprint_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json", { - "pdd_version": "0.0.156", - "timestamp": "2026-02-23T00:00:00Z", - "command": "test", - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash - }) + # Create minimal .pdd structure + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) - # Run report: tests pass, exit_code=0, but coverage is 0.0 - create_run_report_file(get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json", { - "timestamp": "2026-02-23T00:01:00Z", - "exit_code": 0, - "tests_passed": 62, - "tests_failed": 0, - "coverage": 0.0, - "test_hash": test_hash - }) + with caplog.at_level(logging.INFO, logger="pdd.sync_determine_operation"): + paths = get_pdd_file_paths("firestore_client", "python", "prompts") - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) + # BUG: Current code logs "exists=False" because it looks in prompts/ not prompts/src/clients/ + exists_log_entries = [r for r in caplog.records if "exists=" in r.message] + assert any("exists=True" in r.message for r in exists_log_entries), ( + f"Bug #1169: Expected 'exists=True' in logs but got: " + f"{[r.message for r in exists_log_entries]}" + ) - # The detection side should correctly identify low coverage and return - # test_extend. This test validates that sync_determine_operation catches - # the problem even though the orchestration layer currently overrides it. - assert decision.operation == 'test_extend', ( - f"Expected 'test_extend' for coverage=0.0 with passing tests, " - f"got '{decision.operation}'. sync_determine_operation should detect " - f"that coverage 0.0 < target {TARGET_COVERAGE} and request test extension." - ) +def test_resolve_prompt_path_from_architecture_flat_filename_misses_subdirectory(tmp_path): + """Bug #1169: _resolve_prompt_path_from_architecture produces wrong path for flat filenames. -# --- GitHub Issue #522: Fingerprint ignores dependencies --- + When architecture.json has filename: "firestore_client_Python.prompt" (no subdirectory), + _resolve_prompt_path_from_architecture joins it to prompts_root producing + prompts/firestore_client_Python.prompt. But the file is actually at + prompts/src/clients/firestore_client_Python.prompt. -class TestFingerprintIncludeDependencies: + The resolved path must exist when the file is in a nested subdirectory. + This tests that the architecture.json lookup + path resolution chain + produces a path that actually resolves to the file on disk. """ - Regression tests for GitHub issue #522: sync fingerprint ignores - dependencies. When an included file changes but the top-level .prompt file - doesn't, sync should detect the change and regenerate. + prompts_root = tmp_path / "prompts" + nested_dir = prompts_root / "src" / "clients" + nested_dir.mkdir(parents=True) + actual_file = nested_dir / "firestore_client_Python.prompt" + actual_file.write_text("prompt content") - Approach 2: Store include dependency paths + hashes in the fingerprint JSON - so changes are detected even after auto-deps strips tags. - """ + # This is what happens in the buggy code: architecture.json returns just the filename + resolved = _resolve_prompt_path_from_architecture( + prompts_root, "firestore_client_Python.prompt" + ) - def test_extract_include_deps_finds_xml_includes(self, pdd_test_environment): - """extract_include_deps should find tags and hash the files.""" - prompts_dir = pdd_test_environment / "prompts" - dep_file = pdd_test_environment / "shared_types.py" - create_file(dep_file, "class User:\n name: str\n") + # BUG: resolved is prompts/firestore_client_Python.prompt which doesn't exist + # The file is at prompts/src/clients/firestore_client_Python.prompt + # After fix, either _resolve_prompt_path_from_architecture or its caller + # must search subdirectories to find the actual file + # Post-#1169: _resolve_prompt_path_from_architecture itself performs the + # recursive case-insensitive search when the naive join misses. The + # returned path must point at the real file on disk. + assert resolved.exists(), ( + f"Bug #1169: architecture.json flat filename must resolve to the nested file. " + f"Resolved: {resolved}. Actual file: {actual_file}" + ) + assert resolved == actual_file, ( + f"Bug #1169: expected resolved path to equal {actual_file}, got {resolved}" + ) - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, "Create a helper.\nshared_types.py\n") - deps = extract_include_deps(prompt_path) - assert len(deps) == 1, f"Expected 1 include dep, got {len(deps)}" - assert str(dep_file) in deps or any("shared_types.py" in k for k in deps) +def test_get_pdd_file_paths_deep_nesting_different_language(tmp_path, monkeypatch): + """Bug #1169: Nested subdirectory resolution must work for all languages. - @pytest.mark.parametrize( - "attributed_include", - [ - 'utils.py', - 'utils.py', - 'utils.py', - ], - ids=["select-attr", "query-attr", "select-plus-mode"], - ) - def test_extract_include_deps_finds_attributed_includes( - self, pdd_test_environment, attributed_include - ): - """extract_include_deps must match attributed forms. + Tests deeper nesting with TypeScript to ensure the fix isn't Python-specific. + """ + monkeypatch.chdir(tmp_path) - ``auto_include`` emits ```` and - ```` directives; if the extractor's regex only - matched bare ```` the fingerprint would lose those deps - and dependency changes would go undetected by sync. - """ - prompts_dir = pdd_test_environment / "prompts" - dep_file = pdd_test_environment / "utils.py" - create_file(dep_file, "def helper(): pass\n") + # Create deeply nested prompt file with TypeScript language + deep_dir = tmp_path / "prompts" / "api" / "v2" / "handlers" + deep_dir.mkdir(parents=True) + actual_prompt = deep_dir / "user_handler_TypeScript.prompt" + actual_prompt.write_text("Generate user handler") - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, f"Build module.\n{attributed_include}\n") + # Create minimal .pdd structure + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) - deps = extract_include_deps(prompt_path) - assert len(deps) == 1, ( - f"Expected 1 dep for {attributed_include!r}, got {deps!r}" - ) - assert any("utils.py" in k for k in deps), ( - f"Expected utils.py in deps keys, got {list(deps)}" - ) + paths = get_pdd_file_paths("user_handler", "typescript", "prompts") - def test_extract_include_deps_finds_backtick_includes(self, pdd_test_environment): - """extract_include_deps should find `````` backtick includes.""" - prompts_dir = pdd_test_environment / "prompts" - dep_file = pdd_test_environment / "utils.py" - create_file(dep_file, "def helper(): pass\n") + # BUG: Constructs prompts/user_handler_typescript.prompt (wrong case + wrong dir) + assert paths['prompt'].exists(), ( + f"Bug #1169: prompt not found for TypeScript in deep nesting. Got: {paths['prompt']}. " + f"Expected to find: {actual_prompt}" + ) - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, "Build module.\n``````\n") - deps = extract_include_deps(prompt_path) - assert len(deps) == 1, f"Expected 1 backtick include dep, got {len(deps)}" +def _write_frontend_page_test_config(tmp_path: Path) -> None: + """Create the minimal repo config needed for nested frontend page path resolution.""" + config = { + "contexts": { + "frontend": { + "paths": ["frontend/**", "prompts/frontend/**"], + "defaults": { + "prompts_dir": "prompts/frontend", + "generate_output_path": "frontend/src", + "default_language": "typescriptreact", + "strength": 0.818, + "outputs": { + "prompt": { + "path": "prompts/frontend/{dir_prefix}{name}_{language}.prompt" + } + }, + }, + }, + "default": {"defaults": {}}, + } + } + (tmp_path / ".pddrc").write_text(json.dumps(config)) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) - def test_extract_include_deps_empty_when_no_includes(self, pdd_test_environment): - """extract_include_deps should return empty dict for prompts without includes.""" - prompts_dir = pdd_test_environment / "prompts" - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, "Simple prompt with no includes.\n") - deps = extract_include_deps(prompt_path) - assert deps == {}, f"Expected empty dict, got {deps}" +def test_get_pdd_file_paths_nested_frontend_page_prefers_nested_prompt(tmp_path, monkeypatch): + """Nested frontend page basenames must not collapse to the flat `page` prompt.""" + monkeypatch.chdir(tmp_path) + _write_frontend_page_test_config(tmp_path) - def test_calculate_prompt_hash_with_stored_deps(self, pdd_test_environment): - """calculate_prompt_hash should use stored deps when prompt has no include tags.""" - prompts_dir = pdd_test_environment / "prompts" - dep_file = pdd_test_environment / "shared_types.py" - create_file(dep_file, "class User:\n name: str\n") + flat_prompt = tmp_path / "prompts" / "frontend" / "page_TypescriptReact.prompt" + flat_prompt.parent.mkdir(parents=True) + flat_prompt.write_text("flat page prompt") - # Prompt WITHOUT include tags (simulates post-auto-deps state) - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, "Create a helper using User class.\n") + nested_prompt = tmp_path / "prompts" / "frontend" / "app" / "settings" / "account" / "page_TypescriptReact.prompt" + nested_prompt.parent.mkdir(parents=True) + nested_prompt.write_text("account page prompt") - stored_deps = {str(dep_file): calculate_sha256(dep_file)} + paths = get_pdd_file_paths("frontend/app/settings/account/page", "typescriptreact", "prompts") - # Hash with stored deps should differ from hash without - hash_without = calculate_prompt_hash(prompt_path) - hash_with = calculate_prompt_hash(prompt_path, stored_deps=stored_deps) + assert paths["prompt"].is_file() + assert paths["prompt"].samefile(nested_prompt) + assert paths["code"].resolve() == (tmp_path / "frontend" / "src" / "app" / "settings" / "account" / "page.tsx").resolve() - assert hash_without != hash_with, ( - "Hash with stored deps should differ from hash without — " - "stored deps should contribute to the composite hash" - ) - def test_legacy_include_hash_uses_prompt_dir_then_process_cwd( - self, pdd_test_environment, monkeypatch - ): - prompt = pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" - dependency = pdd_test_environment / "shared.py" - create_file(dependency, "VALUE = 1\n") - create_file(prompt, "Build it.\nshared.py\n") - expected = hashlib.sha256(prompt.read_bytes() + dependency.read_bytes()).hexdigest() - hashes = [] - for cwd in (pdd_test_environment, pdd_test_environment.parent): - monkeypatch.chdir(cwd) - hashes.append(calculate_prompt_hash(prompt)) - assert hashes == [expected, hashlib.sha256(prompt.read_bytes()).hexdigest()] +def test_get_pdd_file_paths_nested_frontend_page_uses_context_relative_architecture_entry(tmp_path, monkeypatch): + """Relative architecture filenames like app/settings/security/page_* must resolve correctly.""" + monkeypatch.chdir(tmp_path) + _write_frontend_page_test_config(tmp_path) - def test_legacy_include_hash_sorts_and_deduplicates_dependencies( - self, pdd_test_environment - ): - prompt = pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" - first = pdd_test_environment / "a.py" - second = pdd_test_environment / "b.py" - create_file(first, "A = 1\n") - create_file(second, "B = 1\n") - create_file( - prompt, - "Build.\nb.py\na.py\n" - "a.py\n", - ) - expected = hashlib.sha256( - prompt.read_bytes() + first.read_bytes() + second.read_bytes() - ).hexdigest() - assert calculate_prompt_hash(prompt, hash_version=1) == expected + flat_prompt = tmp_path / "prompts" / "frontend" / "page_TypescriptReact.prompt" + flat_prompt.parent.mkdir(parents=True) + flat_prompt.write_text("flat page prompt") - def test_legacy_v1_preserves_pre_versioned_include_grammar_and_missing_files( - self, pdd_test_environment - ): - prompt = pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" - body_dep = prompt.parent / "body.py" - attr_dep = prompt.parent / "attribute.py" - create_file(body_dep, "BODY = 1\n") - create_file(attr_dep, "ATTRIBUTE = 1\n") - create_file( - prompt, - "body.py\n" - "\n" - "*.py\n" - "missing.py\n", - ) - expected = hashlib.sha256(prompt.read_bytes() + body_dep.read_bytes()).hexdigest() - assert calculate_prompt_hash(prompt, hash_version=1) == expected + security_prompt = tmp_path / "prompts" / "frontend" / "app" / "settings" / "security" / "page_TypescriptReact.prompt" + security_prompt.parent.mkdir(parents=True) + security_prompt.write_text("security page prompt") - def test_legacy_v1_stored_dependencies_skip_missing_and_keep_key_order( - self, pdd_test_environment - ): - prompt = pdd_test_environment / "prompts" / f"{BASENAME}_{LANGUAGE}.prompt" - first = pdd_test_environment / "a.py" - second = pdd_test_environment / "b.py" - create_file(prompt, "No includes.\n") - create_file(first, "A = 1\n") - create_file(second, "B = 1\n") - stored = {str(second): "old", str(pdd_test_environment / "missing.py"): "old", str(first): "old"} - expected = hashlib.sha256(prompt.read_bytes() + first.read_bytes() + second.read_bytes()).hexdigest() - assert calculate_prompt_hash(prompt, stored_deps=stored, hash_version=1) == expected + (tmp_path / "architecture.json").write_text(json.dumps([ + { + "filename": "app/settings/security/page_TypescriptReact.prompt", + "filepath": "frontend/src/app/settings/security/page.tsx", + }, + { + "filename": "page_TypescriptReact.prompt", + "filepath": "frontend/src/app/settings/github/page.tsx", + }, + ])) - def test_calculate_prompt_hash_detects_dep_change_via_stored_deps(self, pdd_test_environment): - """When a stored dep file changes, the composite hash must change.""" - prompts_dir = pdd_test_environment / "prompts" - dep_file = pdd_test_environment / "shared_types.py" - create_file(dep_file, "class User:\n name: str\n") + paths = get_pdd_file_paths("frontend/app/settings/security/page", "typescriptreact", "prompts") - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, "Create a helper using User class.\n") + assert paths["prompt"].is_file() + assert paths["prompt"].samefile(security_prompt) + assert paths["code"].resolve() == (tmp_path / "frontend" / "src" / "app" / "settings" / "security" / "page.tsx").resolve() - stored_deps = {str(dep_file): calculate_sha256(dep_file)} - hash_before = calculate_prompt_hash(prompt_path, stored_deps=stored_deps) - # Change the dependency file - create_file(dep_file, "class User:\n name: str\n email: str\n") +def test_get_pdd_file_paths_nested_frontend_page_rejects_wrong_flat_architecture_match(tmp_path, monkeypatch): + """Flat architecture entries must not steal other nested page basenames with the same filename.""" + monkeypatch.chdir(tmp_path) + _write_frontend_page_test_config(tmp_path) - hash_after = calculate_prompt_hash(prompt_path, stored_deps=stored_deps) + flat_prompt = tmp_path / "prompts" / "frontend" / "page_TypescriptReact.prompt" + flat_prompt.parent.mkdir(parents=True) + flat_prompt.write_text("flat page prompt") - assert hash_before != hash_after, ( - "Composite prompt hash must change when a stored dependency file changes, " - "even when the prompt itself has no tags" - ) + account_prompt = tmp_path / "prompts" / "frontend" / "app" / "settings" / "account" / "page_TypescriptReact.prompt" + account_prompt.parent.mkdir(parents=True) + account_prompt.write_text("account page prompt") - def test_calculate_prompt_hash_anchors_relative_stored_deps_to_explicit_root( - self, pdd_test_environment, monkeypatch - ): - """Stored relative dependency keys must not be interpreted from process CWD.""" - prompts_dir = pdd_test_environment / "prompts" - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, "Create a helper using project docs.\n") - project_dep = pdd_test_environment / "docs" / "contract.md" - create_file(project_dep, "trusted project dependency\n") + github_prompt = tmp_path / "prompts" / "frontend" / "app" / "settings" / "github" / "page_TypescriptReact.prompt" + github_prompt.parent.mkdir(parents=True) + github_prompt.write_text("github page prompt") - nested = pdd_test_environment / "nested" - alternate_dep = nested / "docs" / "contract.md" - create_file(alternate_dep, "wrong nested dependency\n") - monkeypatch.chdir(nested) + (tmp_path / "architecture.json").write_text(json.dumps([ + { + "filename": "page_TypescriptReact.prompt", + "filepath": "frontend/src/app/settings/github/page.tsx", + }, + ])) - stored_deps = {"docs/contract.md": calculate_sha256(project_dep)} - anchored_hash = calculate_prompt_hash( - prompt_path, - stored_deps=stored_deps, - dependency_root=pdd_test_environment, - ) - create_file(alternate_dep, "changed wrong nested dependency\n") - anchored_hash_after_alternate_change = calculate_prompt_hash( - prompt_path, - stored_deps=stored_deps, - dependency_root=pdd_test_environment, - ) + github_paths = get_pdd_file_paths("frontend/app/settings/github/page", "typescriptreact", "prompts") + assert github_paths["prompt"].is_file() + assert github_paths["prompt"].samefile(github_prompt) + assert github_paths["code"].resolve() == (tmp_path / "frontend" / "src" / "app" / "settings" / "github" / "page.tsx").resolve() - assert anchored_hash == anchored_hash_after_alternate_change + account_paths = get_pdd_file_paths("frontend/app/settings/account/page", "typescriptreact", "prompts") + assert account_paths["prompt"].is_file() + assert account_paths["prompt"].samefile(account_prompt) + assert account_paths["code"].resolve() == (tmp_path / "frontend" / "src" / "app" / "settings" / "account" / "page.tsx").resolve() - def test_fingerprint_stores_include_deps(self, pdd_test_environment): - """Fingerprint dataclass should correctly store and serialize include_deps.""" - fp = Fingerprint( - pdd_version="0.0.156", - timestamp="2026-02-23T00:00:00Z", - command="test", - prompt_hash="abc", - code_hash="def", - example_hash="ghi", - test_hash="jkl", - include_deps={"shared_types.py": "hash1", "utils.py": "hash2"}, - ) - from dataclasses import asdict - d = asdict(fp) - assert d["include_deps"] == {"shared_types.py": "hash1", "utils.py": "hash2"} - def test_fingerprint_include_deps_backward_compat(self, pdd_test_environment): - """Old fingerprint files without include_deps should load with include_deps=None.""" - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, { - "pdd_version": "0.0.145", - "timestamp": "2026-01-01T00:00:00Z", - "command": "generate", - "prompt_hash": "abc", - "code_hash": "def", - "example_hash": "ghi", - "test_hash": "jkl", - }) +# --- Issue #1256: Dict-format architecture tolerance --- +# Scope addition: covers expansion item "pdd/sync_determine_operation.py:340-342 +# has partial tolerance but should use centralized extract_modules() for consistency" +# identified by Step 6 but absent from Step 8's plan - fp = read_fingerprint(BASENAME, LANGUAGE) - assert fp is not None - assert fp.include_deps is None, "Old fingerprints should have include_deps=None" - def test_fingerprint_with_include_deps_loads_correctly(self, pdd_test_environment): - """Fingerprint with include_deps field should load correctly.""" - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, { - "pdd_version": "0.0.156", - "timestamp": "2026-02-23T00:00:00Z", - "command": "generate", - "prompt_hash": "abc", - "code_hash": "def", - "example_hash": "ghi", - "test_hash": "jkl", - "include_deps": {"shared.py": "hash1"}, - }) +def test_get_filepath_dict_format_without_modules_key_returns_tuple(tmp_path): + """_get_filepath_from_architecture returns (None, None) for dict without 'modules' key (Test 18). - fp = read_fingerprint(BASENAME, LANGUAGE) - assert fp is not None - assert fp.include_deps == {"shared.py": "hash1"} + Bug: at sync_determine_operation.py:340, arch.get("modules", arch) falls back to + the dict itself when there is no "modules" key. Then isinstance(modules, list) + is False and line 343 returns bare None instead of the expected (None, None) tuple. + Callers that unpack `filepath, filename = _get_filepath_from_architecture(...)` crash + with TypeError. + """ + from pdd.sync_determine_operation import _get_filepath_from_architecture - def test_included_file_change_triggers_regeneration(self, pdd_test_environment): - """ - Primary bug reproduction (Greg's scenario): After auto-deps strips - tags, changing the included file should still trigger regeneration via stored deps. - """ - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) + arch_path = tmp_path / "architecture.json" + # Dict without "modules" key — triggers the fallback bug + arch_path.write_text(json.dumps({"prd_files": ["prd.md"]}), encoding="utf-8") - # Create dependency file - dep_file = pdd_test_environment / "shared_types.py" - create_file(dep_file, "class User:\n def __init__(self, name): self.name = name\n") + result = _get_filepath_from_architecture(arch_path, "auth_Python.prompt") + assert result == (None, None), ( + f"Expected (None, None) for dict without 'modules' key, got {result!r}. " + "Line 343 returns bare None instead of the expected (None, None) tuple." + ) - # Prompt WITH includes (first sync) - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - prompt_content_with_tags = "Create a helper.\nshared_types.py\n" - create_file(prompt_path, prompt_content_with_tags) - # Calculate what hash the first sync would have saved - first_sync_hash = calculate_prompt_hash(prompt_path) - first_sync_deps = extract_include_deps(prompt_path) +# --------------------------------------------------------------------------- +# Issue #1201: generate_output_path from .pddrc not honored in arch branch +# --------------------------------------------------------------------------- +# The architecture.json branch of get_pdd_file_paths() at line 942 sets +# code_path = project_root / arch_filepath +# with no consultation of .pddrc's generate_output_path. Meanwhile, +# lines 961-962 correctly read test_dir and example_dir from .pddrc defaults. +# All four tests below FAIL on the current (buggy) code and must PASS after +# the fix that reads generate_output_path from .pddrc in the same block. +# --------------------------------------------------------------------------- - # Simulate auto-deps stripping the include tags (rewrites .prompt) - prompt_content_stripped = "Create a helper.\nclass User:\n def __init__(self, name): self.name = name\n" - create_file(prompt_path, prompt_content_stripped) +class TestIssue1201GenerateOutputPathInArchBranch: + """Issue #1201: generate_output_path is silently ignored when architecture.json + provides a filepath, unlike example_output_path and test_output_path which are + correctly applied from .pddrc defaults. + """ - # Create fingerprint from "first sync" with stored deps - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, { - "pdd_version": "0.0.156", - "timestamp": "2026-02-23T00:00:00Z", - "command": "test", - "prompt_hash": first_sync_hash, - "code_hash": None, - "example_hash": None, - "test_hash": None, - "include_deps": first_sync_deps, - }) + def _write_pddrc(self, tmp_path: Path, generate_dir: str = "src/", + test_dir: str = "tests/", example_dir: str = "examples/") -> None: + (tmp_path / ".pddrc").write_text( + f"contexts:\n" + f" default:\n" + f" paths: [\"**\"]\n" + f" defaults:\n" + f" generate_output_path: \"{generate_dir}\"\n" + f" test_output_path: \"{test_dir}\"\n" + f" example_output_path: \"{example_dir}\"\n" + ) - # Create code/example/test files - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") - code_hash = create_file(paths['code'], "def helper(): pass") - example_hash = create_file(paths['example'], "helper()") - test_hash = create_file(paths['test'], "def test_helper(): pass") + def _write_arch_json(self, tmp_path: Path, prompt_filename: str, filepath: str) -> None: + (tmp_path / "architecture.json").write_text(json.dumps({ + "modules": [{"filename": prompt_filename, "filepath": filepath}] + })) - # Update fingerprint with file hashes - create_fingerprint_file(fp_path, { - "pdd_version": "0.0.156", - "timestamp": "2026-02-23T00:00:00Z", - "command": "test", - "prompt_hash": first_sync_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash, - "include_deps": first_sync_deps, - }) + def _setup_dirs(self, tmp_path: Path) -> None: + for d in ("prompts", ".pdd/meta", ".pdd/locks"): + (tmp_path / d).mkdir(parents=True, exist_ok=True) - # Create passing run report - rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" - create_run_report_file(rr_path, { - "timestamp": "2026-02-23T00:00:00Z", - "exit_code": 0, - "tests_passed": 5, - "tests_failed": 0, - "coverage": 95.0, - }) + def test_generate_output_path_honored_when_arch_filepath_is_bare(self, tmp_path, monkeypatch): + """code_path must land in .pddrc generate_output_path when arch.json filepath has no + directory component (i.e., is a bare filename at the project root). - # NOW change the included file (this is the bug trigger) - create_file(dep_file, "class User:\n def __init__(self, name, age, email): pass\n") + Bug: code_path = project_root / arch_filepath uses root unconditionally. + Fix: read generate_output_path from .pddrc defaults and apply it to code_path, + exactly as example_dir/test_dir are already applied. + """ + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) + self._write_pddrc(tmp_path, generate_dir="src/") + self._write_arch_json(tmp_path, "widget_python.prompt", "widget.py") - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) + paths = get_pdd_file_paths("widget", "python", "prompts") - assert decision.operation in ('generate', 'auto-deps'), ( - f"Expected 'generate' or 'auto-deps' because included file changed " - f"(via stored deps), but got '{decision.operation}'. " - f"Stored include_deps in fingerprint must detect dependency changes " - f"even when auto-deps has stripped tags from the prompt." + assert "src" in paths["code"].parts, ( + f"generate_output_path 'src/' from .pddrc must be applied to code path in the " + f"architecture.json branch, but got: {paths['code']!r} (parent: {paths['code'].parent!r})" ) - def test_no_change_no_false_positive_with_stored_deps(self, pdd_test_environment): - """When nothing changes, stored deps must not cause false positive regeneration.""" - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) + def test_all_three_output_paths_applied_symmetrically_in_arch_branch(self, tmp_path, monkeypatch): + """generate_output_path, test_output_path, and example_output_path must all be + honored uniformly in the architecture.json branch — not just the latter two. - dep_file = pdd_test_environment / "shared_types.py" - create_file(dep_file, "class User:\n pass\n") + Currently example_dir and test_dir are read from .pddrc (correct), but + generate_output_path is absent from the same block, causing asymmetry. + """ + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) + self._write_pddrc(tmp_path, generate_dir="src/", test_dir="tests/", example_dir="examples/") + self._write_arch_json(tmp_path, "widget_python.prompt", "widget.py") - # Prompt WITH includes - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, "Create a helper.\nshared_types.py\n") + paths = get_pdd_file_paths("widget", "python", "prompts") - prompt_hash = calculate_prompt_hash(prompt_path) - include_deps = extract_include_deps(prompt_path) + # test and example are already correct (they should stay correct after fix) + assert "examples" in paths["example"].parts, ( + f"example_output_path not honored: {paths['example']!r}" + ) + assert "tests" in paths["test"].parts, ( + f"test_output_path not honored: {paths['test']!r}" + ) + # code must also use its configured directory — currently broken + assert "src" in paths["code"].parts, ( + f"generate_output_path 'src/' is not applied symmetrically with " + f"test_output_path and example_output_path. " + f"code={paths['code']!r}, test={paths['test']!r}, example={paths['example']!r}" + ) - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") - code_hash = create_file(paths['code'], "def helper(): pass") - example_hash = create_file(paths['example'], "helper()") - test_hash = create_file(paths['test'], "def test_helper(): pass") + def test_code_filename_from_arch_json_preserved_after_generate_path_applied( + self, tmp_path, monkeypatch + ): + """After the fix, the filename component from architecture.json must be preserved; + only the parent directory should be overridden by .pddrc generate_output_path. - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, { - "pdd_version": "0.0.156", - "timestamp": "2026-02-23T00:00:00Z", - "command": "test", - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash, - "include_deps": include_deps, - }) + This verifies the correct resolution: src/widget.py — not src/widget_widget.py. + """ + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) + self._write_pddrc(tmp_path, generate_dir="src/") + self._write_arch_json(tmp_path, "widget_python.prompt", "widget.py") - rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" - create_run_report_file(rr_path, { - "timestamp": "2026-02-23T00:00:00Z", - "exit_code": 0, - "tests_passed": 5, - "tests_failed": 0, - "coverage": 95.0, - }) + paths = get_pdd_file_paths("widget", "python", "prompts") - # Nothing changed - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) + # Filename must come from arch.json + assert paths["code"].name == "widget.py", ( + f"Filename should be preserved from arch.json as 'widget.py', got: {paths['code'].name!r}" + ) + # Parent directory must come from .pddrc generate_output_path + assert paths["code"].parent.name == "src", ( + f"Parent directory should be 'src' from generate_output_path, " + f"got: {paths['code'].parent.name!r} (full path: {paths['code']!r})" ) - assert decision.operation not in ('generate', 'auto-deps'), ( - f"Expected no regeneration when nothing changed, got '{decision.operation}'. " - f"Stored include_deps must not cause false positives." + def test_generate_output_path_honored_with_explicit_context_override( + self, tmp_path, monkeypatch + ): + """generate_output_path is honored in the arch branch even when context_override is given. + + Steps 2-3 confirmed the bug in the pddrc_path branch that reads context_name from + context_override (line 952). This test ensures the fix works end-to-end when the + caller supplies an explicit context. + """ + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) + (tmp_path / ".pddrc").write_text( + "contexts:\n" + " default:\n" + " paths: [\"**\"]\n" + " defaults:\n" + " generate_output_path: \"lib/\"\n" + " test_output_path: \"tests/\"\n" + " example_output_path: \"examples/\"\n" + " backend:\n" + " paths: [\"backend/**\"]\n" + " defaults:\n" + " generate_output_path: \"backend/src/\"\n" + " test_output_path: \"backend/tests/\"\n" + " example_output_path: \"backend/examples/\"\n" ) + self._write_arch_json(tmp_path, "service_python.prompt", "service.py") - def test_one_of_multiple_deps_changes(self, pdd_test_environment): - """When one of multiple stored deps changes, sync should detect it.""" - prompts_dir = pdd_test_environment / "prompts" - prompts_dir.mkdir(exist_ok=True) + paths = get_pdd_file_paths("service", "python", "prompts", context_override="backend") - dep1 = pdd_test_environment / "types.py" - dep2 = pdd_test_environment / "utils.py" - create_file(dep1, "class Foo: pass") - create_file(dep2, "def bar(): pass") + # With context_override="backend", generate_output_path should be "backend/src/" + code_parts = paths["code"].parts + assert "backend" in code_parts and "src" in code_parts, ( + f"With context_override='backend', code_path should be in backend/src/, " + f"but got: {paths['code']!r}" + ) +from datetime import datetime, timezone +from pathlib import Path - prompt_path = prompts_dir / f"{BASENAME}_{LANGUAGE}.prompt" - create_file(prompt_path, "Build module.\ntypes.py\nutils.py\n") +from pdd.sync_determine_operation import _handle_missing_expected_files - prompt_hash = calculate_prompt_hash(prompt_path) - include_deps = extract_include_deps(prompt_path) - paths = get_pdd_file_paths(BASENAME, LANGUAGE, prompts_dir="prompts") - code_hash = create_file(paths['code'], "def module(): pass") - example_hash = create_file(paths['example'], "module()") - test_hash = create_file(paths['test'], "def test_module(): pass") +def test_missing_example_schedules_example_by_default(tmp_path: Path) -> None: + from pdd.sync_determine_operation import Fingerprint + prompt = tmp_path / "prompts" / "calc_python.prompt" + code = tmp_path / "pdd" / "calc.py" + example = tmp_path / "context" / "calc_example.py" + test = tmp_path / "tests" / "test_calc.py" + prompt.parent.mkdir() + code.parent.mkdir() + example.parent.mkdir() + test.parent.mkdir() + prompt.write_text("Create calc.\n", encoding="utf-8") + code.write_text("def add(a, b): return a + b\n", encoding="utf-8") + fingerprint = Fingerprint("test", datetime.now(timezone.utc).isoformat(), "fix", "p", "c", "e", "t") + + decision = _handle_missing_expected_files( + ["example"], + {"prompt": prompt, "code": code, "example": example, "test": test}, + fingerprint, + "calc", + "python", + str(prompt.parent), + ) - fp_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}.json" - create_fingerprint_file(fp_path, { - "pdd_version": "0.0.156", - "timestamp": "2026-02-23T00:00:00Z", - "command": "test", - "prompt_hash": prompt_hash, - "code_hash": code_hash, - "example_hash": example_hash, - "test_hash": test_hash, - "include_deps": include_deps, - }) + assert decision.operation == "example" - rr_path = get_meta_dir() / f"{BASENAME}_{LANGUAGE}_run.json" - create_run_report_file(rr_path, { - "timestamp": "2026-02-23T00:00:00Z", - "exit_code": 0, - "tests_passed": 1, - "tests_failed": 0, - "coverage": 90.0, - }) - # Change only dep2 - create_file(dep2, "def bar(): return 42") +def test_missing_example_is_bypassed_for_isolated_repair_or_replay(tmp_path: Path) -> None: + from pdd.sync_determine_operation import Fingerprint - decision = sync_determine_operation( - BASENAME, LANGUAGE, TARGET_COVERAGE, - prompts_dir=str(prompts_dir) - ) + prompt = tmp_path / "prompts" / "calc_python.prompt" + code = tmp_path / "pdd" / "calc.py" + example = tmp_path / "context" / "calc_example.py" + test = tmp_path / "tests" / "test_calc.py" + prompt.parent.mkdir() + code.parent.mkdir() + example.parent.mkdir() + test.parent.mkdir() + prompt.write_text("Create calc.\n", encoding="utf-8") + code.write_text("def add(a, b): return a + b\n", encoding="utf-8") + fingerprint = Fingerprint("test", datetime.now(timezone.utc).isoformat(), "fix", "p", "c", "e", "t") - assert decision.operation in ('generate', 'auto-deps'), ( - f"Expected regeneration when one of multiple included files changed, " - f"got '{decision.operation}'." - ) + decision = _handle_missing_expected_files( + ["example"], + {"prompt": prompt, "code": code, "example": example, "test": test}, + fingerprint, + "calc", + "python", + str(prompt.parent), + isolated_replay_or_repair=True, + ) + + assert decision.operation == "generate" + assert decision.details["isolated_replay_or_repair"] is True # --------------------------------------------------------------------------- -# Bug: _generate_paths_from_templates missing 'code' fallback (#826) +# Issue #551 (reopened): YAML and Markdown example/test paths use raw language +# name instead of canonical file extension. +# +# Root cause: local get_extension() in sync_determine_operation fell back to +# language.lower() for languages not in its hard-coded map, returning "yaml" +# instead of "yml" and "markdown" instead of "md". +# +# Fix: local get_extension() now reads the first matching row from the +# package-local language_format.csv, which maps YAML -> .yml and Markdown -> .md. # --------------------------------------------------------------------------- -class TestGeneratePathsFromTemplatesCodeFallback: - """When .pddrc outputs config defines 'prompt' but not 'code', the returned - dict must still have a 'code' key. Otherwise sync_orchestration crashes with - KeyError: 'code'. - - Regression test for promptdriven/example_app#826: the frontend catch-all - context has outputs.prompt but no outputs.code, causing page syncs to crash. +class TestIssue551CanonicalExtensionInGetPddFilePaths: + """Regression tests for issue #551 (reopened): YAML and Markdown example/test + paths must use canonical file extensions, not raw language names. """ - def test_code_key_always_present(self): - """_generate_paths_from_templates must return a 'code' key even when - outputs config only defines 'prompt'.""" - from pdd.sync_determine_operation import _generate_paths_from_templates - - outputs_config = { - "prompt": {"path": "prompts/frontend/{dir_prefix}{name}_{language}.prompt"}, - # NOTE: no 'code' output defined — this is the bug trigger - } - result = _generate_paths_from_templates( - basename="app/dashboard/page", - language="typescriptreact", - extension="tsx", - outputs_config=outputs_config, - prompt_path="prompts/frontend/app/dashboard/page_TypescriptReact.prompt", - ) + def _write_arch_json(self, tmp_path: Path, prompt_filename: str, filepath: str) -> None: + (tmp_path / "architecture.json").write_text(json.dumps({ + "modules": [{"filename": prompt_filename, "filepath": filepath}] + })) - assert "code" in result, ( - f"'code' key missing from result: {list(result.keys())}. " - "sync_orchestration accesses pdd_files['code'] directly and will " - "crash with KeyError if this key is absent." - ) - assert "page" in str(result["code"]), ( - f"Code path should contain the module name 'page', got: {result['code']}" - ) + def _setup_dirs(self, tmp_path: Path) -> None: + for d in ("prompts", "examples", "tests", ".pdd/meta", ".pdd/locks"): + (tmp_path / d).mkdir(parents=True, exist_ok=True) - def test_code_key_present_with_generate_output_path(self): - """When generate_output_path is available, use it for the code fallback.""" - from pdd.sync_determine_operation import _generate_paths_from_templates + @pytest.mark.parametrize("language,code_filename,expected_example_suffix,expected_test_suffix", [ + ("YAML", "ci.yml", ".yml", ".yml"), + ("Markdown", "manifest.md", ".md", ".md"), + ("Text", "dockerfile.txt", ".txt", ".txt"), + ]) + def test_architecture_paths_use_canonical_extensions( + self, + tmp_path, + monkeypatch, + language, + code_filename, + expected_example_suffix, + expected_test_suffix, + ): + """get_pdd_file_paths must derive example/test extensions from the canonical + language mapping, not the raw language string. - outputs_config = { - "prompt": {"path": "prompts/frontend/{dir_prefix}{name}_{language}.prompt"}, - } - result = _generate_paths_from_templates( - basename="app/dashboard/page", - language="typescriptreact", - extension="tsx", - outputs_config=outputs_config, - prompt_path="prompts/frontend/app/dashboard/page_TypescriptReact.prompt", - ) + Before the fix: + YAML -> ci_example.yaml / test_ci.yaml (wrong, should be .yml) + Markdown -> manifest_example.markdown (wrong, should be .md) + Text -> dockerfile_example.text (wrong, should be .txt) + """ + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) - assert "code" in result - # Should use dir_prefix + name pattern - code_str = str(result["code"]) - assert "page.tsx" in code_str, f"Expected page.tsx in code path, got: {code_str}" + basename = Path(code_filename).stem + prompt_filename = f"{basename}_{language}.prompt" + (tmp_path / "prompts" / prompt_filename).write_text(f"% {language} module\n") + self._write_arch_json(tmp_path, prompt_filename, code_filename) + paths = get_pdd_file_paths(basename, language, "prompts") -# ============================================================================= -# Issue #1048: Glob patterns must escape brackets in basenames -# ============================================================================= + assert paths["example"].suffix == expected_example_suffix, ( + f"Issue #551: example path for {language} must end with {expected_example_suffix!r}, " + f"got {paths['example'].suffix!r} (full path: {paths['example']})" + ) + assert paths["test"].suffix == expected_test_suffix, ( + f"Issue #551: test path for {language} must end with {expected_test_suffix!r}, " + f"got {paths['test'].suffix!r} (full path: {paths['test']})" + ) + def test_yaml_example_path_is_yml_not_yaml(self, tmp_path, monkeypatch): + """Explicit regression for the reported YAML case: ci.yml must produce + ci_example.yml (not ci_example.yaml) and test_ci.yml (not test_ci.yaml). + """ + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) + (tmp_path / "prompts" / "ci_YAML.prompt").write_text("% CI pipeline\n") + self._write_arch_json(tmp_path, "ci_YAML.prompt", "ci.yml") -class TestIssue1048GlobEscapingInDetermineOperation: - """Tests that glob patterns in sync_determine_operation correctly handle - bracket characters in basenames by using glob.escape().""" + paths = get_pdd_file_paths("ci", "YAML", "prompts") - def test_check_example_success_history_with_bracket_basename(self, tmp_path): - """_check_example_success_history glob pattern must escape brackets in _safe_basename output. + assert paths["example"].name == "ci_example.yml", ( + f"Issue #551: YAML example must be ci_example.yml, got {paths['example'].name!r}" + ) + assert paths["test"].name == "test_ci.yml", ( + f"Issue #551: YAML test must be test_ci.yml, got {paths['test'].name!r}" + ) - Bug: _safe_basename('frontend/[id]') -> 'frontend_[id]', then - meta_dir.glob('frontend_[id]_python_run*.json') interprets [id] as char class. + def test_markdown_example_path_is_md_not_markdown(self, tmp_path, monkeypatch): + """Explicit regression for the reported Markdown case: manifest.md must + produce manifest_example.md (not manifest_example.markdown). """ - from sync_determine_operation import _check_example_success_history, _safe_basename + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) + (tmp_path / "prompts" / "manifest_Markdown.prompt").write_text("% Manifest docs\n") + self._write_arch_json(tmp_path, "manifest_Markdown.prompt", "manifest.md") - assert _safe_basename("frontend/[id]") == "frontend_[id]" + paths = get_pdd_file_paths("manifest", "Markdown", "prompts") - meta_dir = tmp_path / ".pdd" / "meta" - meta_dir.mkdir(parents=True, exist_ok=True) + assert paths["example"].name == "manifest_example.md", ( + f"Issue #551: Markdown example must be manifest_example.md, " + f"got {paths['example'].name!r}" + ) + assert paths["test"].name == "test_manifest.md", ( + f"Issue #551: Markdown test must be test_manifest.md, " + f"got {paths['test'].name!r}" + ) + + # ------------------------------------------------------------------ + # Comprehensive sibling-language parametrized regression tests + # These cover languages from the Step 6 NEEDS_FIX list where the old + # local helper returned raw language names instead of canonical exts. + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("language,code_filename,expected_example_suffix,expected_test_suffix", [ + # C-family / systems + ("C++", "engine.cpp", ".cpp", ".cpp"), + ("C#", "service.cs", ".cs", ".cs"), + ("Haskell", "parser.hs", ".hs", ".hs"), + ("F#", "module.fs", ".fs", ".fs"), + ("R", "stats.R", ".R", ".R"), + ("LaTeX", "paper.tex", ".tex", ".tex"), + ("Assembly", "boot.asm", ".asm", ".asm"), + ("Fortran", "solver.f90", ".f90", ".f90"), + ("COBOL", "report.cob", ".cob", ".cob"), + ("Prolog", "facts.pl", ".pl", ".pl"), + ("Erlang", "node.erl", ".erl", ".erl"), + ("Clojure", "core.clj", ".clj", ".clj"), + ("Julia", "compute.jl", ".jl", ".jl"), + ("Elixir", "worker.ex", ".ex", ".ex"), + ("Pascal", "program.pas", ".pas", ".pas"), + ("VBScript", "script.vbs", ".vbs", ".vbs"), + ("CoffeeScript", "app.coffee", ".coffee", ".coffee"), + ("Objective-C", "view.m", ".m", ".m"), + ("Scheme", "eval.scm", ".scm", ".scm"), + ("OCaml", "lexer.ml", ".ml", ".ml"), + ("LLM", "agent.prompt", ".prompt", ".prompt"), + ("reStructuredText","manual.rst", ".rst", ".rst"), + ("Verilog", "adder.v", ".v", ".v"), + ("Systemverilog", "module.sv", ".sv", ".sv"), + ("Jinja", "tmpl.jinja2", ".jinja2", ".jinja2"), + ("Handlebars", "page.hbs", ".hbs", ".hbs"), + ("Terraform", "main.tf", ".tf", ".tf"), + ("Solidity", "token.sol", ".sol", ".sol"), + ("Protobuf", "schema.proto", ".proto", ".proto"), + ("Starlark", "rules.bzl", ".bzl", ".bzl"), + ]) + def test_sibling_language_architecture_paths_use_canonical_extensions( + self, + tmp_path, + monkeypatch, + language, + code_filename, + expected_example_suffix, + expected_test_suffix, + ): + """Issue #551 scope expansion: all languages from the Step 6 NEEDS_FIX list + must produce canonical extensions in get_pdd_file_paths, not raw language names. - report_file = meta_dir / "frontend_[id]_python_run_001.json" - report_file.write_text('{"exit_code": 0}') + Before the fix, the local get_extension() fell back to language.lower() for any + language not in its hard-coded map, producing suffixes like .c++, .haskell, + .terraform, .restructuredtext, etc. instead of canonical .cpp, .hs, .tf, .rst. + """ + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) - with patch("sync_determine_operation.get_meta_dir", return_value=meta_dir), \ - patch("sync_determine_operation.read_fingerprint", return_value=None), \ - patch("sync_determine_operation.read_run_report", return_value=None): + basename = Path(code_filename).stem + prompt_filename = f"{basename}_{language}.prompt" + (tmp_path / "prompts" / prompt_filename).write_text(f"% {language} module\n") + self._write_arch_json(tmp_path, prompt_filename, code_filename) - result = _check_example_success_history("frontend/[id]", "python") + paths = get_pdd_file_paths(basename, language, "prompts") - assert result is True, \ - "Bug #1048: _check_example_success_history can't find run report because " \ - "glob interprets [id] as character class matching 'i' or 'd'" + assert paths["example"].suffix == expected_example_suffix, ( + f"Issue #551 ({language}): example path must end with {expected_example_suffix!r}, " + f"got {paths['example'].suffix!r} (full path: {paths['example']})" + ) + assert paths["test"].suffix == expected_test_suffix, ( + f"Issue #551 ({language}): test path must end with {expected_test_suffix!r}, " + f"got {paths['test'].suffix!r} (full path: {paths['test']})" + ) + assert paths["test_files"] == [paths["test"]], ( + f"Issue #551 ({language}): test_files must contain the canonical test path, " + f"got {paths['test_files']!r}" + ) - def test_get_pdd_file_paths_primary_test_glob_with_brackets(self, tmp_path, monkeypatch): - """get_pdd_file_paths primary path: test glob must escape brackets in name_part. + def test_makefile_uses_no_extension(self, tmp_path, monkeypatch): + """Makefile has no extension in language_format.csv — example/test paths must + not get a raw .makefile suffix. - Bug: _extract_name_part('[id]') returns ('', '[id]'), then - test_dir.glob('test_[id]*.py') interprets [id] as char class. - The fallback returns [test_path] (1 file) masking the glob failure. - We create 2 test files so only proper glob finds both. + Before the fix: get_extension('Makefile') returned 'makefile', so paths + would be *_example.makefile and test_*.makefile. + After the fix: the canonical CSV row has empty extension, so get_extension + returns '' and paths omit the suffix. """ monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) + (tmp_path / "prompts" / "build_Makefile.prompt").write_text("% Build rules\n") + self._write_arch_json(tmp_path, "build_Makefile.prompt", "Makefile") - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir() - (prompts_dir / "[id]_python.prompt").write_text("# prompt") + paths = get_pdd_file_paths("Makefile", "Makefile", "prompts") - tests_dir = tmp_path / "tests" - tests_dir.mkdir() - # Create TWO matching test files — fallback only returns 1 - test_file_1 = tests_dir / "test_[id].py" - test_file_1.write_text("def test_1(): pass") - test_file_2 = tests_dir / "test_[id]_extra.py" - test_file_2.write_text("def test_2(): pass") + # The empty extension must not leave a malformed trailing-dot path + # (e.g. "Makefile_example.") via the unconditional ".{extension}" join. + for key in ("code", "example", "test"): + name = paths[key].name + assert not name.endswith("."), ( + f"Issue #551 (Makefile): {key} path must not end with a trailing " + f"dot, got {name!r}" + ) + assert ".makefile" not in name.lower(), ( + f"Issue #551 (Makefile): {key} path must not contain .makefile, " + f"got {name!r}" + ) - with patch("sync_determine_operation.construct_paths") as mock_cp: - def side_effect(*args, **kwargs): - cmd = kwargs.get("command", "sync") - if cmd == "test": - return ( - {"prompts_dir": str(prompts_dir), "tests_dir": str(tests_dir)}, - {"prompt_file": "content"}, - {"output": str(tests_dir / "test_[id].py")}, - "python", - ) - return ( - {"prompts_dir": str(prompts_dir), "tests_dir": str(tests_dir)}, - {"prompt_file": "content"}, - { - "output": str(tmp_path / "src" / "[id].py"), - "generate_output_path": str(tmp_path / "src" / "[id].py"), - "test_output_path": str(tests_dir / "test_[id].py"), - "example_output_path": str(tmp_path / "examples" / "[id]_example.py"), - }, - "python", - ) - mock_cp.side_effect = side_effect + # test_files entries must likewise be free of trailing dots. + for tf in paths.get("test_files", []): + tf_name = Path(tf).name + assert not tf_name.endswith("."), ( + f"Issue #551 (Makefile): test_files entry must not end with a " + f"trailing dot, got {tf_name!r}" + ) - result = get_pdd_file_paths("[id]", "python", prompts_dir=str(prompts_dir)) + def test_test_files_list_uses_canonical_extension(self, tmp_path, monkeypatch): + """test_files key in get_pdd_file_paths return must also use the canonical extension. - test_files = result.get("test_files", []) - test_file_names = [Path(f).name for f in test_files] + Before the fix, YAML produced test_files=[Path('tests/test_ci.yaml')] instead of + [Path('tests/test_ci.yml')]. + """ + monkeypatch.chdir(tmp_path) + self._setup_dirs(tmp_path) + (tmp_path / "prompts" / "ci_YAML.prompt").write_text("% CI pipeline\n") + self._write_arch_json(tmp_path, "ci_YAML.prompt", "ci.yml") - # Both test files should be found by glob. The fallback only returns 1. - assert "test_[id].py" in test_file_names, \ - f"Bug #1048: glob missed test_[id].py. Found: {test_file_names}" - assert "test_[id]_extra.py" in test_file_names, \ - f"Bug #1048: glob missed test_[id]_extra.py because [id] was treated as char class " \ - f"(fallback masked the bug by returning only test_path). Found: {test_file_names}" + paths = get_pdd_file_paths("ci", "YAML", "prompts") - def test_get_pdd_file_paths_fallback_glob_with_brackets(self, tmp_path, monkeypatch): - """get_pdd_file_paths exception fallback: test glob must also escape brackets. + assert len(paths["test_files"]) >= 1, "test_files must be non-empty" + for tf in paths["test_files"]: + assert Path(tf).suffix == ".yml", ( + f"Issue #551: test_files entry must end with .yml, got {Path(tf).suffix!r} " + f"(entry: {tf!r})" + ) + assert Path(tf).suffix != ".yaml", ( + f"Issue #551: test_files must not contain .yaml path, got {tf!r}" + ) - With construct_paths raising, the fallback globs in CWD. - We create 2 test files to detect the glob failure (fallback returns only 1). + def test_pdd_path_unset_generation_matches_sync_extension(self, tmp_path, monkeypatch): + """Issue #551 (FM1): with PDD_PATH unset, the extension generation WRITES + (construct_paths' offline fallback) must equal the extension sync EXPECTS + (get_pdd_file_paths). Before the shared-CSV fix, generation wrote + ci_example.yaml (BUILTIN_EXT_MAP) while sync expected ci_example.yml + (bundled CSV) -> sync looped regenerating forever. """ + from pdd.construct_paths import construct_paths + + monkeypatch.delenv("PDD_PATH", raising=False) monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir(parents=True, exist_ok=True) + prompt_file = tmp_path / "prompts" / "ci_YAML.prompt" + code_file = tmp_path / "ci.yml" + prompt_file.write_text("% CI pipeline example\n") + code_file.write_text("on: [push]\n") - # Create TWO test files in CWD - test_file_1 = tmp_path / "test_[id].py" - test_file_1.write_text("def test_1(): pass") - test_file_2 = tmp_path / "test_[id]_extra.py" - test_file_2.write_text("def test_2(): pass") + # What generation writes for `pdd example` when PDD_PATH is unset. + _, _, output_paths, _ = construct_paths( + input_file_paths={"prompt_file": str(prompt_file), "code_file": str(code_file)}, + force=True, + quiet=True, + command="example", + command_options={}, + ) + written = Path(output_paths["output"]) - with patch("sync_determine_operation.construct_paths", side_effect=Exception("force fallback")): - result = get_pdd_file_paths("[id]", "python", prompts_dir=str(tmp_path)) + # What sync expects for the same module. + expected = get_pdd_file_paths("ci", "YAML", "prompts")["example"] - test_files = result.get("test_files", []) - test_file_names = [Path(f).name for f in test_files] + assert written.suffix == ".yml", ( + f"FM1: generation should write .yml offline, got {written.name!r}" + ) + assert written.suffix == expected.suffix, ( + f"FM1: generation writes {written.suffix!r} but sync expects " + f"{expected.suffix!r} (PDD_PATH unset) -> #551 regeneration loop" + ) - assert "test_[id]_extra.py" in test_file_names, \ - f"Bug #1048: fallback glob missed test_[id]_extra.py because [id] was treated as char class. Found: {test_file_names}" - def test_get_pdd_file_paths_prompt_missing_glob_with_brackets(self, tmp_path, monkeypatch): - """get_pdd_file_paths prompt-missing fallback: test glob must escape brackets. +# --------------------------------------------------------------------------- +# R16: configured .pddrc output paths (generate_output_path, +# example/test_output_path, outputs.*.path) are held to the same project +# containment as architecture code filepaths (R7). An escaping configured output +# must fail closed (UnsafeOutputPathError) and must never materialize a file or +# directory outside the project during resolution — not even in dry-run. +# Regression for the independent-review finding that R7-R10 containment was +# applied only to architecture metadata, not to .pddrc-derived destinations. +# --------------------------------------------------------------------------- - When prompt doesn't exist on disk, the function still tries to find test files. - We create 2 test files to detect the glob failure. - """ - monkeypatch.chdir(tmp_path) - # NO prompt file exists — triggers the prompt-missing path - tests_dir = tmp_path / "tests" - tests_dir.mkdir() - test_file_1 = tests_dir / "test_[id].py" - test_file_1.write_text("def test_1(): pass") - test_file_2 = tests_dir / "test_[id]_extra.py" - test_file_2.write_text("def test_2(): pass") +def _write_escape_pddrc_project(root: Path, defaults_yaml: str, *, with_arch: bool) -> None: + """A minimal project whose .pddrc `backend` context carries `defaults_yaml`.""" + (root / "prompts").mkdir(parents=True) + (root / "prompts" / "widget_python.prompt").write_text( + "% widget\n", encoding="utf-8" + ) + (root / ".pdd" / "meta").mkdir(parents=True) + (root / ".pdd" / "locks").mkdir(parents=True) + (root / ".pddrc").write_text( + 'contexts:\n' + ' backend:\n' + ' paths: ["**"]\n' + ' defaults:\n' + + defaults_yaml, + encoding="utf-8", + ) + if with_arch: + (root / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "widget_python.prompt", "filepath": "widget.py"} + ]}), + encoding="utf-8", + ) - with patch("sync_determine_operation.construct_paths") as mock_cp: - mock_cp.return_value = ( - {"prompts_dir": str(tmp_path / "prompts"), "tests_dir": str(tests_dir)}, - {"prompt_file": "content"}, - { - "generate_output_path": str(tmp_path / "src" / "[id].py"), - "test_output_path": str(tests_dir / "test_[id].py"), - "example_output_path": str(tmp_path / "examples" / "[id]_example.py"), - }, - "python", - ) - result = get_pdd_file_paths("[id]", "python", prompts_dir=str(tmp_path / "prompts")) +def test_get_pdd_file_paths_rejects_pddrc_example_test_output_escape(tmp_path, monkeypatch): + """R16: escaping example_output_path/test_output_path fail closed (arch branch).""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' example_output_path: "../../../escape_ex/"\n' + ' test_output_path: "../../escape_test/"\n', + with_arch=True, + ) + with pytest.raises(UnsafeOutputPathError, match="resolves outside project root"): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + assert not (tmp_path.parent.parent.parent / "escape_ex").exists() + assert not (tmp_path.parent.parent / "escape_test").exists() - test_files = result.get("test_files", []) - test_file_names = [Path(f).name for f in test_files] - assert "test_[id]_extra.py" in test_file_names, \ - f"Bug #1048: prompt-missing glob missed test_[id]_extra.py. Found: {test_file_names}" +def test_get_pdd_file_paths_rejects_pddrc_generate_output_escape(tmp_path, monkeypatch): + """R16: an escaping generate_output_path cannot redirect the code target out of tree.""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' generate_output_path: "../../escape_gen/"\n', + with_arch=True, + ) + with pytest.raises(UnsafeOutputPathError, match="resolves outside project root"): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + assert not (tmp_path.parent.parent / "escape_gen").exists() -# ============================================================================ -# Issue #1169: get_pdd_file_paths fails for nested subdirectories + case mismatch -# ============================================================================ +def test_get_pdd_file_paths_rejects_pddrc_outputs_template_escape(tmp_path, monkeypatch): + """R16: escaping outputs.*.path templates fail closed (arch branch).""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' outputs:\n' + ' example:\n path: "../../../escape_out/{name}_example.py"\n' + ' test:\n path: "../../escape_out/test_{name}.py"\n', + with_arch=True, + ) + with pytest.raises(UnsafeOutputPathError, match="resolves outside project root"): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + assert not (tmp_path.parent.parent.parent / "escape_out").exists() -from pdd.sync_determine_operation import ( - _case_insensitive_path_lookup, - _resolve_prompt_path_from_architecture, -) +def test_get_pdd_file_paths_non_arch_generate_escape_creates_nothing_out_of_tree(tmp_path, monkeypatch): + """R16: without architecture.json, an escaping generate_output_path must not + mkdir/touch a probe file outside the project during resolution and must fail closed.""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' generate_output_path: "../../escape_gen/"\n', + with_arch=False, + ) + outside = tmp_path.parent.parent / "escape_gen" + with pytest.raises(UnsafeOutputPathError, match="resolves outside project root"): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + # The pre-existing temp-probe mkdir/touch must not have leaked out of tree. + assert not outside.exists() -def test_case_insensitive_path_lookup_returns_on_disk_casing_when_alias_exists(tmp_path, monkeypatch): - """Case-insensitive filesystems must not preserve the caller's wrong casing.""" - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir() - actual_prompt = prompts_dir / "api_pipeline_compositions_route_TypeScript.prompt" - actual_prompt.write_text("Generate API route") - lower_candidate = prompts_dir / "api_pipeline_compositions_route_typescript.prompt" - original_exists = Path.exists +def test_get_pdd_file_paths_pddrc_within_project_outputs_allowed(tmp_path, monkeypatch): + """R16 neighboring positive control: custom BUT in-project output dirs still resolve + (the containment guard must not over-reject legitimate non-default layouts).""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' example_output_path: "custom/usage/"\n' + ' test_output_path: "custom/specs/"\n' + ' generate_output_path: "src/lib/"\n', + with_arch=True, + ) + paths = get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + root = tmp_path.resolve() + for key in ("code", "example", "test"): + assert paths[key].resolve(strict=False).is_relative_to(root), ( + f"{key}={paths[key]} should stay within {root}" + ) + assert paths["example"].resolve(strict=False) == (root / "custom" / "usage" / "widget_example.py") + assert paths["test"].resolve(strict=False) == (root / "custom" / "specs" / "test_widget.py") + + +def test_pdd_sync_dry_run_cli_resolves_nested_context(tmp_path, monkeypatch): + """Finding-3 boundary test: the REAL `pdd sync --dry-run --json` Click + entrypoint (context discovery + prompts_dir rewrite in sync_main) resolves a + nested-context, path-qualified architecture unit to its prefix-retained prompt + and code — not just the get_pdd_file_paths helper in isolation.""" + from click.testing import CliRunner + from pdd.cli import cli + + proj = tmp_path + (proj / "prompts" / "commands").mkdir(parents=True) + (proj / "prompts" / "commands" / "checkup_python.prompt").write_text("% checkup\n", encoding="utf-8") + (proj / "pdd" / "commands").mkdir(parents=True) + (proj / "pdd" / "commands" / "checkup.py").write_text("x = 1\n", encoding="utf-8") + (proj / ".pdd" / "meta").mkdir(parents=True) + (proj / ".pdd" / "locks").mkdir(parents=True) + (proj / ".pddrc").write_text( + 'contexts:\n' + ' default:\n' + ' paths: ["**"]\n' + ' defaults:\n' + ' generate_output_path: "pdd/"\n', + encoding="utf-8", + ) + (proj / "architecture.json").write_text( + json.dumps({"modules": [ + {"filename": "commands/checkup_python.prompt", "filepath": "pdd/commands/checkup.py"} + ]}), + encoding="utf-8", + ) + monkeypatch.chdir(proj) + result = CliRunner().invoke(cli, ["sync", "commands/checkup", "--dry-run", "--json"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + unit = payload["units"][0] + assert unit["paths"]["prompt"].replace("\\", "/").endswith("prompts/commands/checkup_python.prompt") + assert unit["paths"]["code"].replace("\\", "/").endswith("pdd/commands/checkup.py") + + +def test_pdd_sync_cli_refuses_escaping_pddrc_output(tmp_path, monkeypatch): + """Finding-3 negative CLI control: a malicious .pddrc artifact path routed through + the real `pdd sync --dry-run` entrypoint must not create or write anything outside + the project tree.""" + from click.testing import CliRunner + from pdd.cli import cli + + proj = tmp_path / "proj" + _write_escape_pddrc_project( + proj, + ' generate_output_path: "../../escape_cli/"\n', + with_arch=True, + ) + outside = tmp_path.parent / "escape_cli" + monkeypatch.chdir(proj) + result = CliRunner().invoke(cli, ["sync", "widget", "--dry-run", "--json"]) + # It must NOT have materialized an out-of-tree target... + assert not outside.exists(), f"CLI dry-run created out-of-tree {outside}" + assert not (tmp_path.parent / "escape_cli").exists() + # ...AND it must surface the unsafe config as a hard failure, not silently + # accept it: the run is not ok and the offending unit is reported failed with + # an out-of-tree path-resolution reason. + payload = json.loads(result.output) + assert payload["ok"] is False, result.output + reported = payload.get("failures", []) + payload.get("units", []) + assert any( + u.get("classification") == "FAILURE" + and "resolves outside" in (u.get("reason") or "") + for u in reported + ), result.output + + +# --- Additional tests appended --- + - def fake_exists(path): - if path == lower_candidate: - return True - return original_exists(path) - monkeypatch.setattr(Path, "exists", fake_exists) +import sys +from pathlib import Path - assert _case_insensitive_path_lookup(lower_candidate) == actual_prompt +# 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 TestEstimateOperationCost: + """Tests for estimate_operation_cost pricing map.""" -def test_get_pdd_file_paths_preserves_existing_mixed_case_prompt_suffix(tmp_path, monkeypatch): - """CI drift path should return the Git-tracked mixed-case prompt path.""" - monkeypatch.chdir(tmp_path) + def test_known_operations_return_positive_cost(self): + from sync_determine_operation import estimate_operation_cost + for op in ('generate', 'auto-deps', 'example', 'crash', 'verify', 'test', 'test_extend', 'fix', 'update'): + assert estimate_operation_cost(op) > 0.0, f"{op} should have positive cost" - prompts_dir = tmp_path / "prompts" - prompts_dir.mkdir() - actual_prompt = prompts_dir / "api_pipeline_compositions_route_TypeScript.prompt" - actual_prompt.write_text("Generate API route") - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - (tmp_path / ".pdd" / "locks").mkdir(parents=True) + def test_no_op_operations_return_zero_cost(self): + from sync_determine_operation import estimate_operation_cost + for op in ('nothing', 'all_synced', 'error', 'fail_and_request_manual_merge'): + assert estimate_operation_cost(op) == 0.0 - paths = get_pdd_file_paths( - "api_pipeline_compositions_route", "typescript", "prompts" - ) + def test_unknown_operation_returns_zero(self): + from sync_determine_operation import estimate_operation_cost + assert estimate_operation_cost('bogus_never_defined_op') == 0.0 - assert paths["prompt"] == actual_prompt.relative_to(tmp_path) + def test_generate_costs_more_than_update(self): + from sync_determine_operation import estimate_operation_cost + assert estimate_operation_cost('generate') > estimate_operation_cost('update') -def test_get_pdd_file_paths_nested_subdir_case_mismatch(tmp_path, monkeypatch): - """Bug #1169: get_pdd_file_paths must find prompts in nested subdirs with case mismatch. +class TestCheckForDependencies: + """Tests for check_for_dependencies content scanning.""" - Production scenario: prompt at prompts/src/clients/firestore_client_Python.prompt - but get_pdd_file_paths("firestore_client", "python", "prompts") constructs - prompts/firestore_client_python.prompt (wrong case, wrong dir). - """ - monkeypatch.chdir(tmp_path) + def test_detects_include_xml_tag(self): + from sync_determine_operation import check_for_dependencies + assert check_for_dependencies("some prompt foo.py more") is True - # Create nested directory structure matching the issue's reproduction - nested_dir = tmp_path / "prompts" / "src" / "clients" - nested_dir.mkdir(parents=True) - actual_prompt = nested_dir / "firestore_client_Python.prompt" - actual_prompt.write_text("Generate Firestore client") + def test_detects_web_xml_tag(self): + from sync_determine_operation import check_for_dependencies + assert check_for_dependencies("look up https://example.com") is True - # Create minimal .pdd structure - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - (tmp_path / ".pdd" / "locks").mkdir(parents=True) + def test_detects_shell_xml_tag(self): + from sync_determine_operation import check_for_dependencies + assert check_for_dependencies("run ls -la") is True - paths = get_pdd_file_paths("firestore_client", "python", "prompts") + def test_detects_explicit_mention_case_insensitive(self): + from sync_determine_operation import check_for_dependencies + assert check_for_dependencies("This prompt REQUIRES DEPENDENCIES to work.") is True + assert check_for_dependencies("Use auto-deps to resolve.") is True + assert check_for_dependencies("include dependencies here") is True - # BUG: Current code constructs prompts/firestore_client_python.prompt (wrong case + wrong dir) - # After fix, should resolve to the actual file in the nested subdirectory - assert paths['prompt'].exists(), ( - f"Bug #1169: prompt not found. Got path: {paths['prompt']}. " - f"Expected to find: {actual_prompt}" - ) + def test_no_dependencies_in_plain_prompt(self): + from sync_determine_operation import check_for_dependencies + assert check_for_dependencies("Just write a simple add function.") is False + def test_empty_string_no_deps(self): + from sync_determine_operation import check_for_dependencies + assert check_for_dependencies("") is False -def test_get_pdd_file_paths_architecture_json_nested_subdir(tmp_path, monkeypatch): - """Bug #1169: architecture.json lookup must resolve prompts in nested subdirs. - When architecture.json has filename: "firestore_client_Python.prompt", - _resolve_prompt_path_from_architecture naively joins to prompts_root (flat), - missing the actual file in prompts/src/clients/. - """ - monkeypatch.chdir(tmp_path) +class TestSyncDecisionDataclass: + """SyncDecision default values and construction.""" - # Create nested prompt file - nested_dir = tmp_path / "prompts" / "src" / "clients" - nested_dir.mkdir(parents=True) - actual_prompt = nested_dir / "firestore_client_Python.prompt" - actual_prompt.write_text("Generate Firestore client") + def test_defaults(self): + d = SyncDecision(operation='nothing', reason='r') + assert d.confidence == 1.0 + assert d.estimated_cost == 0.0 + assert d.details is None + assert d.prerequisites is None - # Create code file - code_dir = tmp_path / "src" / "clients" - code_dir.mkdir(parents=True) - (code_dir / "firestore_client.py").write_text("# client code") + def test_full_construction(self): + d = SyncDecision( + operation='generate', reason='r', confidence=0.5, + estimated_cost=0.25, details={'k': 'v'}, prerequisites=['test'] + ) + assert d.confidence == 0.5 + assert d.estimated_cost == 0.25 + assert d.details == {'k': 'v'} + assert d.prerequisites == ['test'] - # Create architecture.json with filename (not full path) - arch = { - "modules": [{ - "filename": "firestore_client_Python.prompt", - "filepath": "src/clients/firestore_client.py" - }] - } - (tmp_path / "architecture.json").write_text(json.dumps(arch)) - # Create minimal .pdd structure - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - (tmp_path / ".pdd" / "locks").mkdir(parents=True) +class TestFingerprintAndRunReportOptionals: + """Verify optional fields on Fingerprint/RunReport.""" - paths = get_pdd_file_paths("firestore_client", "python", "prompts") + def test_fingerprint_optional_fields_default_none(self): + fp = Fingerprint( + pdd_version="1.0", timestamp="t", command="generate", + prompt_hash=None, code_hash=None, example_hash=None, test_hash=None, + ) + assert fp.test_files is None + assert fp.include_deps is None - # BUG: _resolve_prompt_path_from_architecture joins prompts_root + filename → flat path - # Then _case_insensitive_prompt_lookup searches only prompts/, not prompts/src/clients/ - assert paths['prompt'].exists(), ( - f"Bug #1169: prompt not found via architecture.json path. Got: {paths['prompt']}. " - f"Expected to resolve to: {actual_prompt}" - ) - # Verify code path resolves correctly from architecture.json - assert "firestore_client.py" in str(paths['code']) + def test_run_report_optional_fields_default_none(self): + rr = RunReport( + timestamp="t", exit_code=0, tests_passed=0, tests_failed=0, coverage=0.0 + ) + assert rr.test_hash is None + assert rr.test_files is None -def test_get_pdd_file_paths_nested_subdir_logging_shows_exists_true(tmp_path, monkeypatch, caplog): - """Bug #1169: Logging must show exists=True when prompt is in nested subdir. +class TestReadFingerprintAndRunReportMissing: + """Missing metadata files return None.""" - Production logs showed 'exists=False' for every sync attempt on nested prompts. - After fix, the log should show exists=True. - """ - import logging - monkeypatch.chdir(tmp_path) + def test_read_fingerprint_missing_returns_none(self, pdd_test_environment): + assert read_fingerprint("nonexistent_module_xyz", "python") is None - # Create nested prompt file - nested_dir = tmp_path / "prompts" / "src" / "clients" - nested_dir.mkdir(parents=True) - actual_prompt = nested_dir / "firestore_client_Python.prompt" - actual_prompt.write_text("Generate Firestore client") + def test_read_run_report_missing_returns_none(self, pdd_test_environment): + assert read_run_report("nonexistent_module_xyz", "python") is None - # Create minimal .pdd structure - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - (tmp_path / ".pdd" / "locks").mkdir(parents=True) + def test_read_run_report_invalid_json_returns_none(self, pdd_test_environment): + rr_path = get_meta_dir() / "bad_python_run.json" + rr_path.write_text("{ not valid json ") + assert read_run_report("bad", "python") is None - with caplog.at_level(logging.INFO, logger="pdd.sync_determine_operation"): - paths = get_pdd_file_paths("firestore_client", "python", "prompts") - # BUG: Current code logs "exists=False" because it looks in prompts/ not prompts/src/clients/ - exists_log_entries = [r for r in caplog.records if "exists=" in r.message] - assert any("exists=True" in r.message for r in exists_log_entries), ( - f"Bug #1169: Expected 'exists=True' in logs but got: " - f"{[r.message for r in exists_log_entries]}" - ) +class TestCalculateSha256EdgeCases: + """calculate_sha256 additional edge cases.""" + def test_empty_file_returns_known_hash(self, tmp_path): + f = tmp_path / "empty.txt" + f.write_text("") + # SHA256 of empty string + assert calculate_sha256(f) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -def test_resolve_prompt_path_from_architecture_flat_filename_misses_subdirectory(tmp_path): - """Bug #1169: _resolve_prompt_path_from_architecture produces wrong path for flat filenames. + def test_directory_path_returns_none(self, tmp_path): + # Passing a directory should not raise; returns None (IOError branch) + assert calculate_sha256(tmp_path) is None - When architecture.json has filename: "firestore_client_Python.prompt" (no subdirectory), - _resolve_prompt_path_from_architecture joins it to prompts_root producing - prompts/firestore_client_Python.prompt. But the file is actually at - prompts/src/clients/firestore_client_Python.prompt. - The resolved path must exist when the file is in a nested subdirectory. - This tests that the architecture.json lookup + path resolution chain - produces a path that actually resolves to the file on disk. - """ - prompts_root = tmp_path / "prompts" - nested_dir = prompts_root / "src" / "clients" - nested_dir.mkdir(parents=True) - actual_file = nested_dir / "firestore_client_Python.prompt" - actual_file.write_text("prompt content") +class TestSyncLockReleaseWithoutAcquire: + """SyncLock.release is safe when nothing was acquired.""" - # This is what happens in the buggy code: architecture.json returns just the filename - resolved = _resolve_prompt_path_from_architecture( - prompts_root, "firestore_client_Python.prompt" - ) + def test_release_without_acquire_is_noop(self, pdd_test_environment): + lock = SyncLock(BASENAME, LANGUAGE) + # Should not raise + lock.release() + assert not (get_locks_dir() / f"{BASENAME}_{LANGUAGE}.lock").exists() - # BUG: resolved is prompts/firestore_client_Python.prompt which doesn't exist - # The file is at prompts/src/clients/firestore_client_Python.prompt - # After fix, either _resolve_prompt_path_from_architecture or its caller - # must search subdirectories to find the actual file - # Post-#1169: _resolve_prompt_path_from_architecture itself performs the - # recursive case-insensitive search when the naive join misses. The - # returned path must point at the real file on disk. - assert resolved.exists(), ( - f"Bug #1169: architecture.json flat filename must resolve to the nested file. " - f"Resolved: {resolved}. Actual file: {actual_file}" - ) - assert resolved == actual_file, ( - f"Bug #1169: expected resolved path to equal {actual_file}, got {resolved}" - ) +class TestReadOnlySkipsLock: + """read_only=True should bypass SyncLock just like log_mode.""" -def test_get_pdd_file_paths_deep_nesting_different_language(tmp_path, monkeypatch): - """Bug #1169: Nested subdirectory resolution must work for all languages. + @patch('sync_determine_operation.construct_paths') + def test_read_only_skips_lock(self, mock_construct, pdd_test_environment): + with patch('sync_determine_operation.SyncLock') as mock_lock: + sync_determine_operation(BASENAME, LANGUAGE, TARGET_COVERAGE, read_only=True) + mock_lock.assert_not_called() - Tests deeper nesting with TypeScript to ensure the fix isn't Python-specific. +# --------------------------------------------------------------------------- +# Round-2 review hardening: lock-name confinement (SyncLock) and portable/ +# canonical validation of .pddrc output destinations (R16 / R9 parity). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "malicious_language", + [ + "/../../../../tmp/pdd-victim", + "..\\..\\pdd-victim", + "python/../../../etc/pdd", + ], +) +def test_sync_lock_language_cannot_escape_locks_dir(tmp_path, monkeypatch, malicious_language): + """A traversal/separator-bearing language must not let the lock file escape. + + `SyncLock` is constructed from raw basename/language BEFORE get_pdd_file_paths + validates them. The lock filename must be a sanitized, separator-free token so + it always resolves under the locks directory (no out-of-tree mkdir/touch/unlink). """ + import sync_determine_operation as sync_determine_module + monkeypatch.chdir(tmp_path) + (tmp_path / ".pdd" / "locks").mkdir(parents=True) + locks_dir = sync_determine_module.get_locks_dir().resolve(strict=False) + lock = sync_determine_module.SyncLock("safe", malicious_language) + resolved = lock.lock_file.resolve(strict=False) + assert locks_dir in resolved.parents, f"{resolved} escaped {locks_dir}" + assert "/" not in lock.lock_file.name and "\\" not in lock.lock_file.name - # Create deeply nested prompt file with TypeScript language - deep_dir = tmp_path / "prompts" / "api" / "v2" / "handlers" - deep_dir.mkdir(parents=True) - actual_prompt = deep_dir / "user_handler_TypeScript.prompt" - actual_prompt.write_text("Generate user handler") - # Create minimal .pdd structure - (tmp_path / ".pdd" / "meta").mkdir(parents=True) +def test_sync_determine_operation_malicious_language_writes_nothing_out_of_tree(tmp_path, monkeypatch): + """Mutable entrypoint: the SyncLock the real (non-read-only) path constructs from a + traversal-bearing language — before input validation — must resolve inside the locks + directory. Captures the constructed lock path so the assertion is load-bearing + regardless of lock-file cleanup timing.""" + import sync_determine_operation as sync_determine_module + + monkeypatch.chdir(tmp_path) (tmp_path / ".pdd" / "locks").mkdir(parents=True) + (tmp_path / ".pdd" / "meta").mkdir(parents=True) - paths = get_pdd_file_paths("user_handler", "typescript", "prompts") + captured = {} + original_init = sync_determine_module.SyncLock.__init__ - # BUG: Constructs prompts/user_handler_typescript.prompt (wrong case + wrong dir) - assert paths['prompt'].exists(), ( - f"Bug #1169: prompt not found for TypeScript in deep nesting. Got: {paths['prompt']}. " - f"Expected to find: {actual_prompt}" + def _spy_init(self, basename, language): + original_init(self, basename, language) + captured["lock_file"] = self.lock_file + + monkeypatch.setattr(sync_determine_module.SyncLock, "__init__", _spy_init) + try: + # Not read-only/log: this path acquires SyncLock before validation. + sync_determine_module.sync_determine_operation( + "safe", "/../../tmp-pdd-victim", 90.0, budget=1.0, + ) + except Exception: + pass # a hard validation error downstream is acceptable; the write is not + assert "lock_file" in captured, "the mutable path must construct a SyncLock" + locks_dir = sync_determine_module.get_locks_dir().resolve(strict=False) + resolved_lock = captured["lock_file"].resolve(strict=False) + assert locks_dir in resolved_lock.parents, ( + f"lock {resolved_lock} escaped locks dir {locks_dir}" ) + assert "/" not in captured["lock_file"].name and "\\" not in captured["lock_file"].name -def _write_frontend_page_test_config(tmp_path: Path) -> None: - """Create the minimal repo config needed for nested frontend page path resolution.""" - config = { - "contexts": { - "frontend": { - "paths": ["frontend/**", "prompts/frontend/**"], - "defaults": { - "prompts_dir": "prompts/frontend", - "generate_output_path": "frontend/src", - "default_language": "typescriptreact", - "strength": 0.818, - "outputs": { - "prompt": { - "path": "prompts/frontend/{dir_prefix}{name}_{language}.prompt" - } - }, - }, - }, - "default": {"defaults": {}}, - } - } - (tmp_path / ".pddrc").write_text(json.dumps(config)) - (tmp_path / ".pdd" / "meta").mkdir(parents=True) - (tmp_path / ".pdd" / "locks").mkdir(parents=True) +@pytest.mark.parametrize( + "defaults_yaml", + [ + ' generate_output_path: "CON/"\n', # reserved device dir + ' example_output_path: "custom/usage/"\n', # SAFE custom in-project dir (sanity) + ' outputs:\n example:\n path: "src/file:stream.py"\n', # NTFS ADS colon + ' outputs:\n test:\n path: "C:/victim.py"\n', # drive marker + ' outputs:\n example:\n path: "src/../other.py"\n', # normalized-away .. + ' outputs:\n code:\n path: "sub/CON/x.py"\n', # device mid-path + ], +) +def test_get_pdd_file_paths_rejects_nonportable_or_traversal_pddrc_output(tmp_path, monkeypatch, defaults_yaml): + """R16/R9 parity: .pddrc output destinations get the same portable/canonical + validation as architecture code filepaths — CWD-independent and before resolve().""" + monkeypatch.chdir(tmp_path) + # The 'custom/usage/' case is intentionally SAFE (a plain in-project dir); no raise. + expect_safe = "custom/usage" in defaults_yaml + _write_escape_pddrc_project(tmp_path, defaults_yaml, with_arch=True) + if expect_safe: + paths = get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + assert paths["example"].resolve(strict=False).is_relative_to(tmp_path.resolve()) + else: + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") -def test_get_pdd_file_paths_nested_frontend_page_prefers_nested_prompt(tmp_path, monkeypatch): - """Nested frontend page basenames must not collapse to the flat `page` prompt.""" +# --------------------------------------------------------------------------- +# Round-3 review hardening: outputs.prompt.path containment (R8), single +# provenance-based output root (R16, no CWD authority), nearer-.pddrc portable +# validation, and fail-closed missing-prompt fallback. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "prompt_template", + ['/tmp/foreign/{name}_{language}.prompt', '../../../foreign/{name}_{language}.prompt'], +) +def test_get_pdd_file_paths_rejects_outputs_prompt_path_escape(tmp_path, monkeypatch, prompt_template): + """R8/R16: an `outputs.prompt.path` template must not return a prompt outside the + prompts root (a later `update` would overwrite that foreign file).""" monkeypatch.chdir(tmp_path) - _write_frontend_page_test_config(tmp_path) + (tmp_path / "prompts").mkdir(parents=True) + (tmp_path / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["**"]\n defaults:\n' + ' outputs:\n prompt:\n path: "' + prompt_template + '"\n', + encoding="utf-8", + ) + with pytest.raises((UnsafePromptPathError, UnsafeOutputPathError)): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + + +def test_get_pdd_file_paths_parent_cwd_sibling_output_stays_under_project(tmp_path, monkeypatch): + """R16: a benign relative `.pddrc` output (no `..`) resolved from a PARENT CWD must + land UNDER the governing project, not beside it — CWD does not widen the boundary.""" + parent = tmp_path + project = parent / "project" + (project / "prompts").mkdir(parents=True) + (project / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (project / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["**"]\n defaults:\n' + ' generate_output_path: "sibling-output/"\n', + encoding="utf-8", + ) + monkeypatch.chdir(parent) # PARENT of the governing project + paths = get_pdd_file_paths( + "widget", "python", + prompts_dir=str((project / "prompts").resolve()), + context_override="backend", + ) + code = paths["code"].resolve(strict=False) + assert code.is_relative_to(project.resolve()), f"{code} escaped project {project}" + assert not (parent / "sibling-output").exists(), "created a sibling dir outside the project" + + +def test_get_pdd_file_paths_nearer_pddrc_nonportable_output_fails_closed(tmp_path, monkeypatch): + """R16: a non-portable output (reserved device) from a NEARER descendant `.pddrc` + that the early raw gate did not see is still rejected at the resolved-path check.""" + project = tmp_path + sub = project / "pkg" + (sub / "prompts").mkdir(parents=True) + (sub / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (project / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n generate_output_path: "src/"\n', + encoding="utf-8", + ) + (sub / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n generate_output_path: "CON/"\n', + encoding="utf-8", + ) + monkeypatch.chdir(sub) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts") - flat_prompt = tmp_path / "prompts" / "frontend" / "page_TypescriptReact.prompt" - flat_prompt.parent.mkdir(parents=True) - flat_prompt.write_text("flat page prompt") - nested_prompt = tmp_path / "prompts" / "frontend" / "app" / "settings" / "account" / "page_TypescriptReact.prompt" - nested_prompt.parent.mkdir(parents=True) - nested_prompt.write_text("account page prompt") +def test_get_pdd_file_paths_missing_prompt_escaping_output_not_swallowed(tmp_path, monkeypatch): + """A hard out-of-tree output error on the MISSING-prompt path must fail closed, not + be swallowed by the broad construct_paths fallback into an unvalidated target.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir(parents=True) # prompt does NOT exist -> missing-prompt branch + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["**"]\n defaults:\n' + ' generate_output_path: "../../escape_missing/"\n', + encoding="utf-8", + ) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("newmodule", "python", prompts_dir="prompts", context_override="backend") + assert not (tmp_path.parent.parent / "escape_missing").exists() - paths = get_pdd_file_paths("frontend/app/settings/account/page", "typescriptreact", "prompts") - assert paths["prompt"].is_file() - assert paths["prompt"].samefile(nested_prompt) - assert paths["code"].resolve() == (tmp_path / "frontend" / "src" / "app" / "settings" / "account" / "page.tsx").resolve() +# --------------------------------------------------------------------------- +# Round-4 review hardening: explicit absolute .pddrc destinations that point +# outside the project fail closed (not silently re-anchored into the project), +# and control-bearing components are rejected on the resolved path too. +# --------------------------------------------------------------------------- -def test_get_pdd_file_paths_nested_frontend_page_uses_context_relative_architecture_entry(tmp_path, monkeypatch): - """Relative architecture filenames like app/settings/security/page_* must resolve correctly.""" +@pytest.mark.parametrize( + "defaults_yaml", + [ + ' generate_output_path: "/work/foreign/"\n', + ' example_output_path: "/etc/pdd-out/"\n', + ' outputs:\n test:\n path: "/tmp/foreign/test_{name}.py"\n', + ], +) +def test_get_pdd_file_paths_rejects_absolute_escape_pddrc_output(tmp_path, monkeypatch, defaults_yaml): + """R16: an explicit absolute `.pddrc` output pointing OUTSIDE the project must fail + closed — it must NOT be silently re-anchored into a (wrong) in-project location.""" monkeypatch.chdir(tmp_path) - _write_frontend_page_test_config(tmp_path) + _write_escape_pddrc_project(tmp_path, defaults_yaml, with_arch=True) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") - flat_prompt = tmp_path / "prompts" / "frontend" / "page_TypescriptReact.prompt" - flat_prompt.parent.mkdir(parents=True) - flat_prompt.write_text("flat page prompt") - security_prompt = tmp_path / "prompts" / "frontend" / "app" / "settings" / "security" / "page_TypescriptReact.prompt" - security_prompt.parent.mkdir(parents=True) - security_prompt.write_text("security page prompt") +def test_get_pdd_file_paths_rejects_control_component_pddrc_output(tmp_path, monkeypatch): + """R16: a control-character component in a `.pddrc` output is rejected (raw gate and, + for values that reach a sink via a nearer config, the resolved-path component check).""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, ' generate_output_path: "bad\\u000aname/"\n', with_arch=True + ) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + - (tmp_path / "architecture.json").write_text(json.dumps([ - { - "filename": "app/settings/security/page_TypescriptReact.prompt", - "filepath": "frontend/src/app/settings/security/page.tsx", - }, - { - "filename": "page_TypescriptReact.prompt", - "filepath": "frontend/src/app/settings/github/page.tsx", - }, - ])) +# --------------------------------------------------------------------------- +# Round-5 review hardening: a present NON-STRING .pddrc output value is malformed +# and must fail closed — it must not slip past string validation, raise inside +# str-only path handling, and degrade to an uncontained parent-CWD fallback. +# --------------------------------------------------------------------------- - paths = get_pdd_file_paths("frontend/app/settings/security/page", "typescriptreact", "prompts") - assert paths["prompt"].is_file() - assert paths["prompt"].samefile(security_prompt) - assert paths["code"].resolve() == (tmp_path / "frontend" / "src" / "app" / "settings" / "security" / "page.tsx").resolve() +@pytest.mark.parametrize("bad_value", ["123", "3.14", "[]", "{}", "true"]) +def test_get_pdd_file_paths_rejects_nonstring_pddrc_output(tmp_path, monkeypatch, bad_value): + """R16: a truthy non-string generate_output_path from a PARENT CWD must fail closed, + not fall through to an out-of-project convention path.""" + parent = tmp_path + project = parent / "project" + (project / "prompts").mkdir(parents=True) + (project / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (project / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["**"]\n defaults:\n' + ' generate_output_path: ' + bad_value + '\n', + encoding="utf-8", + ) + monkeypatch.chdir(parent) # parent CWD is where the unsafe fallback would land + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths( + "widget", "python", + prompts_dir=str((project / "prompts").resolve()), + context_override="backend", + ) -def test_get_pdd_file_paths_nested_frontend_page_rejects_wrong_flat_architecture_match(tmp_path, monkeypatch): - """Flat architecture entries must not steal other nested page basenames with the same filename.""" +def test_get_pdd_file_paths_rejects_nonstring_outputs_template_path(tmp_path, monkeypatch): + """R16: a non-string outputs..path template value is malformed -> fail closed.""" monkeypatch.chdir(tmp_path) - _write_frontend_page_test_config(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' outputs:\n example:\n path: 123\n', + with_arch=True, + ) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") - flat_prompt = tmp_path / "prompts" / "frontend" / "page_TypescriptReact.prompt" - flat_prompt.parent.mkdir(parents=True) - flat_prompt.write_text("flat page prompt") - account_prompt = tmp_path / "prompts" / "frontend" / "app" / "settings" / "account" / "page_TypescriptReact.prompt" - account_prompt.parent.mkdir(parents=True) - account_prompt.write_text("account page prompt") +# --------------------------------------------------------------------------- +# Round-6 review hardening: discovered test_files are contained (an escaping +# symlink is dropped, never handed to a runner); a nearer descendant .pddrc is +# raw-validated (normalized-away `..`); a non-string configured prompts_dir +# fails closed instead of degrading to a wrong convention path. +# --------------------------------------------------------------------------- - github_prompt = tmp_path / "prompts" / "frontend" / "app" / "settings" / "github" / "page_TypescriptReact.prompt" - github_prompt.parent.mkdir(parents=True) - github_prompt.write_text("github page prompt") - (tmp_path / "architecture.json").write_text(json.dumps([ - { - "filename": "page_TypescriptReact.prompt", - "filepath": "frontend/src/app/settings/github/page.tsx", - }, - ])) +def test_get_pdd_file_paths_drops_escaping_test_file_symlink(tmp_path, monkeypatch): + """R16: a discovered test_files entry that resolves outside the project (via symlink) + is dropped from the returned list so it is never executed by the test runner.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir(parents=True) + (tmp_path / "tests").mkdir() + (tmp_path / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n' + ' generate_output_path: "src/"\n test_output_path: "tests/"\n', + encoding="utf-8", + ) + (tmp_path / "tests" / "test_widget.py").write_text("x = 1\n", encoding="utf-8") + foreign = tmp_path.parent / "sdo_foreign_test.py" + foreign.write_text("raise SystemExit\n", encoding="utf-8") + try: + (tmp_path / "tests" / "test_widget_extra.py").symlink_to(foreign) + except OSError: + pytest.skip("symlinks unavailable") + try: + paths = get_pdd_file_paths("widget", "python", prompts_dir="prompts") + root = tmp_path.resolve() + for tf in paths.get("test_files", []): + assert Path(tf).resolve(strict=False).is_relative_to(root), ( + f"escaping test file returned: {tf}" + ) + finally: + foreign.unlink(missing_ok=True) + + +def test_get_pdd_file_paths_rejects_nearer_pddrc_normalized_traversal(tmp_path, monkeypatch): + """R16: a nearer descendant `.pddrc` output with `safe/../src/` (which resolve() + would normalize back inside the project) still fails closed.""" + project = tmp_path + sub = project / "pkg" + (sub / "prompts").mkdir(parents=True) + (sub / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (project / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n generate_output_path: "src/"\n', + encoding="utf-8", + ) + (sub / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n generate_output_path: "safe/../src/"\n', + encoding="utf-8", + ) + monkeypatch.chdir(sub) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts") - github_paths = get_pdd_file_paths("frontend/app/settings/github/page", "typescriptreact", "prompts") - assert github_paths["prompt"].is_file() - assert github_paths["prompt"].samefile(github_prompt) - assert github_paths["code"].resolve() == (tmp_path / "frontend" / "src" / "app" / "settings" / "github" / "page.tsx").resolve() - account_paths = get_pdd_file_paths("frontend/app/settings/account/page", "typescriptreact", "prompts") - assert account_paths["prompt"].is_file() - assert account_paths["prompt"].samefile(account_prompt) - assert account_paths["code"].resolve() == (tmp_path / "frontend" / "src" / "app" / "settings" / "account" / "page.tsx").resolve() +def test_get_pdd_file_paths_rejects_nonstring_prompts_dir_config(tmp_path, monkeypatch): + """A present non-string `.pddrc` prompts_dir is malformed and must fail closed, not + degrade to a wrong convention path.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir(parents=True) + (tmp_path / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["**"]\n defaults:\n' + ' prompts_dir: 123\n generate_output_path: "src/"\n', + encoding="utf-8", + ) + with pytest.raises((UnsafeOutputPathError, UnsafePromptPathError)): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") -# --- Issue #1256: Dict-format architecture tolerance --- -# Scope addition: covers expansion item "pdd/sync_determine_operation.py:340-342 -# has partial tolerance but should use centralized extract_modules() for consistency" -# identified by Step 6 but absent from Step 8's plan +# --------------------------------------------------------------------------- +# Round-7 review hardening: absolute outputs templates (which template +# normalization would mangle into a doubled path) and malformed `outputs` +# shapes fail closed instead of silently degrading to a convention path. +# --------------------------------------------------------------------------- -def test_get_filepath_dict_format_without_modules_key_returns_tuple(tmp_path): - """_get_filepath_from_architecture returns (None, None) for dict without 'modules' key (Test 18). +def test_get_pdd_file_paths_rejects_absolute_outputs_template(tmp_path, monkeypatch): + """R16: an absolute `outputs..path` template is rejected — template + normalization strips its leading slash and would double-anchor it under the root.""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' outputs:\n code:\n path: "' + + str((tmp_path / "src").resolve()) + '/{name}.py"\n', + with_arch=True, + ) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") - Bug: at sync_determine_operation.py:340, arch.get("modules", arch) falls back to - the dict itself when there is no "modules" key. Then isinstance(modules, list) - is False and line 343 returns bare None instead of the expected (None, None) tuple. - Callers that unpack `filepath, filename = _get_filepath_from_architecture(...)` crash - with TypeError. - """ - from pdd.sync_determine_operation import _get_filepath_from_architecture - arch_path = tmp_path / "architecture.json" - # Dict without "modules" key — triggers the fallback bug - arch_path.write_text(json.dumps({"prd_files": ["prd.md"]}), encoding="utf-8") +@pytest.mark.parametrize( + "outputs_yaml", + [ + ' outputs:\n code: "src/{name}.py"\n', # entry is a bare string, not {path:...} + ' outputs: "not-a-mapping"\n', # outputs is not a mapping + ' outputs:\n code:\n path: 123\n', # path is non-string + ], +) +def test_get_pdd_file_paths_rejects_malformed_outputs_shape(tmp_path, monkeypatch, outputs_yaml): + """R16: a malformed `outputs` mapping is rejected rather than silently ignored and + degraded to a convention path.""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project(tmp_path, outputs_yaml, with_arch=True) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") - result = _get_filepath_from_architecture(arch_path, "auth_Python.prompt") - assert result == (None, None), ( - f"Expected (None, None) for dict without 'modules' key, got {result!r}. " - "Line 343 returns bare None instead of the expected (None, None) tuple." + +def test_reject_unsafe_pddrc_output_config_rejects_normalized_traversal(tmp_path): + """Isolates the nearer-.pddrc revalidation: `_reject_unsafe_pddrc_output_config` + (called on the resolved prompt's directory in the finalizer) rejects a raw `..` + even though it would normalize back inside the project.""" + import sync_determine_operation as sync_determine_module + + (tmp_path / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n generate_output_path: "safe/../src/"\n', + encoding="utf-8", ) + with pytest.raises(UnsafeOutputPathError): + sync_determine_module._reject_unsafe_pddrc_output_config(tmp_path) # --------------------------------------------------------------------------- -# Issue #1201: generate_output_path from .pddrc not honored in arch branch -# --------------------------------------------------------------------------- -# The architecture.json branch of get_pdd_file_paths() at line 942 sets -# code_path = project_root / arch_filepath -# with no consultation of .pddrc's generate_output_path. Meanwhile, -# lines 961-962 correctly read test_dir and example_dir from .pddrc defaults. -# All four tests below FAIL on the current (buggy) code and must PASS after -# the fix that reads generate_output_path from .pddrc in the same block. +# Round-8 review hardening: a relative outputs.prompt.path is anchored at the +# governing project root (not CWD); an outputs entry without a `path` fails +# closed instead of silently suppressing the configured legacy fallback. # --------------------------------------------------------------------------- -class TestIssue1201GenerateOutputPathInArchBranch: - """Issue #1201: generate_output_path is silently ignored when architecture.json - provides a filepath, unlike example_output_path and test_output_path which are - correctly applied from .pddrc defaults. - """ - def _write_pddrc(self, tmp_path: Path, generate_dir: str = "src/", - test_dir: str = "tests/", example_dir: str = "examples/") -> None: - (tmp_path / ".pddrc").write_text( - f"contexts:\n" - f" default:\n" - f" paths: [\"**\"]\n" - f" defaults:\n" - f" generate_output_path: \"{generate_dir}\"\n" - f" test_output_path: \"{test_dir}\"\n" - f" example_output_path: \"{example_dir}\"\n" - ) +def test_get_pdd_file_paths_outputs_prompt_path_anchored_from_parent_cwd(tmp_path, monkeypatch): + """R8/Issue #237: a relative `outputs.prompt.path` resolves under the governing + project even when sync is driven from a PARENT CWD (not beside it).""" + parent = tmp_path + project = parent / "project" + (project / "api").mkdir(parents=True) + (project / "custom" / "prompts").mkdir(parents=True) + (project / "custom" / "prompts" / "users_python.prompt").write_text("% users\n", encoding="utf-8") + (project / ".pddrc").write_text( + 'version: "1.0"\ncontexts:\n api:\n paths: ["api/**", "custom/prompts/**"]\n' + ' defaults:\n default_language: "python"\n outputs:\n' + ' prompt:\n path: "custom/prompts/{name}_{language}.prompt"\n' + ' code:\n path: "src/api/{name}.py"\n', + encoding="utf-8", + ) + monkeypatch.chdir(parent) # PARENT of the governing project + paths = get_pdd_file_paths( + "users", "python", + prompts_dir=str((project / "custom" / "prompts").resolve()), + context_override="api", + ) + prompt = paths["prompt"].resolve(strict=False) + assert prompt.is_relative_to(project.resolve()), f"{prompt} not under project {project}" + assert prompt == (project / "custom" / "prompts" / "users_python.prompt").resolve() - def _write_arch_json(self, tmp_path: Path, prompt_filename: str, filepath: str) -> None: - (tmp_path / "architecture.json").write_text(json.dumps({ - "modules": [{"filename": prompt_filename, "filepath": filepath}] - })) - def _setup_dirs(self, tmp_path: Path) -> None: - for d in ("prompts", ".pdd/meta", ".pdd/locks"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) +def test_get_pdd_file_paths_rejects_pathless_outputs_entry(tmp_path, monkeypatch): + """R16: an `outputs` artifact entry with no (or empty) `path` is malformed — its + key presence would suppress the configured legacy fallback and silently degrade to + a convention path — so it fails closed.""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' generate_output_path: "src/"\n outputs:\n code: {}\n', + with_arch=True, + ) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") +def test_v1_hash_matches_base_whitespace_cwd_and_invalid_utf8(tmp_path, monkeypatch): + prompt_dir = tmp_path / "prompts" + cwd = tmp_path / "cwd" + prompt_dir.mkdir() + cwd.mkdir() + dependency = cwd / "shared.bin" + dependency.write_bytes(b"dependency\xff") + prompt = prompt_dir / "widget.prompt" + prompt.write_bytes(b" shared.bin \ninvalid:\xff\n") + monkeypatch.chdir(cwd) + expected = hashlib.sha256(prompt.read_bytes() + dependency.read_bytes()).hexdigest() + assert calculate_prompt_hash(prompt, hash_version=1) == expected - def test_generate_output_path_honored_when_arch_filepath_is_bare(self, tmp_path, monkeypatch): - """code_path must land in .pddrc generate_output_path when arch.json filepath has no - directory component (i.e., is a bare filename at the project root). - Bug: code_path = project_root / arch_filepath uses root unconditionally. - Fix: read generate_output_path from .pddrc defaults and apply it to code_path, - exactly as example_dir/test_dir are already applied. - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - self._write_pddrc(tmp_path, generate_dir="src/") - self._write_arch_json(tmp_path, "widget_python.prompt", "widget.py") +def test_v1_hash_resolves_stored_relative_keys_from_cwd(tmp_path, monkeypatch): + prompt_dir = tmp_path / "prompts" + cwd = tmp_path / "cwd" + prompt_dir.mkdir() + cwd.mkdir() + dependency = cwd / "stored.txt" + dependency.write_bytes(b"stored") + prompt = prompt_dir / "widget.prompt" + prompt.write_bytes(b"no includes\n") + monkeypatch.chdir(cwd) + expected = hashlib.sha256(prompt.read_bytes() + dependency.read_bytes()).hexdigest() + assert calculate_prompt_hash( + prompt, {"stored.txt": "ignored"}, hash_version=1 + ) == expected - paths = get_pdd_file_paths("widget", "python", "prompts") - assert "src" in paths["code"].parts, ( - f"generate_output_path 'src/' from .pddrc must be applied to code path in the " - f"architecture.json branch, but got: {paths['code']!r} (parent: {paths['code'].parent!r})" - ) +def test_v1_old_grammar_ignores_self_closing_and_path_attributes(tmp_path): + prompt = tmp_path / "widget.prompt" + prompt.write_text( + '\nmissing.txt\n' + ) + assert calculate_prompt_hash(prompt, hash_version=1) == hashlib.sha256( + prompt.read_bytes() + ).hexdigest() - def test_all_three_output_paths_applied_symmetrically_in_arch_branch(self, tmp_path, monkeypatch): - """generate_output_path, test_output_path, and example_output_path must all be - honored uniformly in the architecture.json branch — not just the latter two. - Currently example_dir and test_dir are read from .pddrc (correct), but - generate_output_path is absent from the same block, causing asymmetry. - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - self._write_pddrc(tmp_path, generate_dir="src/", test_dir="tests/", example_dir="examples/") - self._write_arch_json(tmp_path, "widget_python.prompt", "widget.py") +@pytest.mark.parametrize( + "markup", + [ + '', + 'dep.txt', + ], +) +def test_v1_new_grammar_save_reload_rerun_does_not_self_drift( + tmp_path, monkeypatch, markup +): + monkeypatch.chdir(tmp_path) + prompt = tmp_path / "widget.prompt" + prompt.write_text(markup, encoding="utf-8") + (tmp_path / "dep.txt").write_text("dependency", encoding="utf-8") - paths = get_pdd_file_paths("widget", "python", "prompts") + first = calculate_prompt_hash(prompt, hash_version=1) + persisted = extract_include_deps(prompt, version=1) + reloaded = json.loads(json.dumps(persisted)) + second = calculate_prompt_hash(prompt, reloaded, hash_version=1) - # test and example are already correct (they should stay correct after fix) - assert "examples" in paths["example"].parts, ( - f"example_output_path not honored: {paths['example']!r}" - ) - assert "tests" in paths["test"].parts, ( - f"test_output_path not honored: {paths['test']!r}" - ) - # code must also use its configured directory — currently broken - assert "src" in paths["code"].parts, ( - f"generate_output_path 'src/' is not applied symmetrically with " - f"test_output_path and example_output_path. " - f"code={paths['code']!r}, test={paths['test']!r}, example={paths['example']!r}" - ) + assert persisted == {} + assert second == first - def test_code_filename_from_arch_json_preserved_after_generate_path_applied( - self, tmp_path, monkeypatch - ): - """After the fix, the filename component from architecture.json must be preserved; - only the parent directory should be overridden by .pddrc generate_output_path. - This verifies the correct resolution: src/widget.py — not src/widget_widget.py. - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - self._write_pddrc(tmp_path, generate_dir="src/") - self._write_arch_json(tmp_path, "widget_python.prompt", "widget.py") +# --------------------------------------------------------------------------- +# Round-9 review hardening (post-#1985 merge): explicit null outputs entries +# fail closed; test_files are rebuilt from the ANCHORED test dir (parent-CWD +# safe); templates that expand to nothing / the project root / an unexpanded +# placeholder fail closed instead of returning a directory or literal braces. +# --------------------------------------------------------------------------- - paths = get_pdd_file_paths("widget", "python", "prompts") - # Filename must come from arch.json - assert paths["code"].name == "widget.py", ( - f"Filename should be preserved from arch.json as 'widget.py', got: {paths['code'].name!r}" - ) - # Parent directory must come from .pddrc generate_output_path - assert paths["code"].parent.name == "src", ( - f"Parent directory should be 'src' from generate_output_path, " - f"got: {paths['code'].parent.name!r} (full path: {paths['code']!r})" - ) +def test_get_pdd_file_paths_rejects_null_outputs_entry(tmp_path, monkeypatch): + """R16: an explicit `outputs: {code: null}` is malformed (its key presence would + suppress the configured legacy fallback) and must fail closed.""" + monkeypatch.chdir(tmp_path) + _write_escape_pddrc_project( + tmp_path, + ' generate_output_path: "src/"\n outputs:\n code: null\n', + with_arch=True, + ) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") - def test_generate_output_path_honored_with_explicit_context_override( - self, tmp_path, monkeypatch - ): - """generate_output_path is honored in the arch branch even when context_override is given. - Steps 2-3 confirmed the bug in the pddrc_path branch that reads context_name from - context_override (line 952). This test ensures the fix works end-to-end when the - caller supplies an explicit context. - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - (tmp_path / ".pddrc").write_text( - "contexts:\n" - " default:\n" - " paths: [\"**\"]\n" - " defaults:\n" - " generate_output_path: \"lib/\"\n" - " test_output_path: \"tests/\"\n" - " example_output_path: \"examples/\"\n" - " backend:\n" - " paths: [\"backend/**\"]\n" - " defaults:\n" - " generate_output_path: \"backend/src/\"\n" - " test_output_path: \"backend/tests/\"\n" - " example_output_path: \"backend/examples/\"\n" +@pytest.mark.parametrize("code_template", ['"{category}"', '"src/{module}.py"']) +def test_get_pdd_file_paths_rejects_empty_or_unresolved_template_expansion(tmp_path, monkeypatch, code_template): + """R16: a template that expands to nothing (flat-basename `{category}` -> project + root) or keeps an unexpanded/unsupported placeholder is not a real artifact file.""" + monkeypatch.chdir(tmp_path) + # NO architecture.json -> the outputs.code template is actually applied. + (tmp_path / "prompts").mkdir(parents=True) + (tmp_path / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["**"]\n defaults:\n' + ' outputs:\n code:\n path: ' + code_template + '\n', + encoding="utf-8", + ) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") + + +def test_get_pdd_file_paths_test_files_rebuilt_from_anchored_dir_parent_cwd(tmp_path, monkeypatch): + """R16: test_files come from the ANCHORED (nested project) test directory, not the + parent CWD — a parent-CWD run must not hand the parent's sibling tests to the runner + nor return nonexistent nested paths.""" + parent = tmp_path + project = parent / "project" + (project / "prompts").mkdir(parents=True) + (project / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (project / "tests").mkdir() + (project / "tests" / "test_widget.py").write_text("x = 1\n", encoding="utf-8") + (project / "tests" / "test_widget_extra.py").write_text("y = 2\n", encoding="utf-8") + (project / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["**"]\n defaults:\n' + ' generate_output_path: "src/"\n test_output_path: "tests/"\n', + encoding="utf-8", + ) + # A decoy sibling under the PARENT CWD that must NOT be picked up. + (parent / "tests").mkdir() + (parent / "tests" / "test_widget_parent.py").write_text("z = 3\n", encoding="utf-8") + monkeypatch.chdir(parent) + paths = get_pdd_file_paths( + "widget", "python", + prompts_dir=str((project / "prompts").resolve()), + context_override="backend", + ) + root = project.resolve() + for tf in paths["test_files"]: + rp = Path(tf).resolve(strict=False) + assert rp.is_relative_to(root), f"test_file escaped project: {tf}" + assert rp.exists(), f"nonexistent test_file returned: {tf}" + names = {Path(tf).name for tf in paths["test_files"]} + assert "test_widget_parent.py" not in names +@pytest.mark.parametrize("policy_mutation", [None, "delete", "rename"]) +def test_sync_classifier_preserves_nested_prompt_alias_identity( + tmp_path, monkeypatch, policy_mutation +): + """Architecture, include closure, and hashing use the approved logical path.""" + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run( + ["git", "config", "user.email", "sync@example.com"], cwd=root, check=True + ) + subprocess.run( + ["git", "config", "user.name", "Sync Test"], cwd=root, check=True + ) + (root / "prompts/nested").mkdir(parents=True) + (root / "canonical-prompts").mkdir() + (root / ".pdd").mkdir() + (root / "src/nested").mkdir(parents=True) + canonical_prompt = root / "canonical-prompts/widget_python.prompt" + canonical_prompt.write_text("Build widget\ncontract.md\n") + logical_prompt = root / "prompts/nested/widget_python.prompt" + logical_prompt.symlink_to("../../canonical-prompts/widget_python.prompt") + contract = root / "prompts/nested/contract.md" + contract.write_text("logical contract\n") + code = root / "src/nested/widget.py" + code.write_text("value = 1\n") + wrong_code = root / "wrong/nested/widget.py" + wrong_code.parent.mkdir(parents=True) + wrong_code.write_text("wrong = True\n") + (root / "architecture.json").write_text( + json.dumps( + [ + { + "filename": "widget_python.prompt", + "filepath": "wrong/nested/widget.py", + }, + { + "filename": "nested/widget_python.prompt", + "filepath": "src/nested/widget.py", + } + ] ) - self._write_arch_json(tmp_path, "service_python.prompt", "service.py") + ) + (root / ".pdd/repository-id").write_text( + "3b4d7b1c-d6cc-4752-ba93-6b98d1a710e0\n" + ) + (root / ".pdd/sync-policy.json").write_text( + json.dumps({"schema_version": 1, "enforcement": "active"}) + ) + (root / ".pdd/sync-aliases.json").write_text( + json.dumps( + { + "schema_version": 1, + "aliases": [ + { + "alias_path": "prompts/nested/widget_python.prompt", + "canonical_path": "canonical-prompts/widget_python.prompt", + } + ], + } + ) + ) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "nested prompt alias"], cwd=root, check=True + ) + policy = root / ".pdd/sync-policy.json" + if policy_mutation == "delete": + policy.unlink() + elif policy_mutation == "rename": + policy.rename(policy.with_suffix(".disabled")) + monkeypatch.chdir(root) + monkeypatch.delenv("PDD_SYNC_PROTECTED_BASE_SHA", raising=False) - paths = get_pdd_file_paths("service", "python", "prompts", context_override="backend") + paths = get_pdd_file_paths("nested/widget", "python", "prompts") + deps = extract_include_deps(paths["prompt"]) + digest_before = calculate_prompt_hash(paths["prompt"]) + contract.write_text("changed logical contract\n") + digest_after = calculate_prompt_hash(paths["prompt"]) - # With context_override="backend", generate_output_path should be "backend/src/" - code_parts = paths["code"].parts - assert "backend" in code_parts and "src" in code_parts, ( - f"With context_override='backend', code_path should be in backend/src/, " - f"but got: {paths['code']!r}" - ) -from datetime import datetime, timezone -from pathlib import Path + assert paths["prompt"] == Path("prompts/nested/widget_python.prompt") + assert paths["code"].resolve() == code.resolve() + assert deps == { + "prompts/nested/contract.md": hashlib.sha256( + b"logical contract\n" + ).hexdigest() + } + assert digest_before is not None + assert digest_after is not None + assert digest_after != digest_before -from pdd.sync_determine_operation import _handle_missing_expected_files +# --------------------------------------------------------------------------- +# Post-#1991-merge reconciliation + round-10 review hardening. +# --------------------------------------------------------------------------- -def test_missing_example_schedules_example_by_default(tmp_path: Path) -> None: - from pdd.sync_determine_operation import Fingerprint - prompt = tmp_path / "prompts" / "calc_python.prompt" - code = tmp_path / "pdd" / "calc.py" - example = tmp_path / "context" / "calc_example.py" - test = tmp_path / "tests" / "test_calc.py" - prompt.parent.mkdir() - code.parent.mkdir() - example.parent.mkdir() - test.parent.mkdir() - prompt.write_text("Create calc.\n", encoding="utf-8") - code.write_text("def add(a, b): return a + b\n", encoding="utf-8") - fingerprint = Fingerprint("test", datetime.now(timezone.utc).isoformat(), "fix", "p", "c", "e", "t") - decision = _handle_missing_expected_files( - ["example"], - {"prompt": prompt, "code": code, "example": example, "test": test}, - fingerprint, - "calc", - "python", - str(prompt.parent), +def test_get_pdd_file_paths_nested_prompt_template_keeps_physical_category(tmp_path, monkeypatch): + """r10 P1: a nested physical prompt with an outputs `{category}` template maps under + the nested directory (src/nested/foo.py), not the bare leaf (src/foo.py).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "foo_python.prompt").write_text("% foo\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n' + ' outputs:\n code:\n path: "src/{category}/{name}.py"\n', + encoding="utf-8", ) - - assert decision.operation == "example" + (tmp_path / "architecture.json").write_text(json.dumps({"modules": []}), encoding="utf-8") + paths = get_pdd_file_paths("foo", "python", "prompts") + assert paths["code"].resolve(strict=False) == (tmp_path / "src" / "nested" / "foo.py").resolve() + + +def test_get_pdd_file_paths_nested_prompt_template_category_from_parent_cwd(tmp_path, monkeypatch): + """r10 P1 (CWD-independence): the nested physical category survives a parent-CWD run.""" + project = tmp_path / "project" + (project / "prompts" / "nested").mkdir(parents=True) + (project / "prompts" / "nested" / "foo_python.prompt").write_text("% foo\n", encoding="utf-8") + (project / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n' + ' outputs:\n code:\n path: "src/{category}/{name}.py"\n', + encoding="utf-8", + ) + (project / "architecture.json").write_text(json.dumps({"modules": []}), encoding="utf-8") + monkeypatch.chdir(tmp_path) # PARENT + paths = get_pdd_file_paths("foo", "python", str((project / "prompts").resolve())) + assert paths["code"].resolve(strict=False) == (project / "src" / "nested" / "foo.py").resolve() -def test_missing_example_is_bypassed_for_isolated_repair_or_replay(tmp_path: Path) -> None: - from pdd.sync_determine_operation import Fingerprint +@pytest.mark.parametrize("prompt_template", ['"custom/{module}.prompt"', '"{category}"']) +def test_get_pdd_file_paths_rejects_unresolved_or_empty_prompt_template(tmp_path, monkeypatch, prompt_template): + """r10 P2: an outputs.prompt.path that keeps an unexpanded placeholder or expands to + the project root is not a real prompt file -> fail closed.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir(parents=True) + (tmp_path / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["**"]\n defaults:\n' + ' outputs:\n prompt:\n path: ' + prompt_template + '\n', + encoding="utf-8", + ) + with pytest.raises((UnsafePromptPathError, UnsafeOutputPathError)): + get_pdd_file_paths("widget", "python", prompts_dir="prompts", context_override="backend") - prompt = tmp_path / "prompts" / "calc_python.prompt" - code = tmp_path / "pdd" / "calc.py" - example = tmp_path / "context" / "calc_example.py" - test = tmp_path / "tests" / "test_calc.py" - prompt.parent.mkdir() - code.parent.mkdir() - example.parent.mkdir() - test.parent.mkdir() - prompt.write_text("Create calc.\n", encoding="utf-8") - code.write_text("def add(a, b): return a + b\n", encoding="utf-8") - fingerprint = Fingerprint("test", datetime.now(timezone.utc).isoformat(), "fix", "p", "c", "e", "t") - decision = _handle_missing_expected_files( - ["example"], - {"prompt": prompt, "code": code, "example": example, "test": test}, - fingerprint, - "calc", - "python", - str(prompt.parent), - isolated_replay_or_repair=True, +def test_get_pdd_file_paths_path_qualified_prefers_filename_path_match_over_bare_leaf(tmp_path, monkeypatch): + """#1991 reconciliation: a path-qualified `nested/widget` resolves to the row whose + FILENAME path-matches it (nested/widget_python.prompt -> src/nested/widget.py), and a + bare `widget_python.prompt` row (-> wrong/nested/widget.py) neither wins nor raises a + false AmbiguousModuleError.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "src" / "nested").mkdir(parents=True) + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "widget_python.prompt", "filepath": "wrong/nested/widget.py"}, + {"filename": "nested/widget_python.prompt", "filepath": "src/nested/widget.py"}, + ]), + encoding="utf-8", ) - - assert decision.operation == "generate" - assert decision.details["isolated_replay_or_repair"] is True + paths = get_pdd_file_paths("nested/widget", "python", "prompts") + assert paths["code"].resolve(strict=False) == (tmp_path / "src" / "nested" / "widget.py").resolve() # --------------------------------------------------------------------------- -# Issue #551 (reopened): YAML and Markdown example/test paths use raw language -# name instead of canonical file extension. -# -# Root cause: local get_extension() in sync_determine_operation fell back to -# language.lower() for languages not in its hard-coded map, returning "yaml" -# instead of "yml" and "markdown" instead of "md". -# -# Fix: local get_extension() now reads the first matching row from the -# package-local language_format.csv, which maps YAML -> .yml and Markdown -> .md. +# Round-11 review hardening: approved aliases must survive ALL discovery paths; +# all-legacy suffix-aligned ambiguity must not be filtered away; the architecture +# branch's example/test templates must keep the nested physical identity. # --------------------------------------------------------------------------- -class TestIssue551CanonicalExtensionInGetPddFilePaths: - """Regression tests for issue #551 (reopened): YAML and Markdown example/test - paths must use canonical file extensions, not raw language names. - """ - def _write_arch_json(self, tmp_path: Path, prompt_filename: str, filepath: str) -> None: - (tmp_path / "architecture.json").write_text(json.dumps({ - "modules": [{"filename": prompt_filename, "filepath": filepath}] - })) +def test_get_pdd_file_paths_approved_alias_via_indirect_discovery(tmp_path, monkeypatch): + """r11 F1: a BARE `widget` request whose architecture row names `nested/widget` + resolves the approved in-repo alias symlink through architecture-hint/recursive + discovery (not only the direct fast path) — no UnsafePromptPathError.""" + import subprocess + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "prompts" / "nested").mkdir(parents=True) + (root / "canonical-prompts").mkdir() + (root / "src" / "nested").mkdir(parents=True) + (root / "canonical-prompts" / "widget_python.prompt").write_text("Build\n", encoding="utf-8") + try: + (root / "prompts" / "nested" / "widget_python.prompt").symlink_to( + "../../canonical-prompts/widget_python.prompt" + ) + except OSError: + pytest.skip("symlinks unavailable") + (root / "src" / "nested" / "widget.py").write_text("v = 1\n", encoding="utf-8") + (root / "architecture.json").write_text( + json.dumps([{"filename": "nested/widget_python.prompt", "filepath": "src/nested/widget.py"}]), + encoding="utf-8", + ) + monkeypatch.chdir(root) + paths = get_pdd_file_paths("widget", "python", "prompts") # BARE request + assert paths["prompt"] == Path("prompts/nested/widget_python.prompt") + assert paths["code"].resolve(strict=False) == (root / "src" / "nested" / "widget.py").resolve() - def _setup_dirs(self, tmp_path: Path) -> None: - for d in ("prompts", "examples", "tests", ".pdd/meta", ".pdd/locks"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - @pytest.mark.parametrize("language,code_filename,expected_example_suffix,expected_test_suffix", [ - ("YAML", "ci.yml", ".yml", ".yml"), - ("Markdown", "manifest.md", ".md", ".md"), - ("Text", "dockerfile.txt", ".txt", ".txt"), - ]) - def test_architecture_paths_use_canonical_extensions( - self, - tmp_path, - monkeypatch, - language, - code_filename, - expected_example_suffix, - expected_test_suffix, - ): - """get_pdd_file_paths must derive example/test extensions from the canonical - language mapping, not the raw language string. +def test_get_pdd_file_paths_all_legacy_suffix_aligned_rows_still_ambiguous(tmp_path, monkeypatch): + """r11 F2: two BARE `widget_python.prompt` rows mapping a qualified `nested/widget` + to two distinct nested outputs is genuinely ambiguous — the filename-ownership + filter must NOT collapse the choices to an empty set that silently falls back.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "widget_python.prompt", "filepath": "src/nested/widget.py"}, + {"filename": "widget_python.prompt", "filepath": "other/nested/widget.py"}, + ]), + encoding="utf-8", + ) + import sync_determine_operation as sync_determine_module + with pytest.raises(sync_determine_module.AmbiguousModuleError): + get_pdd_file_paths("nested/widget", "python", "prompts") - Before the fix: - YAML -> ci_example.yaml / test_ci.yaml (wrong, should be .yml) - Markdown -> manifest_example.markdown (wrong, should be .md) - Text -> dockerfile_example.text (wrong, should be .txt) - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - basename = Path(code_filename).stem - prompt_filename = f"{basename}_{language}.prompt" - (tmp_path / "prompts" / prompt_filename).write_text(f"% {language} module\n") - self._write_arch_json(tmp_path, prompt_filename, code_filename) +def test_get_pdd_file_paths_arch_branch_nested_category_example_test(tmp_path, monkeypatch): + """r11 F3: with a non-empty architecture row for a nested prompt, an example/test + `{category}` template keeps the nested prefix (examples/nested/..., tests/nested/...).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "foo_python.prompt").write_text("% foo\n", encoding="utf-8") + (tmp_path / "src" / "nested").mkdir(parents=True) + (tmp_path / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n outputs:\n' + ' example:\n path: "examples/{category}/{name}_example.py"\n' + ' test:\n path: "tests/{category}/test_{name}.py"\n', + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps([{"filename": "nested/foo_python.prompt", "filepath": "src/nested/foo.py"}]), + encoding="utf-8", + ) + paths = get_pdd_file_paths("foo", "python", "prompts") + assert paths["example"].resolve(strict=False) == (tmp_path / "examples" / "nested" / "foo_example.py").resolve() + assert paths["test"].resolve(strict=False) == (tmp_path / "tests" / "nested" / "test_foo.py").resolve() + - paths = get_pdd_file_paths(basename, language, "prompts") +# --------------------------------------------------------------------------- +# Round-12 review hardening: the approved-alias policy must hold through the +# INTERNAL recursive architecture-hint search and nested-subproject +# finalization; alias authority must come from a REAL git worktree (not a +# planted `.git`); and an unsafe path-owning row must not suppress a genuine +# ambiguity between two valid legacy rows. +# --------------------------------------------------------------------------- - assert paths["example"].suffix == expected_example_suffix, ( - f"Issue #551: example path for {language} must end with {expected_example_suffix!r}, " - f"got {paths['example'].suffix!r} (full path: {paths['example']})" + +def test_get_pdd_file_paths_approved_alias_flat_filename_recursive_discovery(tmp_path, monkeypatch): + """r12 F1: a LEGACY FLAT architecture filename (`widget_python.prompt`) whose file + lives nested is found by the INTERNAL recursive rglob search. When that nested file + is an APPROVED in-repo alias (symlink escaping the prompts root but staying inside + the git repository), the recursive search must honour it — not raise + UnsafePromptPathError. This exercises the recursive path (line ~422), not the + qualified-filename direct path covered by the r11 test.""" + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "prompts" / "nested").mkdir(parents=True) + (root / "canonical-prompts").mkdir() + (root / "src" / "nested").mkdir(parents=True) + (root / "canonical-prompts" / "widget_python.prompt").write_text("Build\n", encoding="utf-8") + try: + (root / "prompts" / "nested" / "widget_python.prompt").symlink_to( + "../../canonical-prompts/widget_python.prompt" ) - assert paths["test"].suffix == expected_test_suffix, ( - f"Issue #551: test path for {language} must end with {expected_test_suffix!r}, " - f"got {paths['test'].suffix!r} (full path: {paths['test']})" + except OSError: + pytest.skip("symlinks unavailable") + (root / "src" / "nested" / "widget.py").write_text("v = 1\n", encoding="utf-8") + # FLAT filename (not `nested/widget_python.prompt`): the direct join misses, so the + # bare request drops into the recursive rglob search. + (root / "architecture.json").write_text( + json.dumps([{"filename": "widget_python.prompt", "filepath": "src/nested/widget.py"}]), + encoding="utf-8", + ) + monkeypatch.chdir(root) + paths = get_pdd_file_paths("widget", "python", "prompts") + assert paths["prompt"].resolve(strict=False) == ( + root / "canonical-prompts" / "widget_python.prompt" + ).resolve() + assert paths["code"].resolve(strict=False) == (root / "src" / "nested" / "widget.py").resolve() + + +def test_get_pdd_file_paths_nested_subproject_alias_survives_finalization(tmp_path, monkeypatch): + """r12 F1: a nested subproject's approved alias may target a canonical prompt + ELSEWHERE in the enclosing repository. It passes discovery, and finalization must + also accept it (the resolved target stays inside the validated enclosing repo) — + it must NOT be rejected just because it escapes the nested governing/prompts root.""" + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + # Canonical prompt shared at the repo top, OUTSIDE the nested subproject. + (root / "shared-prompts").mkdir() + (root / "shared-prompts" / "widget_python.prompt").write_text("Build\n", encoding="utf-8") + sub = root / "sub" + (sub / "prompts").mkdir(parents=True) + (sub / "src").mkdir(parents=True) + (sub / "src" / "widget.py").write_text("v = 1\n", encoding="utf-8") + try: + (sub / "prompts" / "widget_python.prompt").symlink_to( + "../../shared-prompts/widget_python.prompt" ) + except OSError: + pytest.skip("symlinks unavailable") + # A nested .pddrc + architecture.json make `sub` its own governing root. + (sub / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n', encoding="utf-8" + ) + (sub / "architecture.json").write_text( + json.dumps([{"filename": "widget_python.prompt", "filepath": "src/widget.py"}]), + encoding="utf-8", + ) + monkeypatch.chdir(sub) + paths = get_pdd_file_paths("widget", "python", "prompts") + assert paths["prompt"].resolve(strict=False) == ( + root / "shared-prompts" / "widget_python.prompt" + ).resolve() + assert paths["code"].resolve(strict=False) == (sub / "src" / "widget.py").resolve() + + +def test_enclosing_git_root_rejects_planted_git_marker(tmp_path): + """r12 F2: an empty/planted `.git` directory is NOT a real worktree, so + _enclosing_git_root must return None for it while returning the resolved root for a + genuine repository.""" + import sync_determine_operation as sync_determine_module + fake = tmp_path / "fake" + (fake / ".git").mkdir(parents=True) + (fake / "prompts").mkdir() + assert sync_determine_module._enclosing_git_root(fake / "prompts") is None + + real = tmp_path / "real" + real.mkdir() + subprocess.run(["git", "init", "-q"], cwd=real, check=True) + (real / "prompts").mkdir() + resolved = sync_determine_module._enclosing_git_root(real / "prompts") + assert resolved is not None + assert resolved.resolve() == real.resolve() + + +def test_get_pdd_file_paths_fake_git_marker_rejects_escaping_alias(tmp_path, monkeypatch): + """r12 F2: a planted empty `.git` in a NON-repository tree must not grant alias + authority — an escaping prompt symlink is still rejected, so a later `update` cannot + overwrite the non-prompt file it points at.""" + root = tmp_path / "proj" + (root / "prompts").mkdir(parents=True) + (root / ".git").mkdir() # planted, empty — NOT a real repository + victim = root / "victim.py" + original = "SECRET = 1 # must remain unchanged\n" + victim.write_text(original, encoding="utf-8") + try: + (root / "prompts" / "credits_python.prompt").symlink_to("../victim.py") + except OSError: + pytest.skip("symlinks unavailable") + monkeypatch.chdir(root) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("credits", "python", prompts_dir="prompts") + assert victim.read_text(encoding="utf-8") == original + + +def test_get_pdd_file_paths_unsafe_owner_row_does_not_suppress_ambiguity(tmp_path, monkeypatch): + """r12 F3: a row whose FILENAME path-owns the qualified basename but whose FILEPATH + escapes containment (`../../nested/widget.py`) must NOT set the owner flag — else it + is dropped by the safety pass while suppressing two valid legacy rows, collapsing a + genuine ambiguity to a silent first-match. The owner flag must be built from the + fully-eligible (contained + right-extension) row set.""" + import sync_determine_operation as sync_determine_module + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps([ + # Unsafe: filename path-owns `nested/widget`, but filepath escapes the root. + {"filename": "nested/widget_python.prompt", "filepath": "../../nested/widget.py"}, + # Two valid legacy rows -> two distinct nested outputs = genuine ambiguity. + {"filename": "widget_python.prompt", "filepath": "src/nested/widget.py"}, + {"filename": "widget_python.prompt", "filepath": "other/nested/widget.py"}, + ]), + encoding="utf-8", + ) + with pytest.raises(sync_determine_module.AmbiguousModuleError): + get_pdd_file_paths("nested/widget", "python", "prompts") - def test_yaml_example_path_is_yml_not_yaml(self, tmp_path, monkeypatch): - """Explicit regression for the reported YAML case: ci.yml must produce - ci_example.yml (not ci_example.yaml) and test_ci.yml (not test_ci.yaml). - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - (tmp_path / "prompts" / "ci_YAML.prompt").write_text("% CI pipeline\n") - self._write_arch_json(tmp_path, "ci_YAML.prompt", "ci.yml") - paths = get_pdd_file_paths("ci", "YAML", "prompts") +# --------------------------------------------------------------------------- +# Round-13 review hardening: git worktree authority must not be redirected by +# inherited GIT_* env or served stale from a process cache; architecture +# SELECTION must apply the same language-extension eligibility as ambiguity +# enumeration; and finalization must reject existing-directory destinations. +# --------------------------------------------------------------------------- - assert paths["example"].name == "ci_example.yml", ( - f"Issue #551: YAML example must be ci_example.yml, got {paths['example'].name!r}" - ) - assert paths["test"].name == "test_ci.yml", ( - f"Issue #551: YAML test must be test_ci.yml, got {paths['test'].name!r}" - ) - def test_markdown_example_path_is_md_not_markdown(self, tmp_path, monkeypatch): - """Explicit regression for the reported Markdown case: manifest.md must - produce manifest_example.md (not manifest_example.markdown). - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - (tmp_path / "prompts" / "manifest_Markdown.prompt").write_text("% Manifest docs\n") - self._write_arch_json(tmp_path, "manifest_Markdown.prompt", "manifest.md") +def test_enclosing_git_root_ignores_inherited_git_env_redirect(tmp_path, monkeypatch): + """r13 F1: a caller-inherited GIT_WORK_TREE=/ (or GIT_DIR to a foreign repo) must + NOT make _enclosing_git_root report an attacker-chosen worktree for a non-repository + directory — repo-selection env is stripped from the git subprocess.""" + import sync_determine_operation as sync_determine_module + non_repo = tmp_path / "not_a_repo" + non_repo.mkdir() + # A real repo elsewhere, wired in via inherited env. + foreign = tmp_path / "foreign" + foreign.mkdir() + subprocess.run(["git", "init", "-q"], cwd=foreign, check=True) + monkeypatch.setenv("GIT_DIR", str(foreign / ".git")) + monkeypatch.setenv("GIT_WORK_TREE", "/") + # Without the env scrub, git would report "/" as the worktree root. + assert sync_determine_module._enclosing_git_root(non_repo) is None + + +def test_enclosing_git_root_not_stale_after_repo_removed(tmp_path): + """r13 F1: a positive result must not remain authoritative after the repository is + removed in the same process (no stale process-wide cache).""" + import shutil + import sync_determine_operation as sync_determine_module + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + first = sync_determine_module._enclosing_git_root(repo) + assert first is not None and first.resolve() == repo.resolve() + shutil.rmtree(repo / ".git") + assert sync_determine_module._enclosing_git_root(repo) is None + + +def test_get_pdd_file_paths_git_env_redirect_still_rejects_escaping_alias(tmp_path, monkeypatch): + """r13 F1: end-to-end — with GIT_WORK_TREE=/ inherited, an escaping prompt symlink in + a NON-repository project is still rejected, so authority cannot be smuggled in via + env and a later update cannot overwrite the target.""" + foreign = tmp_path / "foreign" + foreign.mkdir() + subprocess.run(["git", "init", "-q"], cwd=foreign, check=True) + root = tmp_path / "proj" + (root / "prompts").mkdir(parents=True) + victim = root / "victim.py" + original = "SECRET = 1\n" + victim.write_text(original, encoding="utf-8") + try: + (root / "prompts" / "credits_python.prompt").symlink_to("../victim.py") + except OSError: + pytest.skip("symlinks unavailable") + monkeypatch.setenv("GIT_DIR", str(foreign / ".git")) + monkeypatch.setenv("GIT_WORK_TREE", "/") + monkeypatch.chdir(root) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("credits", "python", prompts_dir="prompts") + assert victim.read_text(encoding="utf-8") == original - paths = get_pdd_file_paths("manifest", "Markdown", "prompts") - assert paths["example"].name == "manifest_example.md", ( - f"Issue #551: Markdown example must be manifest_example.md, " - f"got {paths['example'].name!r}" - ) - assert paths["test"].name == "test_manifest.md", ( - f"Issue #551: Markdown test must be test_manifest.md, " - f"got {paths['test'].name!r}" - ) +def test_get_pdd_file_paths_selection_respects_language_extension(tmp_path, monkeypatch): + """r13 F2: architecture SELECTION must apply the same language-extension gate as the + ambiguity enumeration. A Python request whose exact-filename row targets a `.ts` file + must not be chosen over the `.py` legacy row that the choices prefer.""" + import sync_determine_operation as sync_determine_module + from pathlib import Path as _P + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "nested/widget_python.prompt", "filepath": "src/nested/widget.ts"}, + {"filename": "widget_python.prompt", "filepath": "other/nested/widget.py"}, + ]), + encoding="utf-8", + ) + choices = sync_determine_module._architecture_module_choices( + _P("architecture.json"), "nested/widget", "python" + ) + assert choices == ["other/nested/widget.py"] + paths = get_pdd_file_paths("nested/widget", "python", "prompts") + # Selection must AGREE with the choice (the .py row), not return the wrong-language .ts. + assert paths["code"].resolve(strict=False) == (tmp_path / "other" / "nested" / "widget.py").resolve() - # ------------------------------------------------------------------ - # Comprehensive sibling-language parametrized regression tests - # These cover languages from the Step 6 NEEDS_FIX list where the old - # local helper returned raw language names instead of canonical exts. - # ------------------------------------------------------------------ - @pytest.mark.parametrize("language,code_filename,expected_example_suffix,expected_test_suffix", [ - # C-family / systems - ("C++", "engine.cpp", ".cpp", ".cpp"), - ("C#", "service.cs", ".cs", ".cs"), - ("Haskell", "parser.hs", ".hs", ".hs"), - ("F#", "module.fs", ".fs", ".fs"), - ("R", "stats.R", ".R", ".R"), - ("LaTeX", "paper.tex", ".tex", ".tex"), - ("Assembly", "boot.asm", ".asm", ".asm"), - ("Fortran", "solver.f90", ".f90", ".f90"), - ("COBOL", "report.cob", ".cob", ".cob"), - ("Prolog", "facts.pl", ".pl", ".pl"), - ("Erlang", "node.erl", ".erl", ".erl"), - ("Clojure", "core.clj", ".clj", ".clj"), - ("Julia", "compute.jl", ".jl", ".jl"), - ("Elixir", "worker.ex", ".ex", ".ex"), - ("Pascal", "program.pas", ".pas", ".pas"), - ("VBScript", "script.vbs", ".vbs", ".vbs"), - ("CoffeeScript", "app.coffee", ".coffee", ".coffee"), - ("Objective-C", "view.m", ".m", ".m"), - ("Scheme", "eval.scm", ".scm", ".scm"), - ("OCaml", "lexer.ml", ".ml", ".ml"), - ("LLM", "agent.prompt", ".prompt", ".prompt"), - ("reStructuredText","manual.rst", ".rst", ".rst"), - ("Verilog", "adder.v", ".v", ".v"), - ("Systemverilog", "module.sv", ".sv", ".sv"), - ("Jinja", "tmpl.jinja2", ".jinja2", ".jinja2"), - ("Handlebars", "page.hbs", ".hbs", ".hbs"), - ("Terraform", "main.tf", ".tf", ".tf"), - ("Solidity", "token.sol", ".sol", ".sol"), - ("Protobuf", "schema.proto", ".proto", ".proto"), - ("Starlark", "rules.bzl", ".bzl", ".bzl"), - ]) - def test_sibling_language_architecture_paths_use_canonical_extensions( - self, - tmp_path, - monkeypatch, - language, - code_filename, - expected_example_suffix, - expected_test_suffix, - ): - """Issue #551 scope expansion: all languages from the Step 6 NEEDS_FIX list - must produce canonical extensions in get_pdd_file_paths, not raw language names. +def test_get_pdd_file_paths_rejects_existing_directory_code_destination(tmp_path, monkeypatch): + """r13 F3: an outputs.code.path pointing at an EXISTING directory is not a writable + artifact file — reject it instead of returning the directory (later IsADirectoryError).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "docs").mkdir() + (tmp_path / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n outputs:\n' + ' code:\n path: "docs"\n', + encoding="utf-8", + ) + with pytest.raises(UnsafeOutputPathError): + get_pdd_file_paths("widget", "python", "prompts") - Before the fix, the local get_extension() fell back to language.lower() for any - language not in its hard-coded map, producing suffixes like .c++, .haskell, - .terraform, .restructuredtext, etc. instead of canonical .cpp, .hs, .tf, .rst. - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - basename = Path(code_filename).stem - prompt_filename = f"{basename}_{language}.prompt" - (tmp_path / "prompts" / prompt_filename).write_text(f"% {language} module\n") - self._write_arch_json(tmp_path, prompt_filename, code_filename) +def test_get_pdd_file_paths_rejects_existing_directory_prompt_destination(tmp_path, monkeypatch): + """r13 F3: an outputs.prompt.path pointing at an EXISTING directory must be rejected + rather than returned as the prompt file.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "docs").mkdir() + (tmp_path / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n outputs:\n' + ' prompt:\n path: "docs"\n', + encoding="utf-8", + ) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("widget", "python", "prompts") - paths = get_pdd_file_paths(basename, language, "prompts") - assert paths["example"].suffix == expected_example_suffix, ( - f"Issue #551 ({language}): example path must end with {expected_example_suffix!r}, " - f"got {paths['example'].suffix!r} (full path: {paths['example']})" - ) - assert paths["test"].suffix == expected_test_suffix, ( - f"Issue #551 ({language}): test path must end with {expected_test_suffix!r}, " - f"got {paths['test'].suffix!r} (full path: {paths['test']})" - ) - assert paths["test_files"] == [paths["test"]], ( - f"Issue #551 ({language}): test_files must contain the canonical test path, " - f"got {paths['test_files']!r}" - ) +# --------------------------------------------------------------------------- +# Round-14 review hardening: the approved-alias exception is DISCOVERY-ONLY and +# must not follow a symlinked ANCESTOR into a foreign repository; a configured +# outputs.prompt.path symlink obeys the normal configured-destination policy; +# and a cyclic configured prompt symlink fails closed. +# --------------------------------------------------------------------------- - def test_makefile_uses_no_extension(self, tmp_path, monkeypatch): - """Makefile has no extension in language_format.csv — example/test paths must - not get a raw .makefile suffix. - Before the fix: get_extension('Makefile') returned 'makefile', so paths - would be *_example.makefile and test_*.makefile. - After the fix: the canonical CSV row has empty extension, so get_extension - returns '' and paths omit the suffix. - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - (tmp_path / "prompts" / "build_Makefile.prompt").write_text("% Build rules\n") - self._write_arch_json(tmp_path, "build_Makefile.prompt", "Makefile") +def test_get_pdd_file_paths_configured_prompt_symlinked_ancestor_into_foreign_repo(tmp_path, monkeypatch): + """r14 F1: an outputs.prompt.path whose lexical path sits under the project but + traverses a DIRECTORY symlink into a FOREIGN git repository must be rejected — a + symlinked ancestor must not redirect `git -C` authority to that foreign repo.""" + proj = tmp_path / "proj" + (proj / "prompts").mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=proj, check=True) + (proj / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + foreign = tmp_path / "foreign" + foreign.mkdir() + subprocess.run(["git", "init", "-q"], cwd=foreign, check=True) + (foreign / "real_python.prompt").write_text("% evil\n", encoding="utf-8") + try: + # The LEAF is itself a symlink (so it enters the discovery-alias branch), reached + # through `link`, a DIRECTORY symlink into the foreign repository. + (foreign / "evil_python.prompt").symlink_to("real_python.prompt") + (proj / "link").symlink_to(foreign, target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + (proj / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n outputs:\n' + ' prompt:\n path: "link/evil_python.prompt"\n', + encoding="utf-8", + ) + monkeypatch.chdir(proj) + with pytest.raises((UnsafePromptPathError, UnsafeOutputPathError)): + get_pdd_file_paths("widget", "python", "prompts") - paths = get_pdd_file_paths("Makefile", "Makefile", "prompts") - # The empty extension must not leave a malformed trailing-dot path - # (e.g. "Makefile_example.") via the unconditional ".{extension}" join. - for key in ("code", "example", "test"): - name = paths[key].name - assert not name.endswith("."), ( - f"Issue #551 (Makefile): {key} path must not end with a trailing " - f"dot, got {name!r}" - ) - assert ".makefile" not in name.lower(), ( - f"Issue #551 (Makefile): {key} path must not contain .makefile, " - f"got {name!r}" - ) +def test_get_pdd_file_paths_configured_prompt_symlink_gets_no_discovery_alias_exception(tmp_path, monkeypatch): + """r14 F2: a CONFIGURED outputs.prompt.path that is an existing leaf symlink to a + canonical prompt elsewhere in the SAME repository must obey the normal + configured-destination policy (contained in prompts root / governing project) and + must NOT inherit the discovery-only approved-alias exception.""" + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "shared").mkdir() + (root / "shared" / "canonical_python.prompt").write_text("% shared\n", encoding="utf-8") + sub = root / "sub" + (sub / "prompts").mkdir(parents=True) + (sub / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (sub / "custom").mkdir() + try: + (sub / "custom" / "widget_python.prompt").symlink_to( + root / "shared" / "canonical_python.prompt" + ) + except OSError: + pytest.skip("symlinks unavailable") + (sub / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n outputs:\n' + ' prompt:\n path: "custom/widget_python.prompt"\n', + encoding="utf-8", + ) + monkeypatch.chdir(sub) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("widget", "python", "prompts") - # test_files entries must likewise be free of trailing dots. - for tf in paths.get("test_files", []): - tf_name = Path(tf).name - assert not tf_name.endswith("."), ( - f"Issue #551 (Makefile): test_files entry must not end with a " - f"trailing dot, got {tf_name!r}" - ) - def test_test_files_list_uses_canonical_extension(self, tmp_path, monkeypatch): - """test_files key in get_pdd_file_paths return must also use the canonical extension. +def test_get_pdd_file_paths_cyclic_configured_prompt_symlink_fails_closed(tmp_path, monkeypatch): + """r14 F3: a configured outputs.prompt.path resolving through a symlink LOOP must fail + closed as UnsafePromptPathError — never leak a raw RuntimeError/OSError or silently + fall through to convention fallback.""" + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + try: + (tmp_path / "a").symlink_to(tmp_path / "b") + (tmp_path / "b").symlink_to(tmp_path / "a") + except OSError: + pytest.skip("symlinks unavailable") + (tmp_path / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n outputs:\n' + ' prompt:\n path: "a"\n', + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("widget", "python", "prompts") - Before the fix, YAML produced test_files=[Path('tests/test_ci.yaml')] instead of - [Path('tests/test_ci.yml')]. - """ - monkeypatch.chdir(tmp_path) - self._setup_dirs(tmp_path) - (tmp_path / "prompts" / "ci_YAML.prompt").write_text("% CI pipeline\n") - self._write_arch_json(tmp_path, "ci_YAML.prompt", "ci.yml") - paths = get_pdd_file_paths("ci", "YAML", "prompts") +def test_get_pdd_file_paths_discovered_nested_alias_still_allowed_control(tmp_path, monkeypatch): + """r14 control: the legitimate neighbour — a DISCOVERED nested-subproject approved + alias targeting a canonical prompt elsewhere in the same repo — must STILL resolve + after the F1/F2 hardening (the discovery-only exception is preserved).""" + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "shared-prompts").mkdir() + (root / "shared-prompts" / "widget_python.prompt").write_text("Build\n", encoding="utf-8") + sub = root / "sub" + (sub / "prompts").mkdir(parents=True) + (sub / "src").mkdir(parents=True) + (sub / "src" / "widget.py").write_text("v = 1\n", encoding="utf-8") + try: + (sub / "prompts" / "widget_python.prompt").symlink_to( + "../../shared-prompts/widget_python.prompt" + ) + except OSError: + pytest.skip("symlinks unavailable") + (sub / ".pddrc").write_text('contexts:\n default:\n paths: ["**"]\n', encoding="utf-8") + (sub / "architecture.json").write_text( + json.dumps([{"filename": "widget_python.prompt", "filepath": "src/widget.py"}]), + encoding="utf-8", + ) + monkeypatch.chdir(sub) + paths = get_pdd_file_paths("widget", "python", "prompts") + assert paths["prompt"].resolve(strict=False) == ( + root / "shared-prompts" / "widget_python.prompt" + ).resolve() - assert len(paths["test_files"]) >= 1, "test_files must be non-empty" - for tf in paths["test_files"]: - assert Path(tf).suffix == ".yml", ( - f"Issue #551: test_files entry must end with .yml, got {Path(tf).suffix!r} " - f"(entry: {tf!r})" - ) - assert Path(tf).suffix != ".yaml", ( - f"Issue #551: test_files must not contain .yaml path, got {tf!r}" - ) - def test_pdd_path_unset_generation_matches_sync_extension(self, tmp_path, monkeypatch): - """Issue #551 (FM1): with PDD_PATH unset, the extension generation WRITES - (construct_paths' offline fallback) must equal the extension sync EXPECTS - (get_pdd_file_paths). Before the shared-CSV fix, generation wrote - ci_example.yaml (BUILTIN_EXT_MAP) while sync expected ci_example.yml - (bundled CSV) -> sync looped regenerating forever. - """ - from pdd.construct_paths import construct_paths +# --------------------------------------------------------------------------- +# Round-15 review hardening: approved aliases must stay in-repo at EVERY physical +# symlink hop (no intermediate external hop / TOCTOU); discovery privilege comes +# from explicit provenance, not final-path equality; and the R11 owner pre-pass +# must apply context eligibility so a sibling-context owner cannot suppress +# ambiguity between two valid legacy rows. +# --------------------------------------------------------------------------- - monkeypatch.delenv("PDD_PATH", raising=False) - monkeypatch.chdir(tmp_path) - (tmp_path / "prompts").mkdir(parents=True, exist_ok=True) - prompt_file = tmp_path / "prompts" / "ci_YAML.prompt" - code_file = tmp_path / "ci.yml" - prompt_file.write_text("% CI pipeline example\n") - code_file.write_text("on: [push]\n") - # What generation writes for `pdd example` when PDD_PATH is unset. - _, _, output_paths, _ = construct_paths( - input_file_paths={"prompt_file": str(prompt_file), "code_file": str(code_file)}, - force=True, - quiet=True, - command="example", - command_options={}, +def test_get_pdd_file_paths_rejects_alias_through_external_intermediate_symlink(tmp_path, monkeypatch): + """r15 F1: an in-repo prompt alias whose chain traverses an EXTERNAL intermediate + symlink (currently re-entering the repo) must be rejected — validating only the final + target would let a later retarget of the external node escape (TOCTOU).""" + root = tmp_path / "repo" + (root / "prompts").mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "canonical").mkdir() + (root / "canonical" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (root / "canonical" / "widget.py").write_text("v = 1\n", encoding="utf-8") + ext = tmp_path / "external" # OUTSIDE the repository + ext.mkdir() + try: + # external intermediate currently points BACK into the repo... + (ext / "intermediate").symlink_to(root / "canonical" / "widget_python.prompt") + # ...and the in-repo alias hops through that external node. + (root / "prompts" / "widget_python.prompt").symlink_to(ext / "intermediate") + except OSError: + pytest.skip("symlinks unavailable") + (root / "architecture.json").write_text( + json.dumps([{"filename": "widget_python.prompt", "filepath": "canonical/widget.py"}]), + encoding="utf-8", + ) + monkeypatch.chdir(root) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("widget", "python", "prompts") + + +def test_symlink_chain_within_root_rejects_external_intermediate(tmp_path): + """r15 F1 (unit): _symlink_chain_within_root walks every hop; an external intermediate + node is rejected even when it re-enters, while a fully in-repo chain is accepted.""" + import sync_determine_operation as sync_determine_module + root = tmp_path / "repo" + (root / "a").mkdir(parents=True) + (root / "canon").mkdir() + (root / "canon" / "f").write_text("x\n", encoding="utf-8") + ext = tmp_path / "ext" + ext.mkdir() + try: + (ext / "mid").symlink_to(root / "canon" / "f") + (root / "a" / "via_ext").symlink_to(ext / "mid") # hops out then back + (root / "a" / "in_repo").symlink_to(root / "canon" / "f") # stays in repo + except OSError: + pytest.skip("symlinks unavailable") + root_real = os.path.realpath(str(root)) + assert sync_determine_module._symlink_chain_within_root(root / "a" / "via_ext", root_real) is False + assert sync_determine_module._symlink_chain_within_root(root / "a" / "in_repo", root_real) is True + + +def test_get_pdd_file_paths_configured_prompt_equal_to_discovered_alias_no_exception(tmp_path, monkeypatch): + """r15 F2: a CONFIGURED outputs.prompt.path expanding to exactly the discovered alias + path must NOT inherit the discovery-only exception (provenance is explicit, not path + equality). The configured symlink to a shared prompt outside the subproject is rejected.""" + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "shared").mkdir() + (root / "shared" / "canonical_python.prompt").write_text("% shared\n", encoding="utf-8") + sub = root / "sub" + (sub / "prompts").mkdir(parents=True) + try: + # discovered prompt AND configured outputs.prompt.path expand to the SAME path. + (sub / "prompts" / "widget_python.prompt").symlink_to( + root / "shared" / "canonical_python.prompt" ) - written = Path(output_paths["output"]) + except OSError: + pytest.skip("symlinks unavailable") + # LITERAL configured destination that coincides exactly with the discovered alias + # path (no placeholders, so it is rejected only by provenance — not by an unresolved + # `{...}` — isolating the R15 F2 fix). + (sub / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n outputs:\n' + ' prompt:\n path: "prompts/widget_python.prompt"\n', + encoding="utf-8", + ) + monkeypatch.chdir(sub) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("widget", "python", "prompts") - # What sync expects for the same module. - expected = get_pdd_file_paths("ci", "YAML", "prompts")["example"] - assert written.suffix == ".yml", ( - f"FM1: generation should write .yml offline, got {written.name!r}" - ) - assert written.suffix == expected.suffix, ( - f"FM1: generation writes {written.suffix!r} but sync expects " - f"{expected.suffix!r} (PDD_PATH unset) -> #551 regeneration loop" - ) +def test_get_pdd_file_paths_sibling_context_owner_does_not_suppress_ambiguity(tmp_path, monkeypatch): + """r15 F3: a path-owning row whose code target lives in a SIBLING context must not set + the owner flag (selection would reject it), so two valid legacy rows in the resolving + context stay ambiguous and raise AmbiguousModuleError instead of falling through.""" + import sync_determine_operation as sync_determine_module + from pathlib import Path as _P + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n' + ' frontend:\n paths: ["frontend/**"]\n' + ' backend:\n paths: ["backend/**"]\n', + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps([ + # path-owning row, but its target is in the SIBLING (frontend) context + {"filename": "nested/widget_python.prompt", "filepath": "frontend/nested/widget.py"}, + # two valid legacy rows in the resolving (backend) context, both suffix-aligning + # with `nested/widget` -> genuine ambiguity that the sibling owner must not hide + {"filename": "widget_python.prompt", "filepath": "backend/nested/widget.py"}, + {"filename": "widget_python.prompt", "filepath": "backend/src/nested/widget.py"}, + ]), + encoding="utf-8", + ) + choices = sync_determine_module._architecture_module_choices( + _P("architecture.json"), "nested/widget", "python", context_name="backend" + ) + # The sibling owner must NOT collapse the two backend legacy rows. + assert "backend/nested/widget.py" in choices + assert "backend/src/nested/widget.py" in choices + assert len(choices) >= 2 -def test_v1_hash_matches_base_whitespace_cwd_and_invalid_utf8(tmp_path, monkeypatch): - prompt_dir = tmp_path / "prompts" - cwd = tmp_path / "cwd" - prompt_dir.mkdir() - cwd.mkdir() - dependency = cwd / "shared.bin" - dependency.write_bytes(b"dependency\xff") - prompt = prompt_dir / "widget.prompt" - prompt.write_bytes(b" shared.bin \ninvalid:\xff\n") - monkeypatch.chdir(cwd) - expected = hashlib.sha256(prompt.read_bytes() + dependency.read_bytes()).hexdigest() - assert calculate_prompt_hash(prompt, hash_version=1) == expected +def test_get_pdd_file_paths_current_context_owner_still_suppresses(tmp_path, monkeypatch): + """r15 F3 control: a path-owning row in the CURRENT context legitimately owns the + qualified basename and still uniquely resolves (the sibling-context guard must not + over-suppress a genuine same-context owner).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "backend" / "nested").mkdir(parents=True) + (tmp_path / "backend" / "nested" / "widget.py").write_text("v = 1\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["backend/**"]\n', + encoding="utf-8", + ) + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "nested/widget_python.prompt", "filepath": "backend/nested/widget.py"}, + {"filename": "widget_python.prompt", "filepath": "backend/legacy/widget.py"}, + ]), + encoding="utf-8", + ) + paths = get_pdd_file_paths("nested/widget", "python", "prompts", context_override="backend") + assert paths["code"].resolve(strict=False) == ( + tmp_path / "backend" / "nested" / "widget.py" + ).resolve() -def test_v1_hash_resolves_stored_relative_keys_from_cwd(tmp_path, monkeypatch): - prompt_dir = tmp_path / "prompts" - cwd = tmp_path / "cwd" - prompt_dir.mkdir() - cwd.mkdir() - dependency = cwd / "stored.txt" - dependency.write_bytes(b"stored") - prompt = prompt_dir / "widget.prompt" - prompt.write_bytes(b"no includes\n") - monkeypatch.chdir(cwd) - expected = hashlib.sha256(prompt.read_bytes() + dependency.read_bytes()).hexdigest() - assert calculate_prompt_hash( - prompt, {"stored.txt": "ignored"}, hash_version=1 - ) == expected +# --------------------------------------------------------------------------- +# Round-16 review hardening: the owner pre-pass must apply the FULL selection +# eligibility (current-context ownership OR exact module-naming), and every-hop +# symlink validation must run on ALL paths including when the terminal target +# re-enters the prompts root (leaf AND directory symlink chains). +# --------------------------------------------------------------------------- -def test_v1_old_grammar_ignores_self_closing_and_path_attributes(tmp_path): - prompt = tmp_path / "widget.prompt" - prompt.write_text( - '\nmissing.txt\n' +def test_get_pdd_file_paths_heuristic_owner_out_of_context_does_not_suppress_ambiguity(tmp_path, monkeypatch): + """r16 F1: a MORE-qualified path-owning row (`src/nested/widget`) whose target is + unowned/out-of-current-context is only a heuristic borrow that selection would reject, + so it must NOT suppress two valid legacy rows in the resolving context. Reproduces the + choices=nonempty / selection=(None,None) divergence as a guarded ambiguity.""" + import sync_determine_operation as sync_determine_module + from pathlib import Path as _P + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["backend/**"]\n', encoding="utf-8" ) - assert calculate_prompt_hash(prompt, hash_version=1) == hashlib.sha256( - prompt.read_bytes() - ).hexdigest() + (tmp_path / "architecture.json").write_text( + json.dumps([ + # heuristic owner: path-owns `nested/widget` by SUFFIX, but exact name is + # `src/nested/widget` and its target is unowned (outside backend) -> not selectable + {"filename": "src/nested/widget_python.prompt", "filepath": "shared/nested/widget.py"}, + # two valid legacy rows in the resolving (backend) context -> genuine ambiguity + {"filename": "widget_python.prompt", "filepath": "backend/nested/widget.py"}, + {"filename": "widget_python.prompt", "filepath": "backend/src/nested/widget.py"}, + ]), + encoding="utf-8", + ) + choices = sync_determine_module._architecture_module_choices( + _P("architecture.json"), "nested/widget", "python", context_name="backend" + ) + assert "backend/nested/widget.py" in choices + assert "backend/src/nested/widget.py" in choices + with pytest.raises(sync_determine_module.AmbiguousModuleError): + get_pdd_file_paths("nested/widget", "python", "prompts", context_override="backend") -@pytest.mark.parametrize( - "markup", - [ - '', - 'dep.txt', - ], -) -def test_v1_new_grammar_save_reload_rerun_does_not_self_drift( - tmp_path, monkeypatch, markup -): +def test_get_pdd_file_paths_exact_name_owner_out_of_context_still_suppresses(tmp_path, monkeypatch): + """r16 F1 control: a row whose filename EXACTLY names the qualified module + (`nested/widget`) IS its own explicit mapping (selection's row_names_this_module), so it + still uniquely resolves even when its target sits outside the current context's globs.""" monkeypatch.chdir(tmp_path) - prompt = tmp_path / "widget.prompt" - prompt.write_text(markup, encoding="utf-8") - (tmp_path / "dep.txt").write_text("dependency", encoding="utf-8") + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "shared" / "nested").mkdir(parents=True) + (tmp_path / "shared" / "nested" / "widget.py").write_text("v = 1\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["backend/**"]\n', encoding="utf-8" + ) + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "nested/widget_python.prompt", "filepath": "shared/nested/widget.py"}, + {"filename": "widget_python.prompt", "filepath": "backend/legacy/widget.py"}, + ]), + encoding="utf-8", + ) + paths = get_pdd_file_paths("nested/widget", "python", "prompts", context_override="backend") + assert paths["code"].resolve(strict=False) == ( + tmp_path / "shared" / "nested" / "widget.py" + ).resolve() - first = calculate_prompt_hash(prompt, hash_version=1) - persisted = extract_include_deps(prompt, version=1) - reloaded = json.loads(json.dumps(persisted)) - second = calculate_prompt_hash(prompt, reloaded, hash_version=1) - assert persisted == {} - assert second == first +def test_get_pdd_file_paths_rejects_leaf_alias_reentering_prompts_root_via_external(tmp_path, monkeypatch): + """r16 F2: a LEAF alias hopping through an EXTERNAL intermediate symlink that re-enters + BENEATH the prompts root (terminal target inside prompts_root) must still be rejected — + the every-hop check runs even on the terminal-contained path.""" + root = tmp_path / "repo" + (root / "prompts" / "canonical").mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "prompts" / "canonical" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (root / "src").mkdir() + (root / "src" / "widget.py").write_text("v = 1\n", encoding="utf-8") + ext = tmp_path / "external" + ext.mkdir() + try: + (ext / "mid").symlink_to(root / "prompts" / "canonical" / "widget_python.prompt") + (root / "prompts" / "widget_python.prompt").symlink_to(ext / "mid") + except OSError: + pytest.skip("symlinks unavailable") + (root / "architecture.json").write_text( + json.dumps([{"filename": "widget_python.prompt", "filepath": "src/widget.py"}]), + encoding="utf-8", + ) + monkeypatch.chdir(root) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("widget", "python", "prompts") -@pytest.mark.parametrize("policy_mutation", [None, "delete", "rename"]) -def test_sync_classifier_preserves_nested_prompt_alias_identity( - tmp_path, monkeypatch, policy_mutation -): - """Architecture, include closure, and hashing use the approved logical path.""" +def test_get_pdd_file_paths_rejects_dir_symlink_reentering_prompts_root_via_external(tmp_path, monkeypatch): + """r16 F2: a DIRECTORY-symlink chain leaving the repo and re-entering beneath the prompts + root must be rejected too — realpath(parent) would collapse the external hop, so the + manual every-hop walk is required.""" root = tmp_path / "repo" - root.mkdir() + (root / "prompts" / "real").mkdir(parents=True) subprocess.run(["git", "init", "-q"], cwd=root, check=True) - subprocess.run( - ["git", "config", "user.email", "sync@example.com"], cwd=root, check=True + (root / "prompts" / "real" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (root / "src").mkdir() + (root / "src" / "widget.py").write_text("v = 1\n", encoding="utf-8") + ext = tmp_path / "external" + ext.mkdir() + try: + (ext / "dirlink").symlink_to(root / "prompts" / "real", target_is_directory=True) + (root / "prompts" / "link").symlink_to(ext / "dirlink", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + (root / "architecture.json").write_text( + json.dumps([{"filename": "link/widget_python.prompt", "filepath": "src/widget.py"}]), + encoding="utf-8", ) - subprocess.run( - ["git", "config", "user.name", "Sync Test"], cwd=root, check=True + import sync_determine_operation as sync_determine_module + monkeypatch.chdir(root) + with pytest.raises((UnsafePromptPathError, sync_determine_module.AmbiguousModuleError)): + get_pdd_file_paths("link/widget", "python", "prompts") + + +def test_symlink_chain_within_root_component_walk(tmp_path): + """r16 F2 (unit): the every-hop walk rejects a chain leaving the repo and re-entering + (leaf and directory forms), while accepting a fully in-repo alias and the trusted + prompt-root directory-symlink topology (prompts -> pdd/prompts).""" + import sync_determine_operation as sync_determine_module + root = tmp_path / "repo" + (root / "prompts" / "canonical").mkdir(parents=True) + (root / "pdd" / "prompts").mkdir(parents=True) + (root / "pdd" / "prompts" / "f.prompt").write_text("x\n", encoding="utf-8") + (root / "prompts" / "canonical" / "f").write_text("y\n", encoding="utf-8") + ext = tmp_path / "ext" + ext.mkdir() + try: + (ext / "mid").symlink_to(root / "prompts" / "canonical" / "f") + (root / "prompts" / "via_ext").symlink_to(ext / "mid") # leaves + re-enters + (root / "prompts" / "in_repo").symlink_to(root / "prompts" / "canonical" / "f") + (root / "top_prompts").symlink_to(root / "pdd" / "prompts", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + root_real = os.path.realpath(str(root)) + assert sync_determine_module._symlink_chain_within_root(root / "prompts" / "via_ext", root_real) is False + assert sync_determine_module._symlink_chain_within_root(root / "prompts" / "in_repo", root_real) is True + # prompt-root directory symlink staying in-repo is accepted + assert sync_determine_module._symlink_chain_within_root(root / "top_prompts" / "f.prompt", root_real) is True + + +def test_get_pdd_file_paths_discovered_prompt_not_reanchored_from_parent_cwd(tmp_path, monkeypatch): + """CI regression: a DISCOVERED prompt is the real CWD-relative on-disk path and must + NOT be re-anchored against a nested subproject governing root — doing so DOUBLED the + subproject prefix (extensions/app/extensions/app/prompts/...) and broke + get_fingerprint_path's walk-up to the subproject .pdd/meta. Only a CONFIGURED + outputs.prompt.path (governing-root-relative, Issue #237) is re-anchored.""" + sub = tmp_path / "extensions" / "github_pdd_app" + prompts_dir = sub / "prompts" / "src" / "routers" + prompts_dir.mkdir(parents=True) + (sub / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n' + ' default_language: "python"\n' + ' prompts_dir: "prompts/src/routers"\n' + ' generate_output_path: "src/routers/"\n', + encoding="utf-8", ) - (root / "prompts/nested").mkdir(parents=True) - (root / "canonical-prompts").mkdir() - (root / ".pdd").mkdir() - (root / "src/nested").mkdir(parents=True) - canonical_prompt = root / "canonical-prompts/widget_python.prompt" - canonical_prompt.write_text("Build widget\ncontract.md\n") - logical_prompt = root / "prompts/nested/widget_python.prompt" - logical_prompt.symlink_to("../../canonical-prompts/widget_python.prompt") - contract = root / "prompts/nested/contract.md" - contract.write_text("logical contract\n") - code = root / "src/nested/widget.py" - code.write_text("value = 1\n") - wrong_code = root / "wrong/nested/widget.py" - wrong_code.parent.mkdir(parents=True) - wrong_code.write_text("wrong = True\n") + (prompts_dir / "webhook_handlers_Python.prompt").write_text("p", encoding="utf-8") + monkeypatch.chdir(tmp_path) + result = get_pdd_file_paths( + "webhook_handlers", "python", + prompts_dir="extensions/github_pdd_app/prompts/src/routers", + ) + resolved_prompt = Path(result["prompt"]).resolve() + # exactly ONE subproject prefix — not doubled + assert resolved_prompt == (prompts_dir / "webhook_handlers_Python.prompt").resolve() + assert str(resolved_prompt).count("github_pdd_app") == 1 + + +# --------------------------------------------------------------------------- +# Round-17 review hardening: every-hop symlink validation must run in NON-Git +# projects too; the R11 owner pre-pass must reject a symlinked owner by its +# RESOLVED sibling-context identity (as selection does); and the manual walker +# must preserve Windows drive-letter / UNC anchors. +# --------------------------------------------------------------------------- + + +def test_get_pdd_file_paths_nongit_rejects_leaf_alias_reentering_via_external(tmp_path, monkeypatch): + """r17 F1: in a NON-Git project, a leaf alias hopping through an EXTERNAL intermediate + symlink that re-enters prompts_root must still be rejected (no enclosing repo).""" + root = tmp_path / "proj" # NOT a git repo + (root / "prompts" / "canonical").mkdir(parents=True) + (root / "prompts" / "canonical" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (root / "src").mkdir() + (root / "src" / "widget.py").write_text("v = 1\n", encoding="utf-8") + ext = tmp_path / "external" + ext.mkdir() + try: + (ext / "mid").symlink_to(root / "prompts" / "canonical" / "widget_python.prompt") + (root / "prompts" / "widget_python.prompt").symlink_to(ext / "mid") + except OSError: + pytest.skip("symlinks unavailable") (root / "architecture.json").write_text( - json.dumps( - [ - { - "filename": "widget_python.prompt", - "filepath": "wrong/nested/widget.py", - }, - { - "filename": "nested/widget_python.prompt", - "filepath": "src/nested/widget.py", - } - ] - ) + json.dumps([{"filename": "widget_python.prompt", "filepath": "src/widget.py"}]), + encoding="utf-8", ) - (root / ".pdd/repository-id").write_text( - "3b4d7b1c-d6cc-4752-ba93-6b98d1a710e0\n" + monkeypatch.chdir(root) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("widget", "python", "prompts") + + +def test_get_pdd_file_paths_nongit_rejects_dir_alias_reentering_via_external(tmp_path, monkeypatch): + """r17 F1: NON-Git directory-symlink chain leaving the project and re-entering + prompts_root is rejected too.""" + root = tmp_path / "proj" + (root / "prompts" / "real").mkdir(parents=True) + (root / "prompts" / "real" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (root / "src").mkdir() + (root / "src" / "widget.py").write_text("v = 1\n", encoding="utf-8") + ext = tmp_path / "external" + ext.mkdir() + try: + (ext / "dirlink").symlink_to(root / "prompts" / "real", target_is_directory=True) + (root / "prompts" / "link").symlink_to(ext / "dirlink", target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + (root / "architecture.json").write_text( + json.dumps([{"filename": "link/widget_python.prompt", "filepath": "src/widget.py"}]), + encoding="utf-8", ) - (root / ".pdd/sync-policy.json").write_text( - json.dumps({"schema_version": 1, "enforcement": "active"}) + import sync_determine_operation as sync_determine_module + monkeypatch.chdir(root) + with pytest.raises((UnsafePromptPathError, sync_determine_module.AmbiguousModuleError)): + get_pdd_file_paths("link/widget", "python", "prompts") + + +def test_get_pdd_file_paths_nongit_toplevel_prompts_symlink_control(tmp_path, monkeypatch): + """r17 F1 control: a trusted NON-Git top-level prompt-root symlink (prompts -> + pdd/prompts, both in-project) must STILL resolve after the non-git hop hardening.""" + (tmp_path / "pdd" / "prompts").mkdir(parents=True) + (tmp_path / "pdd" / "prompts" / "foo_python.prompt").write_text("% f\n", encoding="utf-8") + try: + (tmp_path / "prompts").symlink_to("pdd/prompts", target_is_directory=True) + except OSError: + pytest.skip("directory symlinks unavailable") + monkeypatch.chdir(tmp_path) + paths = get_pdd_file_paths("foo", "python", prompts_dir="prompts") + assert Path(paths["prompt"]).resolve() == ( + tmp_path / "pdd" / "prompts" / "foo_python.prompt" + ).resolve() + + +def test_get_pdd_file_paths_resolved_sibling_owner_does_not_suppress_ambiguity(tmp_path, monkeypatch): + """r17 F2: a path-owning row whose LEXICAL filepath is in the current context but which + RESOLVES (through an in-project symlink) into a SIBLING context must not set the owner + flag — selection would reject it — so two valid legacy rows stay ambiguous.""" + import sync_determine_operation as sync_determine_module + from pathlib import Path as _P + monkeypatch.chdir(tmp_path) + (tmp_path / "prompts" / "nested").mkdir(parents=True) + (tmp_path / "prompts" / "nested" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / ".pddrc").write_text( + 'contexts:\n backend:\n paths: ["backend/**"]\n frontend:\n paths: ["frontend/**"]\n', + encoding="utf-8", ) - (root / ".pdd/sync-aliases.json").write_text( - json.dumps( - { - "schema_version": 1, - "aliases": [ - { - "alias_path": "prompts/nested/widget_python.prompt", - "canonical_path": "canonical-prompts/widget_python.prompt", - } - ], - } + (tmp_path / "frontend" / "nested").mkdir(parents=True) + (tmp_path / "frontend" / "nested" / "widget.py").write_text("v = 1\n", encoding="utf-8") + (tmp_path / "backend" / "nested").mkdir(parents=True) + try: + # lexically backend, but a symlink resolving into the frontend (sibling) context + (tmp_path / "backend" / "nested" / "widget.py").symlink_to( + tmp_path / "frontend" / "nested" / "widget.py" ) + except OSError: + pytest.skip("symlinks unavailable") + for leg in ("legacy_a", "legacy_b"): + (tmp_path / "backend" / leg / "nested").mkdir(parents=True) + (tmp_path / "backend" / leg / "nested" / "widget.py").write_text("x\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "nested/widget_python.prompt", "filepath": "backend/nested/widget.py"}, + {"filename": "widget_python.prompt", "filepath": "backend/legacy_a/nested/widget.py"}, + {"filename": "widget_python.prompt", "filepath": "backend/legacy_b/nested/widget.py"}, + ]), + encoding="utf-8", ) - subprocess.run(["git", "add", "."], cwd=root, check=True) - subprocess.run( - ["git", "commit", "-q", "-m", "nested prompt alias"], cwd=root, check=True + choices = sync_determine_module._architecture_module_choices( + _P("architecture.json"), "nested/widget", "python", context_name="backend" + ) + assert "backend/legacy_a/nested/widget.py" in choices + assert "backend/legacy_b/nested/widget.py" in choices + + +def test_split_path_anchor_preserves_windows_and_unc_anchors(): + """r17 F3: the manual walker's anchor extraction must preserve a Windows drive letter + and a UNC share root rather than restarting traversal from a bare separator (which + would drop the drive and fail containment for valid Windows in-repo aliases).""" + import ntpath + import posixpath + import sync_determine_operation as sync_determine_module + drive_anchor, drive_parts = sync_determine_module._split_path_anchor( + r"C:\repo\prompts\alias", ntpath + ) + assert drive_anchor == "C:\\" + assert drive_parts == ["repo", "prompts", "alias"] + unc_anchor, unc_parts = sync_determine_module._split_path_anchor( + r"\\server\share\repo\x", ntpath + ) + assert unc_anchor == "\\\\server\\share\\" + assert unc_parts == ["repo", "x"] + posix_anchor, posix_parts = sync_determine_module._split_path_anchor( + "/repo/prompts/alias", posixpath + ) + assert posix_anchor == "/" + assert posix_parts == ["repo", "prompts", "alias"] + + +# --------------------------------------------------------------------------- +# Round-18 review hardening: every-hop validation on code/example/test artifact +# paths; R11 pre-pass + selection share physical prompt identity under prompt-root +# prefix-overlap; and a convention-reconstructed dangling alias must fail closed. +# --------------------------------------------------------------------------- + + +def test_get_pdd_file_paths_artifact_rejects_external_intermediate_symlink(tmp_path, monkeypatch): + """r18 F1: a configured artifact output (outputs.code.path) reached through an EXTERNAL + intermediate directory symlink that re-enters the repo must be rejected — the every-hop + walk applies to code/example/test, not only prompts (retarget-safe).""" + root = tmp_path / "repo" + (root / "prompts").mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "prompts" / "foo_python.prompt").write_text("% f\n", encoding="utf-8") + (root / "realsrc").mkdir() + ext = tmp_path / "external" + ext.mkdir() + try: + (ext / "back").symlink_to(root / "realsrc", target_is_directory=True) + (root / "link").symlink_to(ext, target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + (root / ".pddrc").write_text( + 'contexts:\n default:\n paths: ["**"]\n defaults:\n outputs:\n' + ' code:\n path: "link/back/foo.py"\n', + encoding="utf-8", ) - policy = root / ".pdd/sync-policy.json" - if policy_mutation == "delete": - policy.unlink() - elif policy_mutation == "rename": - policy.rename(policy.with_suffix(".disabled")) monkeypatch.chdir(root) - monkeypatch.delenv("PDD_SYNC_PROTECTED_BASE_SHA", raising=False) + with pytest.raises((UnsafeOutputPathError, UnsafePromptPathError)): + get_pdd_file_paths("foo", "python", "prompts") - paths = get_pdd_file_paths("nested/widget", "python", "prompts") - deps = extract_include_deps(paths["prompt"]) - digest_before = calculate_prompt_hash(paths["prompt"]) - contract.write_text("changed logical contract\n") - digest_after = calculate_prompt_hash(paths["prompt"]) - assert paths["prompt"] == Path("prompts/nested/widget_python.prompt") - assert paths["code"].resolve() == code.resolve() - assert deps == { - "prompts/nested/contract.md": hashlib.sha256( - b"logical contract\n" - ).hexdigest() - } - assert digest_before is not None - assert digest_after is not None - assert digest_after != digest_before +def test_get_pdd_file_paths_prompt_root_overlap_is_ambiguous(tmp_path, monkeypatch): + """r18 F2: when the prompt root already represents the qualified dir (pdd/prompts), a + bare row and a `prompts/`-prefixed row both physically own the same prompt. Two distinct + outputs is a genuine ambiguity that must be BLOCKED (not silently resolved to one row).""" + monkeypatch.chdir(tmp_path) + (tmp_path / "pdd" / "prompts").mkdir(parents=True) + (tmp_path / "pdd" / "prompts" / "widget_python.prompt").write_text("% w\n", encoding="utf-8") + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "widget_python.prompt", "filepath": "legacy/prompts/widget.py"}, + {"filename": "prompts/widget_python.prompt", "filepath": "src/prompts/widget.py"}, + ]), + encoding="utf-8", + ) + import sync_determine_operation as sync_determine_module + with pytest.raises(sync_determine_module.AmbiguousModuleError): + get_pdd_file_paths("prompts/widget", "python", prompts_dir="pdd/prompts") + + +def test_architecture_module_choices_prompt_root_overlap_counts_both_rows(tmp_path): + """r18 F2 (helper): _architecture_module_choices canonicalises `prompts/widget` against + a `pdd/prompts` root (prefix-overlap stripping) so BOTH the bare and prefixed rows count + -> two distinct outputs -> ambiguity, matching what final selection would face.""" + import sync_determine_operation as sync_determine_module + from pathlib import Path as _P + (tmp_path / "architecture.json").write_text( + json.dumps([ + {"filename": "widget_python.prompt", "filepath": "legacy/prompts/widget.py"}, + {"filename": "prompts/widget_python.prompt", "filepath": "src/prompts/widget.py"}, + ]), + encoding="utf-8", + ) + choices = sync_determine_module._architecture_module_choices( + tmp_path / "architecture.json", "prompts/widget", "python", + prompts_root=_P("pdd/prompts"), + ) + assert "legacy/prompts/widget.py" in choices + assert "src/prompts/widget.py" in choices + + +def test_get_pdd_file_paths_dangling_convention_alias_fails_closed(tmp_path, monkeypatch): + """r18 F3: a nested `prompts/foo -> ../../shared/foo` whose in-repo target does NOT exist + is excluded by discovery (is_file False); the missing-prompt convention reconstruction + must NOT grant it the discovery-only approved-alias privilege — it fails closed.""" + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "shared").mkdir() # target dir exists, but the prompt file does NOT + sub = root / "sub" + (sub / "prompts").mkdir(parents=True) + try: + (sub / "prompts" / "foo_python.prompt").symlink_to("../../shared/foo_python.prompt") + except OSError: + pytest.skip("symlinks unavailable") + (sub / ".pddrc").write_text('contexts:\n default:\n paths: ["**"]\n', encoding="utf-8") + monkeypatch.chdir(sub) + with pytest.raises(UnsafePromptPathError): + get_pdd_file_paths("foo", "python", "prompts") + + +def test_get_pdd_file_paths_discovered_alias_control_still_resolves(tmp_path, monkeypatch): + """r18 F3 control: a genuinely DISCOVERED approved alias whose in-repo target EXISTS must + still resolve after the provenance tightening.""" + root = tmp_path / "repo" + root.mkdir() + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "shared-prompts").mkdir() + (root / "shared-prompts" / "widget_python.prompt").write_text("Build\n", encoding="utf-8") + sub = root / "sub" + (sub / "prompts").mkdir(parents=True) + (sub / "src").mkdir(parents=True) + (sub / "src" / "widget.py").write_text("v = 1\n", encoding="utf-8") + try: + (sub / "prompts" / "widget_python.prompt").symlink_to( + "../../shared-prompts/widget_python.prompt" + ) + except OSError: + pytest.skip("symlinks unavailable") + (sub / ".pddrc").write_text('contexts:\n default:\n paths: ["**"]\n', encoding="utf-8") + (sub / "architecture.json").write_text( + json.dumps([{"filename": "widget_python.prompt", "filepath": "src/widget.py"}]), + encoding="utf-8", + ) + monkeypatch.chdir(sub) + paths = get_pdd_file_paths("widget", "python", "prompts") + assert paths["prompt"].resolve(strict=False) == ( + root / "shared-prompts" / "widget_python.prompt" + ).resolve()