pdd-cli 0.0.45__py3-none-any.whl → 0.0.118__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. pdd/__init__.py +40 -8
  2. pdd/agentic_bug.py +323 -0
  3. pdd/agentic_bug_orchestrator.py +497 -0
  4. pdd/agentic_change.py +231 -0
  5. pdd/agentic_change_orchestrator.py +526 -0
  6. pdd/agentic_common.py +598 -0
  7. pdd/agentic_crash.py +534 -0
  8. pdd/agentic_e2e_fix.py +319 -0
  9. pdd/agentic_e2e_fix_orchestrator.py +426 -0
  10. pdd/agentic_fix.py +1294 -0
  11. pdd/agentic_langtest.py +162 -0
  12. pdd/agentic_update.py +387 -0
  13. pdd/agentic_verify.py +183 -0
  14. pdd/architecture_sync.py +565 -0
  15. pdd/auth_service.py +210 -0
  16. pdd/auto_deps_main.py +71 -51
  17. pdd/auto_include.py +245 -5
  18. pdd/auto_update.py +125 -47
  19. pdd/bug_main.py +196 -23
  20. pdd/bug_to_unit_test.py +2 -0
  21. pdd/change_main.py +11 -4
  22. pdd/cli.py +22 -1181
  23. pdd/cmd_test_main.py +350 -150
  24. pdd/code_generator.py +60 -18
  25. pdd/code_generator_main.py +790 -57
  26. pdd/commands/__init__.py +48 -0
  27. pdd/commands/analysis.py +306 -0
  28. pdd/commands/auth.py +309 -0
  29. pdd/commands/connect.py +290 -0
  30. pdd/commands/fix.py +163 -0
  31. pdd/commands/generate.py +257 -0
  32. pdd/commands/maintenance.py +175 -0
  33. pdd/commands/misc.py +87 -0
  34. pdd/commands/modify.py +256 -0
  35. pdd/commands/report.py +144 -0
  36. pdd/commands/sessions.py +284 -0
  37. pdd/commands/templates.py +215 -0
  38. pdd/commands/utility.py +110 -0
  39. pdd/config_resolution.py +58 -0
  40. pdd/conflicts_main.py +8 -3
  41. pdd/construct_paths.py +589 -111
  42. pdd/context_generator.py +10 -2
  43. pdd/context_generator_main.py +175 -76
  44. pdd/continue_generation.py +53 -10
  45. pdd/core/__init__.py +33 -0
  46. pdd/core/cli.py +527 -0
  47. pdd/core/cloud.py +237 -0
  48. pdd/core/dump.py +554 -0
  49. pdd/core/errors.py +67 -0
  50. pdd/core/remote_session.py +61 -0
  51. pdd/core/utils.py +90 -0
  52. pdd/crash_main.py +262 -33
  53. pdd/data/language_format.csv +71 -63
  54. pdd/data/llm_model.csv +20 -18
  55. pdd/detect_change_main.py +5 -4
  56. pdd/docs/prompting_guide.md +864 -0
  57. pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
  58. pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
  59. pdd/fix_code_loop.py +523 -95
  60. pdd/fix_code_module_errors.py +6 -2
  61. pdd/fix_error_loop.py +491 -92
  62. pdd/fix_errors_from_unit_tests.py +4 -3
  63. pdd/fix_main.py +278 -21
  64. pdd/fix_verification_errors.py +12 -100
  65. pdd/fix_verification_errors_loop.py +529 -286
  66. pdd/fix_verification_main.py +294 -89
  67. pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
  68. pdd/frontend/dist/assets/index-DQ3wkeQ2.js +449 -0
  69. pdd/frontend/dist/index.html +376 -0
  70. pdd/frontend/dist/logo.svg +33 -0
  71. pdd/generate_output_paths.py +139 -15
  72. pdd/generate_test.py +218 -146
  73. pdd/get_comment.py +19 -44
  74. pdd/get_extension.py +8 -9
  75. pdd/get_jwt_token.py +318 -22
  76. pdd/get_language.py +8 -7
  77. pdd/get_run_command.py +75 -0
  78. pdd/get_test_command.py +68 -0
  79. pdd/git_update.py +70 -19
  80. pdd/incremental_code_generator.py +2 -2
  81. pdd/insert_includes.py +13 -4
  82. pdd/llm_invoke.py +1711 -181
  83. pdd/load_prompt_template.py +19 -12
  84. pdd/path_resolution.py +140 -0
  85. pdd/pdd_completion.fish +25 -2
  86. pdd/pdd_completion.sh +30 -4
  87. pdd/pdd_completion.zsh +79 -4
  88. pdd/postprocess.py +14 -4
  89. pdd/preprocess.py +293 -24
  90. pdd/preprocess_main.py +41 -6
  91. pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
  92. pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
  93. pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
  94. pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
  95. pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
  96. pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
  97. pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
  98. pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
  99. pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
  100. pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
  101. pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
  102. pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
  103. pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +131 -0
  104. pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
  105. pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
  106. pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
  107. pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
  108. pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
  109. pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
  110. pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
  111. pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
  112. pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
  113. pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
  114. pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
  115. pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
  116. pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
  117. pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
  118. pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
  119. pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
  120. pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
  121. pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
  122. pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
  123. pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
  124. pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
  125. pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
  126. pdd/prompts/agentic_update_LLM.prompt +925 -0
  127. pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
  128. pdd/prompts/auto_include_LLM.prompt +122 -905
  129. pdd/prompts/change_LLM.prompt +3093 -1
  130. pdd/prompts/detect_change_LLM.prompt +686 -27
  131. pdd/prompts/example_generator_LLM.prompt +22 -1
  132. pdd/prompts/extract_code_LLM.prompt +5 -1
  133. pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
  134. pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
  135. pdd/prompts/extract_promptline_LLM.prompt +17 -11
  136. pdd/prompts/find_verification_errors_LLM.prompt +6 -0
  137. pdd/prompts/fix_code_module_errors_LLM.prompt +12 -2
  138. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +9 -0
  139. pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
  140. pdd/prompts/generate_test_LLM.prompt +41 -7
  141. pdd/prompts/generate_test_from_example_LLM.prompt +115 -0
  142. pdd/prompts/increase_tests_LLM.prompt +1 -5
  143. pdd/prompts/insert_includes_LLM.prompt +316 -186
  144. pdd/prompts/prompt_code_diff_LLM.prompt +119 -0
  145. pdd/prompts/prompt_diff_LLM.prompt +82 -0
  146. pdd/prompts/trace_LLM.prompt +25 -22
  147. pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
  148. pdd/prompts/update_prompt_LLM.prompt +22 -1
  149. pdd/pytest_output.py +127 -12
  150. pdd/remote_session.py +876 -0
  151. pdd/render_mermaid.py +236 -0
  152. pdd/server/__init__.py +52 -0
  153. pdd/server/app.py +335 -0
  154. pdd/server/click_executor.py +587 -0
  155. pdd/server/executor.py +338 -0
  156. pdd/server/jobs.py +661 -0
  157. pdd/server/models.py +241 -0
  158. pdd/server/routes/__init__.py +31 -0
  159. pdd/server/routes/architecture.py +451 -0
  160. pdd/server/routes/auth.py +364 -0
  161. pdd/server/routes/commands.py +929 -0
  162. pdd/server/routes/config.py +42 -0
  163. pdd/server/routes/files.py +603 -0
  164. pdd/server/routes/prompts.py +1322 -0
  165. pdd/server/routes/websocket.py +473 -0
  166. pdd/server/security.py +243 -0
  167. pdd/server/terminal_spawner.py +209 -0
  168. pdd/server/token_counter.py +222 -0
  169. pdd/setup_tool.py +648 -0
  170. pdd/simple_math.py +2 -0
  171. pdd/split_main.py +3 -2
  172. pdd/summarize_directory.py +237 -195
  173. pdd/sync_animation.py +8 -4
  174. pdd/sync_determine_operation.py +839 -112
  175. pdd/sync_main.py +351 -57
  176. pdd/sync_orchestration.py +1400 -756
  177. pdd/sync_tui.py +848 -0
  178. pdd/template_expander.py +161 -0
  179. pdd/template_registry.py +264 -0
  180. pdd/templates/architecture/architecture_json.prompt +237 -0
  181. pdd/templates/generic/generate_prompt.prompt +174 -0
  182. pdd/trace.py +168 -12
  183. pdd/trace_main.py +4 -3
  184. pdd/track_cost.py +140 -63
  185. pdd/unfinished_prompt.py +51 -4
  186. pdd/update_main.py +567 -67
  187. pdd/update_model_costs.py +2 -2
  188. pdd/update_prompt.py +19 -4
  189. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/METADATA +29 -11
  190. pdd_cli-0.0.118.dist-info/RECORD +227 -0
  191. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/licenses/LICENSE +1 -1
  192. pdd_cli-0.0.45.dist-info/RECORD +0 -116
  193. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/WHEEL +0 -0
  194. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/entry_points.txt +0 -0
  195. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.118.dist-info}/top_level.txt +0 -0
pdd/construct_paths.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
  import sys
5
5
  import os
6
6
  from pathlib import Path
7
- from typing import Dict, Tuple, Any, Optional, List
7
+ from typing import Dict, Tuple, Any, Optional, List, Callable
8
8
  import fnmatch
9
9
  import logging
10
10
 
@@ -56,6 +56,163 @@ def _load_pddrc_config(pddrc_path: Path) -> Dict[str, Any]:
56
56
  except Exception as e:
57
57
  raise ValueError(f"Error loading .pddrc: {e}")
58
58
 
59
+ def list_available_contexts(start_path: Optional[Path] = None) -> list[str]:
60
+ """Return sorted context names from the nearest .pddrc.
61
+
62
+ - Searches upward from `start_path` (or CWD) for a `.pddrc` file.
63
+ - If found, loads and validates it, then returns sorted context names.
64
+ - If no `.pddrc` exists, returns ["default"].
65
+ - Propagates ValueError for malformed `.pddrc` to allow callers to render
66
+ helpful errors.
67
+ """
68
+ pddrc = _find_pddrc_file(start_path)
69
+ if not pddrc:
70
+ return ["default"]
71
+ config = _load_pddrc_config(pddrc)
72
+ contexts = config.get("contexts", {})
73
+ names = sorted(contexts.keys()) if isinstance(contexts, dict) else []
74
+ return names or ["default"]
75
+
76
+ def _match_path_to_contexts(
77
+ path_str: str,
78
+ contexts: Dict[str, Any],
79
+ use_specificity: bool = False,
80
+ is_absolute: bool = False
81
+ ) -> Optional[str]:
82
+ """
83
+ Core pattern matching logic - matches a path against context patterns.
84
+
85
+ Args:
86
+ path_str: Path to match (can be relative or absolute)
87
+ contexts: The contexts dict from .pddrc config
88
+ use_specificity: If True, return most specific match; else first match
89
+ is_absolute: If True, use absolute path matching with "*/" prefix
90
+
91
+ Returns:
92
+ Context name or None
93
+ """
94
+ matches = []
95
+ for context_name, context_config in contexts.items():
96
+ if context_name == 'default':
97
+ continue
98
+ for path_pattern in context_config.get('paths', []):
99
+ pattern_base = path_pattern.rstrip('/**').rstrip('/*')
100
+
101
+ # Check for match - handle both absolute and relative paths
102
+ matched = False
103
+ if is_absolute:
104
+ # For absolute paths (CWD-based detection), use existing logic
105
+ if fnmatch.fnmatch(path_str, f"*/{path_pattern}") or \
106
+ fnmatch.fnmatch(path_str, path_pattern) or \
107
+ path_str.endswith(f"/{pattern_base}"):
108
+ matched = True
109
+ else:
110
+ # For relative paths (file-based detection)
111
+ if fnmatch.fnmatch(path_str, path_pattern) or \
112
+ path_str.startswith(pattern_base + '/') or \
113
+ path_str.startswith(pattern_base):
114
+ matched = True
115
+
116
+ if matched:
117
+ if not use_specificity:
118
+ return context_name # First match wins
119
+ matches.append((context_name, len(pattern_base)))
120
+
121
+ if matches:
122
+ matches.sort(key=lambda x: x[1], reverse=True)
123
+ return matches[0][0]
124
+
125
+ return 'default' if 'default' in contexts else None
126
+
127
+
128
+ def _detect_context_from_basename(basename: str, config: Dict[str, Any]) -> Optional[str]:
129
+ """Detect context by matching a sync basename against prompts_dir prefixes or paths patterns."""
130
+ if not basename:
131
+ return None
132
+
133
+ contexts = config.get('contexts', {})
134
+ matches = []
135
+
136
+ for context_name, context_config in contexts.items():
137
+ if context_name == 'default':
138
+ continue
139
+
140
+ defaults = context_config.get('defaults', {})
141
+ prompts_dir = defaults.get('prompts_dir', '')
142
+ if prompts_dir:
143
+ normalized = prompts_dir.rstrip('/')
144
+ prefix = normalized
145
+ if normalized == 'prompts':
146
+ prefix = ''
147
+ elif normalized.startswith('prompts/'):
148
+ prefix = normalized[len('prompts/'):]
149
+
150
+ if prefix and (basename == prefix or basename.startswith(prefix + '/')):
151
+ matches.append((context_name, len(prefix)))
152
+ continue
153
+
154
+ for path_pattern in context_config.get('paths', []):
155
+ pattern_base = path_pattern.rstrip('/**').rstrip('/*')
156
+ if fnmatch.fnmatch(basename, path_pattern) or \
157
+ basename.startswith(pattern_base + '/') or \
158
+ basename == pattern_base:
159
+ matches.append((context_name, len(pattern_base)))
160
+
161
+ if not matches:
162
+ return None
163
+
164
+ matches.sort(key=lambda item: item[1], reverse=True)
165
+ return matches[0][0]
166
+
167
+
168
+ def _get_relative_basename(input_path: str, pattern: str) -> str:
169
+ """
170
+ Compute basename relative to the matched pattern base.
171
+
172
+ This is critical for Issue #237: when a context pattern like
173
+ 'frontend/components/**' matches 'frontend/components/marketplace/AssetCard',
174
+ we need to return 'marketplace/AssetCard' (relative to pattern base),
175
+ not the full path which would cause double-pathing in output.
176
+
177
+ Args:
178
+ input_path: The full input path (e.g., 'frontend/components/marketplace/AssetCard')
179
+ pattern: The matching pattern (e.g., 'frontend/components/**')
180
+
181
+ Returns:
182
+ Path relative to the pattern base (e.g., 'marketplace/AssetCard')
183
+
184
+ Examples:
185
+ >>> _get_relative_basename('frontend/components/marketplace/AssetCard', 'frontend/components/**')
186
+ 'marketplace/AssetCard'
187
+ >>> _get_relative_basename('backend/utils/credit_helpers', 'backend/utils/**')
188
+ 'credit_helpers'
189
+ >>> _get_relative_basename('unknown/path', 'other/**')
190
+ 'unknown/path' # No match, return as-is
191
+ """
192
+ # Strip glob patterns to get the base directory
193
+ pattern_base = pattern.rstrip('/**').rstrip('/*').rstrip('*')
194
+
195
+ # Remove trailing slash from pattern base if present
196
+ pattern_base = pattern_base.rstrip('/')
197
+
198
+ # Check if input path starts with pattern base
199
+ if input_path.startswith(pattern_base + '/'):
200
+ # Return the portion after pattern_base/
201
+ return input_path[len(pattern_base) + 1:]
202
+ elif input_path.startswith(pattern_base) and len(input_path) > len(pattern_base):
203
+ # Handle case where pattern_base has no trailing content
204
+ remainder = input_path[len(pattern_base):]
205
+ if remainder.startswith('/'):
206
+ return remainder[1:]
207
+ return remainder
208
+ elif input_path == pattern_base:
209
+ # Exact match - return just the last component
210
+ return input_path.split('/')[-1] if '/' in input_path else input_path
211
+
212
+ # No match - return as-is (fallback for default context)
213
+ return input_path
214
+
215
+
59
216
  def _detect_context(current_dir: Path, config: Dict[str, Any], context_override: Optional[str] = None) -> Optional[str]:
60
217
  """Detect the appropriate context based on current directory path."""
61
218
  if context_override:
@@ -65,28 +222,82 @@ def _detect_context(current_dir: Path, config: Dict[str, Any], context_override:
65
222
  available = list(contexts.keys())
66
223
  raise ValueError(f"Unknown context '{context_override}'. Available contexts: {available}")
67
224
  return context_override
68
-
225
+
69
226
  contexts = config.get('contexts', {})
70
- current_path_str = str(current_dir)
71
-
72
- # Try to match against each context's paths
227
+ return _match_path_to_contexts(str(current_dir), contexts, use_specificity=False, is_absolute=True)
228
+
229
+
230
+ def detect_context_for_file(file_path: str, repo_root: Optional[str] = None) -> Tuple[Optional[str], Dict[str, Any]]:
231
+ """
232
+ Detect the appropriate context for a file path based on .pddrc configuration.
233
+
234
+ This function finds the most specific matching context by comparing pattern lengths.
235
+ For example, 'backend/functions/utils/**' is more specific than 'backend/**'.
236
+
237
+ Args:
238
+ file_path: Path to the file (can be absolute or relative)
239
+ repo_root: Optional repository root path. If not provided, will be detected.
240
+
241
+ Returns:
242
+ Tuple of (context_name, context_config_defaults) or (None, {}) if no match.
243
+ """
244
+ # Find repo root if not provided
245
+ if repo_root is None:
246
+ pddrc_path = _find_pddrc_file(Path(file_path).parent)
247
+ if pddrc_path:
248
+ repo_root = str(pddrc_path.parent)
249
+ else:
250
+ try:
251
+ import git
252
+ repo = git.Repo(file_path, search_parent_directories=True)
253
+ repo_root = repo.working_tree_dir
254
+ except:
255
+ repo_root = os.getcwd()
256
+
257
+ # Make file_path relative to repo_root for matching
258
+ file_path_abs = os.path.abspath(file_path)
259
+ repo_root_abs = os.path.abspath(repo_root)
260
+
261
+ if file_path_abs.startswith(repo_root_abs):
262
+ relative_path = os.path.relpath(file_path_abs, repo_root_abs)
263
+ else:
264
+ relative_path = file_path
265
+
266
+ # Find and load .pddrc
267
+ pddrc_path = _find_pddrc_file(Path(repo_root))
268
+ if not pddrc_path:
269
+ return None, {}
270
+
271
+ try:
272
+ config = _load_pddrc_config(pddrc_path)
273
+ except ValueError:
274
+ return None, {}
275
+
276
+ contexts = config.get('contexts', {})
277
+
278
+ # First, try to match against prompts_dir for each context
279
+ # This allows prompt files to be detected even when paths pattern only matches code files
280
+ prompts_dir_matches = []
73
281
  for context_name, context_config in contexts.items():
74
282
  if context_name == 'default':
75
- continue # Handle default as fallback
76
-
77
- paths = context_config.get('paths', [])
78
- for path_pattern in paths:
79
- # Convert glob pattern to match current directory
80
- if fnmatch.fnmatch(current_path_str, f"*/{path_pattern}") or \
81
- fnmatch.fnmatch(current_path_str, path_pattern) or \
82
- current_path_str.endswith(f"/{path_pattern.rstrip('/**')}"):
83
- return context_name
84
-
85
- # Return default context if available
86
- if 'default' in contexts:
87
- return 'default'
88
-
89
- return None
283
+ continue
284
+ prompts_dir = context_config.get('defaults', {}).get('prompts_dir', '')
285
+ if prompts_dir:
286
+ prompts_dir_normalized = prompts_dir.rstrip('/')
287
+ if relative_path.startswith(prompts_dir_normalized + '/') or relative_path == prompts_dir_normalized:
288
+ # Track match with specificity (length of prompts_dir)
289
+ prompts_dir_matches.append((context_name, len(prompts_dir_normalized)))
290
+
291
+ # Return most specific prompts_dir match if any
292
+ if prompts_dir_matches:
293
+ prompts_dir_matches.sort(key=lambda x: x[1], reverse=True)
294
+ matched_context = prompts_dir_matches[0][0]
295
+ return matched_context, _get_context_config(config, matched_context)
296
+
297
+ # Fall back to existing paths pattern matching
298
+ context_name = _match_path_to_contexts(relative_path, contexts, use_specificity=True, is_absolute=False)
299
+ return context_name, _get_context_config(config, context_name)
300
+
90
301
 
91
302
  def _get_context_config(config: Dict[str, Any], context_name: Optional[str]) -> Dict[str, Any]:
92
303
  """Get configuration settings for the specified context."""
@@ -104,12 +315,13 @@ def _resolve_config_hierarchy(
104
315
  ) -> Dict[str, Any]:
105
316
  """Apply configuration hierarchy: CLI > context > environment > defaults."""
106
317
  resolved = {}
107
-
318
+
108
319
  # Configuration keys to resolve
109
320
  config_keys = {
110
321
  'generate_output_path': 'PDD_GENERATE_OUTPUT_PATH',
111
- 'test_output_path': 'PDD_TEST_OUTPUT_PATH',
322
+ 'test_output_path': 'PDD_TEST_OUTPUT_PATH',
112
323
  'example_output_path': 'PDD_EXAMPLE_OUTPUT_PATH',
324
+ 'prompts_dir': 'PDD_PROMPTS_DIR',
113
325
  'default_language': 'PDD_DEFAULT_LANGUAGE',
114
326
  'target_coverage': 'PDD_TEST_COVERAGE_TARGET',
115
327
  'strength': None,
@@ -117,7 +329,7 @@ def _resolve_config_hierarchy(
117
329
  'budget': None,
118
330
  'max_attempts': None,
119
331
  }
120
-
332
+
121
333
  for config_key, env_var in config_keys.items():
122
334
  # 1. CLI options (highest priority)
123
335
  if config_key in cli_options and cli_options[config_key] is not None:
@@ -129,10 +341,56 @@ def _resolve_config_hierarchy(
129
341
  elif env_var and env_var in env_vars:
130
342
  resolved[config_key] = env_vars[env_var]
131
343
  # 4. Defaults are handled elsewhere
132
-
344
+
345
+ # Issue #237: Pass through 'outputs' config for template-based path generation
346
+ # This enables extensible project layouts (Next.js, Vue, Python, Go, etc.)
347
+ if 'outputs' in context_config:
348
+ resolved['outputs'] = context_config['outputs']
349
+
133
350
  return resolved
134
351
 
135
352
 
353
+ def get_tests_dir_from_config(start_path: Optional[Path] = None) -> Optional[Path]:
354
+ """
355
+ Get the tests directory from .pddrc configuration.
356
+
357
+ Searches for .pddrc, detects the appropriate context, and returns the
358
+ configured test_output_path as a Path object.
359
+
360
+ Args:
361
+ start_path: Starting directory for .pddrc search. Defaults to CWD.
362
+
363
+ Returns:
364
+ Path to tests directory if configured, None otherwise.
365
+ """
366
+ if start_path is None:
367
+ start_path = Path.cwd()
368
+
369
+ # Find and load .pddrc
370
+ pddrc_path = _find_pddrc_file(start_path)
371
+ if not pddrc_path:
372
+ return None
373
+
374
+ try:
375
+ config = _load_pddrc_config(pddrc_path)
376
+ except ValueError:
377
+ return None
378
+
379
+ # Detect context and get its config
380
+ context_name = _detect_context(start_path, config)
381
+ context_config = _get_context_config(config, context_name)
382
+
383
+ # Check context config first, then env var
384
+ test_output_path = context_config.get('test_output_path')
385
+ if not test_output_path:
386
+ test_output_path = os.environ.get('PDD_TEST_OUTPUT_PATH')
387
+
388
+ if test_output_path:
389
+ return Path(test_output_path)
390
+
391
+ return None
392
+
393
+
136
394
  def _read_file(path: Path) -> str:
137
395
  """Read a text file safely and return its contents."""
138
396
  try:
@@ -176,52 +434,41 @@ def _candidate_prompt_path(input_files: Dict[str, Path]) -> Path | None:
176
434
  for p in input_files.values():
177
435
  if p.suffix == ".prompt":
178
436
  return p
437
+
438
+ # Final fallback: Return the first file path available (e.g. for pdd update <code_file>)
439
+ if input_files:
440
+ return next(iter(input_files.values()))
441
+
179
442
  return None
180
443
 
181
444
 
182
445
  # New helper function to check if a language is known
183
446
  def _is_known_language(language_name: str) -> bool:
184
- """Checks if a language name is present in the language_format.csv."""
447
+ """Return True if the language is recognized.
448
+
449
+ Prefer CSV in PDD_PATH if available; otherwise fall back to a built-in set
450
+ so basename/language inference does not fail when PDD_PATH is unset.
451
+ """
452
+ language_name_lower = (language_name or "").lower()
453
+ if not language_name_lower:
454
+ return False
455
+
456
+ builtin_languages = {
457
+ 'python', 'javascript', 'typescript', 'java', 'cpp', 'c', 'go', 'ruby', 'rust',
458
+ 'kotlin', 'swift', 'csharp', 'php', 'scala', 'r', 'lua', 'perl', 'bash', 'shell',
459
+ 'powershell', 'sql', 'prompt', 'html', 'css', 'makefile',
460
+ # Common data and config formats for architecture prompts and configs
461
+ 'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini'
462
+ }
463
+
185
464
  pdd_path_str = os.getenv('PDD_PATH')
186
465
  if not pdd_path_str:
187
- # Consistent with get_extension, raise ValueError if PDD_PATH is not set.
188
- # Or, for an internal helper, we might decide to log and return False,
189
- # but raising an error for missing config is generally safer.
190
- # However, _determine_language (the caller) already raises ValueError
191
- # if language cannot be found, so this path might not be strictly necessary
192
- # if we assume PDD_PATH is validated earlier or by other get_extension/get_language calls.
193
- # For robustness here, let's keep a check but perhaps make it less severe if called internally.
194
- # For now, align with how get_extension might handle it.
195
- # console.print("[error]PDD_PATH environment variable is not set. Cannot validate language.", style="error")
196
- # return False # Or raise error
197
- # Given this is internal and other functions (get_extension) already depend on PDD_PATH,
198
- # we can assume if those ran, PDD_PATH is set. If not, they'd fail first.
199
- # So, we can simplify or rely on that pre-condition.
200
- # Let's assume PDD_PATH will be set if other language functions are working.
201
- # If it's critical, an explicit check and raise ValueError is better.
202
- # For now, let's proceed assuming PDD_PATH is available if this point is reached.
203
- pass # Assuming PDD_PATH is checked by get_extension/get_language if they are called
204
-
205
- # If PDD_PATH is not set, this will likely fail earlier if get_extension/get_language are used.
206
- # If we want this helper to be fully independent, it needs robust PDD_PATH handling.
207
- # Let's assume for now, PDD_PATH is available if this point is reached through normal flow.
208
-
209
- # Re-evaluate: PDD_PATH is critical for this function. Let's keep the check.
210
- if not pdd_path_str:
211
- # This helper might be called before get_extension in some logic paths
212
- # if _determine_language prioritizes suffix checking first.
213
- # So, it needs its own PDD_PATH check.
214
- # Raise ValueError to be consistent with get_extension's behavior.
215
- raise ValueError("PDD_PATH environment variable is not set. Cannot validate language.")
466
+ return language_name_lower in builtin_languages
216
467
 
217
468
  csv_file_path = Path(pdd_path_str) / 'data' / 'language_format.csv'
218
-
219
469
  if not csv_file_path.is_file():
220
- # Raise FileNotFoundError if CSV is missing, consistent with get_extension
221
- raise FileNotFoundError(f"The language format CSV file does not exist: {csv_file_path}")
222
-
223
- language_name_lower = language_name.lower()
224
-
470
+ return language_name_lower in builtin_languages
471
+
225
472
  try:
226
473
  with open(csv_file_path, mode='r', encoding='utf-8', newline='') as csvfile:
227
474
  reader = csv.DictReader(csvfile)
@@ -229,10 +476,10 @@ def _is_known_language(language_name: str) -> bool:
229
476
  if row.get('language', '').lower() == language_name_lower:
230
477
  return True
231
478
  except csv.Error as e:
232
- # Log and return False or raise a custom error
233
479
  console.print(f"[error]CSV Error reading {csv_file_path}: {e}", style="error")
234
- return False # Indicates language could not be confirmed due to CSV issue
235
- return False
480
+ return language_name_lower in builtin_languages
481
+
482
+ return language_name_lower in builtin_languages
236
483
 
237
484
 
238
485
  def _strip_language_suffix(path_like: os.PathLike[str]) -> str:
@@ -264,6 +511,24 @@ def _extract_basename(
264
511
  """
265
512
  Deduce the project basename according to the rules explained in *Step A*.
266
513
  """
514
+ # Handle 'fix' command specifically to create a unique basename per test file
515
+ if command == "fix":
516
+ prompt_path = _candidate_prompt_path(input_file_paths)
517
+ if not prompt_path:
518
+ raise ValueError("Could not determine prompt file for 'fix' command.")
519
+
520
+ prompt_basename = _strip_language_suffix(prompt_path)
521
+
522
+ unit_test_path = input_file_paths.get("unit_test_file")
523
+ if not unit_test_path:
524
+ # Fallback to just the prompt basename if no unit test file is provided
525
+ # This might happen in some edge cases, but 'fix' command structure requires it
526
+ return prompt_basename
527
+
528
+ # Use the stem of the unit test file to make the basename unique
529
+ test_basename = Path(unit_test_path).stem
530
+ return f"{prompt_basename}_{test_basename}"
531
+
267
532
  # Handle conflicts first due to its unique structure
268
533
  if command == "conflicts":
269
534
  key1 = "prompt1"
@@ -331,14 +596,48 @@ def _determine_language(
331
596
  ext = path_obj.suffix
332
597
  # Prioritize non-prompt code files
333
598
  if ext and ext != ".prompt":
334
- language = get_language(ext)
335
- if language:
336
- return language.lower()
599
+ try:
600
+ language = get_language(ext)
601
+ if language:
602
+ return language.lower()
603
+ except ValueError:
604
+ # Fallback: load language CSV file directly when PDD_PATH is not set
605
+ try:
606
+ import csv
607
+ import os
608
+ # Try to find the CSV file relative to this script
609
+ script_dir = os.path.dirname(os.path.abspath(__file__))
610
+ csv_path = os.path.join(script_dir, 'data', 'language_format.csv')
611
+ if os.path.exists(csv_path):
612
+ with open(csv_path, 'r') as csvfile:
613
+ reader = csv.DictReader(csvfile)
614
+ for row in reader:
615
+ if row['extension'].lower() == ext.lower():
616
+ return row['language'].lower()
617
+ except (FileNotFoundError, csv.Error):
618
+ pass
337
619
  # Handle files without extension like Makefile
338
620
  elif not ext and path_obj.is_file(): # Check it's actually a file
339
- language = get_language(path_obj.name) # Check name (e.g., 'Makefile')
340
- if language:
341
- return language.lower()
621
+ try:
622
+ language = get_language(path_obj.name) # Check name (e.g., 'Makefile')
623
+ if language:
624
+ return language.lower()
625
+ except ValueError:
626
+ # Fallback: load language CSV file directly for files without extension
627
+ try:
628
+ import csv
629
+ import os
630
+ script_dir = os.path.dirname(os.path.abspath(__file__))
631
+ csv_path = os.path.join(script_dir, 'data', 'language_format.csv')
632
+ if os.path.exists(csv_path):
633
+ with open(csv_path, 'r') as csvfile:
634
+ reader = csv.DictReader(csvfile)
635
+ for row in reader:
636
+ # Check if the filename matches (for files without extension)
637
+ if not row['extension'] and path_obj.name.lower() == row['language'].lower():
638
+ return row['language'].lower()
639
+ except (FileNotFoundError, csv.Error):
640
+ pass
342
641
 
343
642
  # 3 – parse from prompt filename suffix
344
643
  prompt_path = _candidate_prompt_path(input_file_paths)
@@ -354,7 +653,7 @@ def _determine_language(
354
653
 
355
654
  # 4 - Special handling for detect command - default to prompt for LLM prompts
356
655
  if command == "detect" and "change_file" in input_file_paths:
357
- return "prompt" # Default to prompt for detect command
656
+ return "prompt"
358
657
 
359
658
  # 5 - If no language determined, raise error
360
659
  raise ValueError("Could not determine language from input files or options.")
@@ -374,6 +673,8 @@ def construct_paths(
374
673
  command_options: Optional[Dict[str, Any]], # Allow None
375
674
  create_error_file: bool = True, # Added parameter to control error file creation
376
675
  context_override: Optional[str] = None, # Added parameter for context override
676
+ confirm_callback: Optional[Callable[[str, str], bool]] = None, # Callback for interactive confirmation
677
+ path_resolution_mode: Optional[str] = None, # "cwd" or "config_base" - if None, use command default
377
678
  ) -> Tuple[Dict[str, Any], Dict[str, str], Dict[str, str], str]:
378
679
  """
379
680
  High‑level orchestrator that loads inputs, determines basename/language,
@@ -390,6 +691,7 @@ def construct_paths(
390
691
 
391
692
  # ------------- Load .pddrc configuration -----------------
392
693
  pddrc_config = {}
694
+ pddrc_path: Optional[Path] = None
393
695
  context = None
394
696
  context_config = {}
395
697
  original_context_config = {} # Keep track of original context config for sync discovery
@@ -401,9 +703,30 @@ def construct_paths(
401
703
  pddrc_config = _load_pddrc_config(pddrc_path)
402
704
 
403
705
  # Detect appropriate context
404
- current_dir = Path.cwd()
405
- context = _detect_context(current_dir, pddrc_config, context_override)
406
-
706
+ # Priority: context_override > file-based detection > CWD-based detection
707
+ if context_override:
708
+ # Delegate validation to _detect_context to avoid duplicate validation logic
709
+ context = _detect_context(Path.cwd(), pddrc_config, context_override)
710
+ else:
711
+ # Try file-based detection when prompt file is provided
712
+ prompt_file_str = input_file_paths.get('prompt_file') if input_file_paths else None
713
+ if prompt_file_str and Path(prompt_file_str).exists():
714
+ detected_context, _ = detect_context_for_file(prompt_file_str)
715
+ if detected_context:
716
+ context = detected_context
717
+ else:
718
+ context = _detect_context(Path.cwd(), pddrc_config, None)
719
+ else:
720
+ basename_hint = command_options.get("basename")
721
+ if basename_hint:
722
+ detected_context = _detect_context_from_basename(basename_hint, pddrc_config)
723
+ if detected_context:
724
+ context = detected_context
725
+ else:
726
+ context = _detect_context(Path.cwd(), pddrc_config, None)
727
+ else:
728
+ context = _detect_context(Path.cwd(), pddrc_config, None)
729
+
407
730
  # Get context-specific configuration
408
731
  context_config = _get_context_config(pddrc_config, context)
409
732
  original_context_config = context_config.copy() # Store original before modifications
@@ -414,9 +737,15 @@ def construct_paths(
414
737
  # Apply configuration hierarchy
415
738
  env_vars = dict(os.environ)
416
739
  resolved_config = _resolve_config_hierarchy(command_options, context_config, env_vars)
417
-
740
+
741
+ # Issue #237: Track matched context for debugging
742
+ resolved_config['_matched_context'] = context or 'default'
743
+
418
744
  # Update command_options with resolved configuration for internal use
745
+ # Exclude internal metadata keys (prefixed with _) from command_options
419
746
  for key, value in resolved_config.items():
747
+ if key.startswith('_'):
748
+ continue # Skip internal metadata like _matched_context
420
749
  if key not in command_options or command_options[key] is None:
421
750
  command_options[key] = value
422
751
 
@@ -450,7 +779,10 @@ def construct_paths(
450
779
  language="python", # Dummy language
451
780
  file_extension=".py", # Dummy extension
452
781
  context_config=context_config,
782
+ config_base_dir=str(pddrc_path.parent) if pddrc_path else None,
783
+ path_resolution_mode="cwd", # Sync resolves paths relative to CWD
453
784
  )
785
+
454
786
  # Infer base directories from a sample output path
455
787
  gen_path = Path(output_paths_str.get("generate_output_path", "src"))
456
788
 
@@ -466,10 +798,13 @@ def construct_paths(
466
798
  else:
467
799
  # Fall back to context-aware logic
468
800
  # Use original_context_config to avoid checking augmented config with env vars
469
- if original_context_config and any(key.endswith('_output_path') for key in original_context_config):
470
- # For configured contexts, prompts are typically at the same level as output dirs
471
- # e.g., if code goes to "pdd/", prompts should be at "prompts/" (siblings)
472
- resolved_config["prompts_dir"] = "prompts"
801
+ if original_context_config and (
802
+ 'prompts_dir' in original_context_config or
803
+ any(key.endswith('_output_path') for key in original_context_config)
804
+ ):
805
+ # For configured contexts, use prompts_dir from config if provided,
806
+ # otherwise default to "prompts" at the same level as output dirs
807
+ resolved_config["prompts_dir"] = original_context_config.get("prompts_dir", "prompts")
473
808
  resolved_config["code_dir"] = str(gen_path.parent)
474
809
  else:
475
810
  # For default contexts, maintain relative relationship
@@ -478,7 +813,27 @@ def construct_paths(
478
813
  resolved_config["code_dir"] = str(gen_path.parent)
479
814
 
480
815
  resolved_config["tests_dir"] = str(Path(output_paths_str.get("test_output_path", "tests")).parent)
481
- resolved_config["examples_dir"] = str(Path(output_paths_str.get("example_output_path", "examples")).parent)
816
+
817
+ # Determine examples_dir for auto-deps scanning
818
+ # NOTE: outputs.example.path is for OUTPUT only (where to write examples),
819
+ # NOT for determining scan scope. Using it caused CSV row deletion issues.
820
+ # Check RAW context config for example_output_path, or default to "context".
821
+ # Do NOT use output_paths_str since generate_output_paths always returns absolute paths.
822
+ example_path_str = None
823
+ if original_context_config:
824
+ example_path_str = original_context_config.get("example_output_path")
825
+
826
+ # Final fallback to "context" (sensible default for this project)
827
+ if not example_path_str:
828
+ example_path_str = "context"
829
+
830
+ # example_path_str can be a directory (e.g., "context/") or a file path (e.g., "examples/foo.py")
831
+ # If it ends with / or has no file extension, treat as directory; otherwise use parent
832
+ example_path = Path(example_path_str)
833
+ if example_path_str.endswith('/') or '.' not in example_path.name:
834
+ resolved_config["examples_dir"] = example_path_str.rstrip('/')
835
+ else:
836
+ resolved_config["examples_dir"] = str(example_path.parent)
482
837
 
483
838
  except Exception as e:
484
839
  console.print(f"[error]Failed to determine initial paths for sync: {e}", style="error")
@@ -497,11 +852,15 @@ def construct_paths(
497
852
  for key, path_str in input_file_paths.items():
498
853
  try:
499
854
  path = Path(path_str).expanduser()
500
- # Resolve non-error files strictly first
855
+ # Resolve non-error files strictly first, but be more lenient for sync command
501
856
  if key != "error_file":
502
- # Let FileNotFoundError propagate naturally if path doesn't exist
503
- resolved_path = path.resolve(strict=True)
504
- input_paths[key] = resolved_path
857
+ # For sync command, be more tolerant of non-existent files since we're just determining paths
858
+ if command == "sync":
859
+ input_paths[key] = path.resolve()
860
+ else:
861
+ # Let FileNotFoundError propagate naturally if path doesn't exist
862
+ resolved_path = path.resolve(strict=True)
863
+ input_paths[key] = resolved_path
505
864
  else:
506
865
  # Resolve error file non-strictly, existence checked later
507
866
  input_paths[key] = path.resolve()
@@ -531,9 +890,14 @@ def construct_paths(
531
890
 
532
891
  # Check existence again, especially for error_file which might have been created
533
892
  if not path.exists():
534
- # This case should ideally be caught by resolve(strict=True) earlier for non-error files
535
- # Raise standard FileNotFoundError
536
- raise FileNotFoundError(f"{path}")
893
+ # For sync command, be more tolerant of non-existent files since we're just determining paths
894
+ if command == "sync":
895
+ # Skip reading content for non-existent files in sync mode
896
+ continue
897
+ else:
898
+ # This case should ideally be caught by resolve(strict=True) earlier for non-error files
899
+ # Raise standard FileNotFoundError
900
+ raise FileNotFoundError(f"{path}")
537
901
 
538
902
  if path.is_file(): # Read only if it's a file
539
903
  try:
@@ -552,7 +916,13 @@ def construct_paths(
552
916
 
553
917
  # ------------- Step 2: basename --------------------------
554
918
  try:
555
- basename = _extract_basename(command, input_paths)
919
+ # For sync, example, and test commands, prefer the basename from command_options if provided.
920
+ # This preserves subdirectory paths like 'core/cloud' which would otherwise
921
+ # be lost when extracting from the prompt file path.
922
+ if command in ("sync", "example", "test") and command_options.get("basename"):
923
+ basename = command_options["basename"]
924
+ else:
925
+ basename = _extract_basename(command, input_paths)
556
926
  except ValueError as exc:
557
927
  # Check if it's the specific error from the initial check (now done at start)
558
928
  # This try/except might not be needed if initial check is robust
@@ -598,7 +968,25 @@ def construct_paths(
598
968
  style="warning"
599
969
  )
600
970
 
601
- file_extension = get_extension(language) # Pass determined language
971
+
972
+ # Try to get extension from CSV; fallback to built-in mapping if PDD_PATH/CSV unavailable
973
+ try:
974
+ file_extension = get_extension(language) # Pass determined language
975
+ if not file_extension and (language or '').lower() != 'prompt':
976
+ raise ValueError('empty extension')
977
+ except Exception:
978
+ builtin_ext_map = {
979
+ 'python': '.py', 'javascript': '.js', 'typescript': '.ts', 'java': '.java',
980
+ 'cpp': '.cpp', 'c': '.c', 'go': '.go', 'ruby': '.rb', 'rust': '.rs',
981
+ 'kotlin': '.kt', 'swift': '.swift', 'csharp': '.cs', 'php': '.php',
982
+ 'scala': '.scala', 'r': '.r', 'lua': '.lua', 'perl': '.pl', 'bash': '.sh',
983
+ 'shell': '.sh', 'powershell': '.ps1', 'sql': '.sql', 'html': '.html', 'css': '.css',
984
+ 'prompt': '.prompt', 'makefile': '',
985
+ # Common data/config formats
986
+ 'json': '.json', 'jsonl': '.jsonl', 'yaml': '.yaml', 'yml': '.yml', 'toml': '.toml', 'ini': '.ini'
987
+ }
988
+ file_extension = builtin_ext_map.get(language.lower(), f".{language.lower()}" if language else '')
989
+
602
990
 
603
991
 
604
992
  # ------------- Step 3b: build output paths ---------------
@@ -608,10 +996,52 @@ def construct_paths(
608
996
  if k.startswith("output") and v is not None # Ensure value is not None
609
997
  }
610
998
 
999
+ # Determine input file directory for default output path generation
1000
+ # Only apply for commands that generate/update files based on specific input files
1001
+ # Commands like sync, generate, test, example have their own directory management
1002
+ commands_using_input_dir = {'fix', 'crash', 'verify', 'split', 'change', 'update'}
1003
+ input_file_dir: Optional[str] = None
1004
+ input_file_dirs: Dict[str, Optional[str]] = {}
1005
+ if input_paths and command in commands_using_input_dir:
1006
+ try:
1007
+ # For fix/crash/verify commands, use specific file directories for each output
1008
+ if command in {'fix', 'crash', 'verify'}:
1009
+ # Map output keys to their corresponding input file keys
1010
+ input_key_map = {
1011
+ 'fix': {'output_code': 'code_file', 'output_test': 'unit_test_file', 'output_results': 'code_file'},
1012
+ 'crash': {'output': 'code_file', 'output_program': 'program_file'},
1013
+ 'verify': {'output_code': 'code_file', 'output_program': 'verification_program', 'output_results': 'code_file'},
1014
+ }
1015
+
1016
+ for output_key, input_key in input_key_map.get(command, {}).items():
1017
+ if input_key in input_paths:
1018
+ input_file_dirs[output_key] = str(input_paths[input_key].parent)
1019
+
1020
+ # Set default input_file_dir to code_file directory as fallback
1021
+ if 'code_file' in input_paths:
1022
+ input_file_dir = str(input_paths['code_file'].parent)
1023
+ else:
1024
+ first_input_path = next(iter(input_paths.values()))
1025
+ input_file_dir = str(first_input_path.parent)
1026
+ else:
1027
+ # For other commands, use first input path
1028
+ first_input_path = next(iter(input_paths.values()))
1029
+ input_file_dir = str(first_input_path.parent)
1030
+ except (StopIteration, AttributeError):
1031
+ # If no input paths or path doesn't have parent, use None (falls back to CWD)
1032
+ pass
1033
+
611
1034
  try:
612
1035
  # generate_output_paths might return Dict[str, str] or Dict[str, Path]
613
1036
  # Let's assume it returns Dict[str, str] based on verification error,
614
1037
  # and convert them to Path objects here.
1038
+ # Determine path resolution mode:
1039
+ # - If explicitly provided, use it
1040
+ # - Otherwise: sync uses "cwd", other commands use "config_base"
1041
+ effective_path_resolution_mode = path_resolution_mode
1042
+ if effective_path_resolution_mode is None:
1043
+ effective_path_resolution_mode = "cwd" if command == "sync" else "config_base"
1044
+
615
1045
  output_paths_str: Dict[str, str] = generate_output_paths(
616
1046
  command=command,
617
1047
  output_locations=output_location_opts,
@@ -619,7 +1049,12 @@ def construct_paths(
619
1049
  language=language,
620
1050
  file_extension=file_extension,
621
1051
  context_config=context_config,
1052
+ input_file_dir=input_file_dir,
1053
+ input_file_dirs=input_file_dirs,
1054
+ config_base_dir=str(pddrc_path.parent) if pddrc_path else None,
1055
+ path_resolution_mode=effective_path_resolution_mode,
622
1056
  )
1057
+
623
1058
  # Convert to Path objects for internal use
624
1059
  output_paths_resolved: Dict[str, Path] = {k: Path(v) for k, v in output_paths_str.items()}
625
1060
 
@@ -628,36 +1063,59 @@ def construct_paths(
628
1063
  raise # Re-raise the ValueError
629
1064
 
630
1065
  # ------------- Step 4: overwrite confirmation ------------
631
- # Check if any output *file* exists (operate on Path objects)
1066
+ # Initialize existing_files before the conditional to avoid UnboundLocalError
632
1067
  existing_files: Dict[str, Path] = {}
633
- for k, p_obj in output_paths_resolved.items():
634
- # p_obj = Path(p_val) # Conversion now happens earlier
635
- if p_obj.is_file():
636
- existing_files[k] = p_obj # Store the Path object
1068
+
1069
+ if command in ["test", "bug"] and not force:
1070
+ # For test/bug commands without --force, create numbered files instead of overwriting
1071
+ for key, path in output_paths_resolved.items():
1072
+ if path.is_file():
1073
+ base, ext = os.path.splitext(path)
1074
+ i = 1
1075
+ new_path = Path(f"{base}_{i}{ext}")
1076
+ while new_path.exists():
1077
+ i += 1
1078
+ new_path = Path(f"{base}_{i}{ext}")
1079
+ output_paths_resolved[key] = new_path
1080
+ else:
1081
+ # Check if any output *file* exists (operate on Path objects)
1082
+ for k, p_obj in output_paths_resolved.items():
1083
+ if p_obj.is_file():
1084
+ existing_files[k] = p_obj # Store the Path object
637
1085
 
638
1086
  if existing_files and not force:
1087
+ paths_list = "\n".join(f" • {p.resolve()}" for p in existing_files.values())
639
1088
  if not quiet:
640
1089
  # Use the Path objects stored in existing_files for resolve()
641
1090
  # Print without Rich tags for easier testing
642
- paths_list = "\n".join(f" • {p.resolve()}" for p in existing_files.values())
643
1091
  console.print(
644
1092
  f"Warning: The following output files already exist and may be overwritten:\n{paths_list}",
645
1093
  style="warning"
646
1094
  )
647
- # Use click.confirm for user interaction
648
- try:
649
- if not click.confirm(
650
- click.style("Overwrite existing files?", fg="yellow"), default=True, show_default=True
651
- ):
652
- click.secho("Operation cancelled.", fg="red", err=True)
653
- sys.exit(1) # Exit if user chooses not to overwrite
654
- except Exception as e: # Catch potential errors during confirm (like EOFError in non-interactive)
655
- if 'EOF' in str(e) or 'end-of-file' in str(e).lower():
656
- # Non-interactive environment, default to not overwriting
657
- click.secho("Non-interactive environment detected. Use --force to overwrite existing files.", fg="yellow", err=True)
658
- else:
659
- click.secho(f"Confirmation failed: {e}. Aborting.", fg="red", err=True)
660
- sys.exit(1)
1095
+
1096
+ # Use confirm_callback if provided (for TUI environments), otherwise use click.confirm
1097
+ if confirm_callback is not None:
1098
+ # Use the provided callback for confirmation (e.g., from Textual TUI)
1099
+ confirm_message = f"The following files will be overwritten:\n{paths_list}\n\nOverwrite existing files?"
1100
+ if not confirm_callback(confirm_message, "Overwrite Confirmation"):
1101
+ raise click.Abort()
1102
+ else:
1103
+ # Use click.confirm for CLI interaction
1104
+ try:
1105
+ if not click.confirm(
1106
+ click.style("Overwrite existing files?", fg="yellow"), default=True, show_default=True
1107
+ ):
1108
+ click.secho("Operation cancelled.", fg="red", err=True)
1109
+ raise click.Abort()
1110
+ except click.Abort:
1111
+ raise # Let Abort propagate to be handled by PDDCLI.invoke()
1112
+ except Exception as e: # Catch potential errors during confirm (like EOFError in non-interactive)
1113
+ if 'EOF' in str(e) or 'end-of-file' in str(e).lower():
1114
+ # Non-interactive environment, default to not overwriting
1115
+ click.secho("Non-interactive environment detected. Use --force to overwrite existing files.", fg="yellow", err=True)
1116
+ else:
1117
+ click.secho(f"Confirmation failed: {e}. Aborting.", fg="red", err=True)
1118
+ raise click.Abort()
661
1119
 
662
1120
 
663
1121
  # ------------- Final reporting ---------------------------
@@ -685,7 +1143,27 @@ def construct_paths(
685
1143
  resolved_config["prompts_dir"] = str(next(iter(input_paths.values())).parent)
686
1144
  resolved_config["code_dir"] = str(gen_path.parent)
687
1145
  resolved_config["tests_dir"] = str(Path(resolved_config.get("test_output_path", "tests")).parent)
688
- resolved_config["examples_dir"] = str(Path(resolved_config.get("example_output_path", "examples")).parent)
689
-
690
1146
 
691
- return resolved_config, input_strings, output_file_paths_str_return, language
1147
+ # Determine examples_dir for auto-deps scanning
1148
+ # NOTE: outputs.example.path is for OUTPUT only (where to write examples),
1149
+ # NOT for determining scan scope. Using it caused CSV row deletion issues.
1150
+ # Check RAW context config for example_output_path, or default to "context".
1151
+ # Do NOT use resolved_config since generate_output_paths sets it to absolute paths.
1152
+ example_path_str = None
1153
+ if original_context_config:
1154
+ example_path_str = original_context_config.get("example_output_path")
1155
+
1156
+ # Final fallback to "context" (sensible default for this project)
1157
+ if not example_path_str:
1158
+ example_path_str = "context"
1159
+
1160
+ # example_path_str can be a directory (e.g., "context/") or a file path (e.g., "examples/foo.py")
1161
+ # If it ends with / or has no file extension, treat as directory; otherwise use parent
1162
+ example_path = Path(example_path_str)
1163
+ if example_path_str.endswith('/') or '.' not in example_path.name:
1164
+ resolved_config["examples_dir"] = example_path_str.rstrip('/')
1165
+ else:
1166
+ resolved_config["examples_dir"] = str(example_path.parent)
1167
+
1168
+
1169
+ return resolved_config, input_strings, output_file_paths_str_return, language