pdd-cli 0.0.42__py3-none-any.whl → 0.0.90__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.
- pdd/__init__.py +4 -4
- pdd/agentic_common.py +863 -0
- pdd/agentic_crash.py +534 -0
- pdd/agentic_fix.py +1179 -0
- pdd/agentic_langtest.py +162 -0
- pdd/agentic_update.py +370 -0
- pdd/agentic_verify.py +183 -0
- pdd/auto_deps_main.py +15 -5
- pdd/auto_include.py +63 -5
- pdd/bug_main.py +3 -2
- pdd/bug_to_unit_test.py +2 -0
- pdd/change_main.py +11 -4
- pdd/cli.py +22 -1181
- pdd/cmd_test_main.py +80 -19
- pdd/code_generator.py +58 -18
- pdd/code_generator_main.py +672 -25
- pdd/commands/__init__.py +42 -0
- pdd/commands/analysis.py +248 -0
- pdd/commands/fix.py +140 -0
- pdd/commands/generate.py +257 -0
- pdd/commands/maintenance.py +174 -0
- pdd/commands/misc.py +79 -0
- pdd/commands/modify.py +230 -0
- pdd/commands/report.py +144 -0
- pdd/commands/templates.py +215 -0
- pdd/commands/utility.py +110 -0
- pdd/config_resolution.py +58 -0
- pdd/conflicts_main.py +8 -3
- pdd/construct_paths.py +281 -81
- pdd/context_generator.py +10 -2
- pdd/context_generator_main.py +113 -11
- pdd/continue_generation.py +47 -7
- pdd/core/__init__.py +0 -0
- pdd/core/cli.py +503 -0
- pdd/core/dump.py +554 -0
- pdd/core/errors.py +63 -0
- pdd/core/utils.py +90 -0
- pdd/crash_main.py +44 -11
- pdd/data/language_format.csv +71 -62
- pdd/data/llm_model.csv +20 -18
- pdd/detect_change_main.py +5 -4
- pdd/fix_code_loop.py +331 -77
- pdd/fix_error_loop.py +209 -60
- pdd/fix_errors_from_unit_tests.py +4 -3
- pdd/fix_main.py +75 -18
- pdd/fix_verification_errors.py +12 -100
- pdd/fix_verification_errors_loop.py +319 -272
- pdd/fix_verification_main.py +57 -17
- pdd/generate_output_paths.py +93 -10
- pdd/generate_test.py +16 -5
- pdd/get_jwt_token.py +48 -9
- pdd/get_run_command.py +73 -0
- pdd/get_test_command.py +68 -0
- pdd/git_update.py +70 -19
- pdd/increase_tests.py +7 -0
- pdd/incremental_code_generator.py +2 -2
- pdd/insert_includes.py +11 -3
- pdd/llm_invoke.py +1278 -110
- pdd/load_prompt_template.py +36 -10
- pdd/pdd_completion.fish +25 -2
- pdd/pdd_completion.sh +30 -4
- pdd/pdd_completion.zsh +79 -4
- pdd/postprocess.py +10 -3
- pdd/preprocess.py +228 -15
- pdd/preprocess_main.py +8 -5
- pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
- pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
- pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
- pdd/prompts/agentic_update_LLM.prompt +1071 -0
- pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
- pdd/prompts/auto_include_LLM.prompt +98 -101
- pdd/prompts/change_LLM.prompt +1 -3
- pdd/prompts/detect_change_LLM.prompt +562 -3
- pdd/prompts/example_generator_LLM.prompt +22 -1
- pdd/prompts/extract_code_LLM.prompt +5 -1
- pdd/prompts/extract_program_code_fix_LLM.prompt +14 -2
- pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
- pdd/prompts/extract_promptline_LLM.prompt +17 -11
- pdd/prompts/find_verification_errors_LLM.prompt +6 -0
- pdd/prompts/fix_code_module_errors_LLM.prompt +16 -4
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +6 -41
- pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
- pdd/prompts/generate_test_LLM.prompt +21 -6
- pdd/prompts/increase_tests_LLM.prompt +1 -2
- pdd/prompts/insert_includes_LLM.prompt +1181 -6
- pdd/prompts/split_LLM.prompt +1 -62
- pdd/prompts/trace_LLM.prompt +25 -22
- pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
- pdd/prompts/update_prompt_LLM.prompt +22 -1
- pdd/prompts/xml_convertor_LLM.prompt +3246 -7
- pdd/pytest_output.py +188 -21
- pdd/python_env_detector.py +151 -0
- pdd/render_mermaid.py +236 -0
- pdd/setup_tool.py +648 -0
- pdd/simple_math.py +2 -0
- pdd/split_main.py +3 -2
- pdd/summarize_directory.py +56 -7
- pdd/sync_determine_operation.py +918 -186
- pdd/sync_main.py +82 -32
- pdd/sync_orchestration.py +1456 -453
- pdd/sync_tui.py +848 -0
- pdd/template_registry.py +264 -0
- pdd/templates/architecture/architecture_json.prompt +242 -0
- pdd/templates/generic/generate_prompt.prompt +174 -0
- pdd/trace.py +168 -12
- pdd/trace_main.py +4 -3
- pdd/track_cost.py +151 -61
- pdd/unfinished_prompt.py +49 -3
- pdd/update_main.py +549 -67
- pdd/update_model_costs.py +2 -2
- pdd/update_prompt.py +19 -4
- {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/METADATA +20 -7
- pdd_cli-0.0.90.dist-info/RECORD +153 -0
- {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/licenses/LICENSE +1 -1
- pdd_cli-0.0.42.dist-info/RECORD +0 -115
- {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/top_level.txt +0 -0
pdd/code_generator_main.py
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import re
|
|
2
3
|
import asyncio
|
|
3
4
|
import json
|
|
4
5
|
import pathlib
|
|
5
6
|
import shlex
|
|
6
7
|
import subprocess
|
|
7
8
|
import requests
|
|
9
|
+
import tempfile
|
|
10
|
+
import sys
|
|
8
11
|
from typing import Optional, Tuple, Dict, Any, List
|
|
9
12
|
|
|
10
13
|
import click
|
|
@@ -19,6 +22,7 @@ from .preprocess import preprocess as pdd_preprocess
|
|
|
19
22
|
from .code_generator import code_generator as local_code_generator_func
|
|
20
23
|
from .incremental_code_generator import incremental_code_generator as incremental_code_generator_func
|
|
21
24
|
from .get_jwt_token import get_jwt_token, AuthError, NetworkError, TokenError, UserCancelledError, RateLimitError
|
|
25
|
+
from .python_env_detector import detect_host_python_executable
|
|
22
26
|
|
|
23
27
|
# Environment variable names for Firebase/GitHub auth
|
|
24
28
|
FIREBASE_API_KEY_ENV_VAR = "NEXT_PUBLIC_FIREBASE_API_KEY"
|
|
@@ -31,6 +35,17 @@ CLOUD_REQUEST_TIMEOUT = 400 # seconds
|
|
|
31
35
|
|
|
32
36
|
console = Console()
|
|
33
37
|
|
|
38
|
+
# --- Helper Functions ---
|
|
39
|
+
def _parse_llm_bool(value: str) -> bool:
|
|
40
|
+
"""Parse LLM boolean value from string."""
|
|
41
|
+
if not value:
|
|
42
|
+
return True
|
|
43
|
+
llm_str = str(value).strip().lower()
|
|
44
|
+
if llm_str in {"0", "false", "no", "off"}:
|
|
45
|
+
return False
|
|
46
|
+
else:
|
|
47
|
+
return llm_str in {"1", "true", "yes", "on"}
|
|
48
|
+
|
|
34
49
|
# --- Git Helper Functions ---
|
|
35
50
|
def _run_git_command(command: List[str], cwd: Optional[str] = None) -> Tuple[int, str, str]:
|
|
36
51
|
"""Runs a git command and returns (return_code, stdout, stderr)."""
|
|
@@ -59,6 +74,102 @@ def is_git_repository(path: Optional[str] = None) -> bool:
|
|
|
59
74
|
return False
|
|
60
75
|
|
|
61
76
|
|
|
77
|
+
def _expand_vars(text: str, vars_map: Optional[Dict[str, str]]) -> str:
|
|
78
|
+
"""Replace $KEY and ${KEY} in text when KEY exists in vars_map. Leave others unchanged."""
|
|
79
|
+
if not text or not vars_map:
|
|
80
|
+
return text
|
|
81
|
+
|
|
82
|
+
def repl_braced(m: re.Match) -> str:
|
|
83
|
+
key = m.group(1)
|
|
84
|
+
return vars_map.get(key, m.group(0))
|
|
85
|
+
|
|
86
|
+
def repl_simple(m: re.Match) -> str:
|
|
87
|
+
key = m.group(1)
|
|
88
|
+
return vars_map.get(key, m.group(0))
|
|
89
|
+
|
|
90
|
+
# Replace ${KEY} first, then $KEY
|
|
91
|
+
text = re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", repl_braced, text)
|
|
92
|
+
text = re.sub(r"\$([A-Za-z_][A-Za-z0-9_]*)", repl_simple, text)
|
|
93
|
+
return text
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_front_matter(text: str) -> Tuple[Optional[Dict[str, Any]], str]:
|
|
97
|
+
"""Parse YAML front matter at the start of a prompt and return (meta, body)."""
|
|
98
|
+
try:
|
|
99
|
+
if not text.startswith("---\n"):
|
|
100
|
+
return None, text
|
|
101
|
+
end_idx = text.find("\n---", 4)
|
|
102
|
+
if end_idx == -1:
|
|
103
|
+
return None, text
|
|
104
|
+
fm_body = text[4:end_idx]
|
|
105
|
+
rest = text[end_idx + len("\n---"):]
|
|
106
|
+
if rest.startswith("\n"):
|
|
107
|
+
rest = rest[1:]
|
|
108
|
+
import yaml as _yaml
|
|
109
|
+
meta = _yaml.safe_load(fm_body) or {}
|
|
110
|
+
if not isinstance(meta, dict):
|
|
111
|
+
meta = {}
|
|
112
|
+
return meta, rest
|
|
113
|
+
except Exception:
|
|
114
|
+
return None, text
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_architecture_template(meta: Optional[Dict[str, Any]]) -> bool:
|
|
118
|
+
"""Detect the packaged architecture JSON template via its front matter name."""
|
|
119
|
+
return isinstance(meta, dict) and meta.get("name") == "architecture/architecture_json"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _repair_architecture_interface_types(payload: Any) -> Tuple[Any, bool]:
|
|
123
|
+
"""
|
|
124
|
+
Patch common LLM slip-ups for the architecture template where interface.type
|
|
125
|
+
occasionally returns an unsupported value like "object". Only normalizes the
|
|
126
|
+
interface.type field and leaves other schema issues untouched so validation
|
|
127
|
+
still fails for genuinely malformed outputs.
|
|
128
|
+
"""
|
|
129
|
+
allowed_types = {
|
|
130
|
+
"component",
|
|
131
|
+
"page",
|
|
132
|
+
"module",
|
|
133
|
+
"api",
|
|
134
|
+
"graphql",
|
|
135
|
+
"cli",
|
|
136
|
+
"job",
|
|
137
|
+
"message",
|
|
138
|
+
"config",
|
|
139
|
+
}
|
|
140
|
+
changed = False
|
|
141
|
+
if not isinstance(payload, list):
|
|
142
|
+
return payload, changed
|
|
143
|
+
|
|
144
|
+
for entry in payload:
|
|
145
|
+
if not isinstance(entry, dict):
|
|
146
|
+
continue
|
|
147
|
+
interface = entry.get("interface")
|
|
148
|
+
if not isinstance(interface, dict):
|
|
149
|
+
continue
|
|
150
|
+
raw_type = interface.get("type")
|
|
151
|
+
normalized = raw_type.lower() if isinstance(raw_type, str) else None
|
|
152
|
+
if normalized in allowed_types:
|
|
153
|
+
if normalized != raw_type:
|
|
154
|
+
interface["type"] = normalized
|
|
155
|
+
changed = True
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
inferred_type = None
|
|
159
|
+
for key in ("page", "component", "module", "api", "graphql", "cli", "job", "message", "config"):
|
|
160
|
+
if isinstance(interface.get(key), dict):
|
|
161
|
+
inferred_type = key
|
|
162
|
+
break
|
|
163
|
+
if inferred_type is None:
|
|
164
|
+
inferred_type = "module"
|
|
165
|
+
|
|
166
|
+
if raw_type != inferred_type:
|
|
167
|
+
interface["type"] = inferred_type
|
|
168
|
+
changed = True
|
|
169
|
+
|
|
170
|
+
return payload, changed
|
|
171
|
+
|
|
172
|
+
|
|
62
173
|
def get_git_content_at_ref(file_path: str, git_ref: str = "HEAD") -> Optional[str]:
|
|
63
174
|
"""Gets the content of the file as it was at the specified git_ref."""
|
|
64
175
|
abs_file_path = pathlib.Path(file_path).resolve()
|
|
@@ -124,6 +235,28 @@ def git_add_files(file_paths: List[str], verbose: bool = False) -> bool:
|
|
|
124
235
|
return False
|
|
125
236
|
# --- End Git Helper Functions ---
|
|
126
237
|
|
|
238
|
+
def _find_default_test_files(tests_dir: Optional[str], code_file_path: Optional[str]) -> List[str]:
|
|
239
|
+
"""Find default test files for a given code file in the tests directory."""
|
|
240
|
+
if not tests_dir or not code_file_path:
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
tests_path = pathlib.Path(tests_dir)
|
|
244
|
+
code_path = pathlib.Path(code_file_path)
|
|
245
|
+
|
|
246
|
+
if not tests_path.exists() or not tests_path.is_dir():
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
code_stem = code_path.stem
|
|
250
|
+
code_suffix = code_path.suffix
|
|
251
|
+
|
|
252
|
+
# Look for files starting with test_{code_stem}
|
|
253
|
+
# We look for test_{code_stem}*.{code_suffix}
|
|
254
|
+
# e.g., hello.py -> test_hello.py, test_hello_1.py
|
|
255
|
+
pattern = f"test_{code_stem}*{code_suffix}"
|
|
256
|
+
found_files = list(tests_path.glob(pattern))
|
|
257
|
+
|
|
258
|
+
return [str(p) for p in sorted(found_files)]
|
|
259
|
+
|
|
127
260
|
|
|
128
261
|
def code_generator_main(
|
|
129
262
|
ctx: click.Context,
|
|
@@ -131,6 +264,9 @@ def code_generator_main(
|
|
|
131
264
|
output: Optional[str],
|
|
132
265
|
original_prompt_file_path: Optional[str],
|
|
133
266
|
force_incremental_flag: bool,
|
|
267
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
268
|
+
unit_test_file: Optional[str] = None,
|
|
269
|
+
exclude_tests: bool = False,
|
|
134
270
|
) -> Tuple[str, bool, float, str]:
|
|
135
271
|
"""
|
|
136
272
|
CLI wrapper for generating code from prompts. Handles full and incremental generation,
|
|
@@ -157,20 +293,97 @@ def code_generator_main(
|
|
|
157
293
|
command_options: Dict[str, Any] = {"output": output}
|
|
158
294
|
|
|
159
295
|
try:
|
|
296
|
+
# Read prompt content once to determine LLM state and for construct_paths
|
|
297
|
+
with open(prompt_file, 'r', encoding='utf-8') as f:
|
|
298
|
+
raw_prompt_content = f.read()
|
|
299
|
+
|
|
300
|
+
# Phase-2 templates: parse front matter metadata
|
|
301
|
+
fm_meta, body = _parse_front_matter(raw_prompt_content)
|
|
302
|
+
if fm_meta:
|
|
303
|
+
prompt_content = body
|
|
304
|
+
else:
|
|
305
|
+
prompt_content = raw_prompt_content
|
|
306
|
+
|
|
307
|
+
# Determine LLM state early to avoid unnecessary overwrite prompts
|
|
308
|
+
llm_enabled: bool = True
|
|
309
|
+
env_llm_raw = None
|
|
310
|
+
try:
|
|
311
|
+
if env_vars and 'llm' in env_vars:
|
|
312
|
+
env_llm_raw = str(env_vars.get('llm'))
|
|
313
|
+
elif os.environ.get('llm') is not None:
|
|
314
|
+
env_llm_raw = os.environ.get('llm')
|
|
315
|
+
elif os.environ.get('LLM') is not None:
|
|
316
|
+
env_llm_raw = os.environ.get('LLM')
|
|
317
|
+
except Exception:
|
|
318
|
+
env_llm_raw = None
|
|
319
|
+
|
|
320
|
+
# Environment variables should override front matter
|
|
321
|
+
if env_llm_raw is not None:
|
|
322
|
+
llm_enabled = _parse_llm_bool(env_llm_raw)
|
|
323
|
+
elif fm_meta and isinstance(fm_meta, dict) and 'llm' in fm_meta:
|
|
324
|
+
llm_enabled = bool(fm_meta.get('llm', True))
|
|
325
|
+
# else: keep default True
|
|
326
|
+
|
|
327
|
+
# If LLM is disabled, we're only doing post-processing, so skip overwrite confirmation
|
|
328
|
+
effective_force = force_overwrite or not llm_enabled
|
|
329
|
+
|
|
160
330
|
resolved_config, input_strings, output_file_paths, language = construct_paths(
|
|
161
331
|
input_file_paths=input_file_paths_dict,
|
|
162
|
-
force=
|
|
332
|
+
force=effective_force,
|
|
163
333
|
quiet=quiet,
|
|
164
334
|
command="generate",
|
|
165
335
|
command_options=command_options,
|
|
336
|
+
context_override=ctx.obj.get('context'),
|
|
337
|
+
confirm_callback=cli_params.get('confirm_callback')
|
|
166
338
|
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
339
|
+
# Determine final output path: if user passed a directory, use resolved file path
|
|
340
|
+
resolved_output = output_file_paths.get("output")
|
|
341
|
+
if output is None:
|
|
342
|
+
output_path = resolved_output
|
|
343
|
+
else:
|
|
344
|
+
try:
|
|
345
|
+
is_dir_hint = output.endswith(os.path.sep) or output.endswith("/")
|
|
346
|
+
except Exception:
|
|
347
|
+
is_dir_hint = False
|
|
348
|
+
if is_dir_hint or os.path.isdir(output):
|
|
349
|
+
output_path = resolved_output
|
|
350
|
+
else:
|
|
351
|
+
output_path = output
|
|
352
|
+
|
|
353
|
+
# --- Unit Test Inclusion Logic ---
|
|
354
|
+
test_files_to_include: List[str] = []
|
|
355
|
+
if unit_test_file:
|
|
356
|
+
test_files_to_include.append(unit_test_file)
|
|
357
|
+
elif not exclude_tests:
|
|
358
|
+
# Try to find default test files
|
|
359
|
+
tests_dir = resolved_config.get("tests_dir")
|
|
360
|
+
found_tests = _find_default_test_files(tests_dir, output_path)
|
|
361
|
+
if found_tests:
|
|
362
|
+
if verbose:
|
|
363
|
+
console.print(f"[info]Found default test files: {', '.join(found_tests)}[/info]")
|
|
364
|
+
test_files_to_include.extend(found_tests)
|
|
365
|
+
|
|
366
|
+
if test_files_to_include:
|
|
367
|
+
prompt_content += "\n\n<unit_test_content>\n"
|
|
368
|
+
prompt_content += "The following is the unit test content that the generated code must pass:\n"
|
|
369
|
+
for tf in test_files_to_include:
|
|
370
|
+
try:
|
|
371
|
+
with open(tf, 'r', encoding='utf-8') as f:
|
|
372
|
+
content = f.read()
|
|
373
|
+
# If multiple files, label them? Or just concat?
|
|
374
|
+
# Using code block with file path comment is safer for context.
|
|
375
|
+
prompt_content += f"\nFile: {pathlib.Path(tf).name}\n```python\n{content}\n```\n"
|
|
376
|
+
except Exception as e:
|
|
377
|
+
console.print(f"[yellow]Warning: Could not read unit test file {tf}: {e}[/yellow]")
|
|
378
|
+
prompt_content += "</unit_test_content>\n"
|
|
379
|
+
# ---------------------------------
|
|
170
380
|
|
|
171
381
|
except FileNotFoundError as e:
|
|
172
382
|
console.print(f"[red]Error: Input file not found: {e.filename}[/red]")
|
|
173
383
|
return "", False, 0.0, "error"
|
|
384
|
+
except click.Abort:
|
|
385
|
+
# User cancelled - re-raise to stop the sync loop
|
|
386
|
+
raise
|
|
174
387
|
except Exception as e:
|
|
175
388
|
console.print(f"[red]Error during path construction: {e}[/red]")
|
|
176
389
|
return "", False, 0.0, "error"
|
|
@@ -179,6 +392,108 @@ def code_generator_main(
|
|
|
179
392
|
existing_code_content: Optional[str] = None
|
|
180
393
|
original_prompt_content_for_incremental: Optional[str] = None
|
|
181
394
|
|
|
395
|
+
# Merge -e vars with front-matter defaults; validate required
|
|
396
|
+
if env_vars is None:
|
|
397
|
+
env_vars = {}
|
|
398
|
+
if fm_meta and isinstance(fm_meta.get("variables"), dict):
|
|
399
|
+
for k, spec in (fm_meta["variables"].items()):
|
|
400
|
+
if isinstance(spec, dict):
|
|
401
|
+
if k not in env_vars and "default" in spec:
|
|
402
|
+
env_vars[k] = str(spec["default"])
|
|
403
|
+
# if scalar default allowed, ignore for now
|
|
404
|
+
missing = [k for k, spec in fm_meta["variables"].items() if isinstance(spec, dict) and spec.get("required") and k not in env_vars]
|
|
405
|
+
if missing:
|
|
406
|
+
console.print(f"[error]Missing required variables: {', '.join(missing)}")
|
|
407
|
+
return "", False, 0.0, "error"
|
|
408
|
+
|
|
409
|
+
# Execute optional discovery from front matter to populate env_vars without overriding explicit -e values
|
|
410
|
+
def _run_discovery(discover_cfg: Dict[str, Any]) -> Dict[str, str]:
|
|
411
|
+
results: Dict[str, str] = {}
|
|
412
|
+
try:
|
|
413
|
+
if not discover_cfg:
|
|
414
|
+
return results
|
|
415
|
+
enabled = discover_cfg.get("enabled", False)
|
|
416
|
+
if not enabled:
|
|
417
|
+
return results
|
|
418
|
+
root = discover_cfg.get("root", ".")
|
|
419
|
+
patterns = discover_cfg.get("patterns", []) or []
|
|
420
|
+
exclude = discover_cfg.get("exclude", []) or []
|
|
421
|
+
max_per = int(discover_cfg.get("max_per_pattern", 0) or 0)
|
|
422
|
+
max_total = int(discover_cfg.get("max_total", 0) or 0)
|
|
423
|
+
root_path = pathlib.Path(root).resolve()
|
|
424
|
+
seen: List[str] = []
|
|
425
|
+
def _match_one(patterns_list: List[str]) -> List[str]:
|
|
426
|
+
matches: List[str] = []
|
|
427
|
+
for pat in patterns_list:
|
|
428
|
+
globbed = list(root_path.rglob(pat))
|
|
429
|
+
for p in globbed:
|
|
430
|
+
if any(p.match(ex) for ex in exclude):
|
|
431
|
+
continue
|
|
432
|
+
sp = str(p.resolve())
|
|
433
|
+
if sp not in matches:
|
|
434
|
+
matches.append(sp)
|
|
435
|
+
if max_per and len(matches) >= max_per:
|
|
436
|
+
matches = matches[:max_per]
|
|
437
|
+
break
|
|
438
|
+
return matches
|
|
439
|
+
# If a mapping 'set' is provided, compute per-variable results
|
|
440
|
+
set_map = discover_cfg.get("set") or {}
|
|
441
|
+
if isinstance(set_map, dict) and set_map:
|
|
442
|
+
for var_name, spec in set_map.items():
|
|
443
|
+
if var_name in env_vars:
|
|
444
|
+
continue # don't override explicit -e
|
|
445
|
+
v_patterns = spec.get("patterns", []) if isinstance(spec, dict) else []
|
|
446
|
+
v_exclude = spec.get("exclude", []) if isinstance(spec, dict) else []
|
|
447
|
+
save_exclude = exclude
|
|
448
|
+
try:
|
|
449
|
+
if v_exclude:
|
|
450
|
+
exclude = v_exclude
|
|
451
|
+
matches = _match_one(v_patterns or patterns)
|
|
452
|
+
finally:
|
|
453
|
+
exclude = save_exclude
|
|
454
|
+
if matches:
|
|
455
|
+
results[var_name] = ",".join(matches)
|
|
456
|
+
seen.extend(matches)
|
|
457
|
+
# Fallback: populate SCAN_FILES and SCAN metadata
|
|
458
|
+
if not results:
|
|
459
|
+
files = _match_one(patterns)
|
|
460
|
+
if max_total and len(files) > max_total:
|
|
461
|
+
files = files[:max_total]
|
|
462
|
+
if files:
|
|
463
|
+
results["SCAN_FILES"] = ",".join(files)
|
|
464
|
+
# Always set root/patterns helpers
|
|
465
|
+
if root:
|
|
466
|
+
results.setdefault("SCAN_ROOT", str(root_path))
|
|
467
|
+
if patterns:
|
|
468
|
+
results.setdefault("SCAN_PATTERNS", ",".join(patterns))
|
|
469
|
+
except Exception as e:
|
|
470
|
+
if verbose and not quiet:
|
|
471
|
+
console.print(f"[yellow]Discovery skipped due to error: {e}[/yellow]")
|
|
472
|
+
return results
|
|
473
|
+
|
|
474
|
+
if fm_meta and isinstance(fm_meta.get("discover"), dict):
|
|
475
|
+
discovered = _run_discovery(fm_meta.get("discover") or {})
|
|
476
|
+
for k, v in discovered.items():
|
|
477
|
+
if k not in env_vars:
|
|
478
|
+
env_vars[k] = v
|
|
479
|
+
|
|
480
|
+
# Expand variables in output path if provided
|
|
481
|
+
if output_path:
|
|
482
|
+
output_path = _expand_vars(output_path, env_vars)
|
|
483
|
+
|
|
484
|
+
# Honor front-matter output when CLI did not pass --output
|
|
485
|
+
if output is None and fm_meta and isinstance(fm_meta.get("output"), str):
|
|
486
|
+
try:
|
|
487
|
+
meta_out = _expand_vars(fm_meta["output"], env_vars)
|
|
488
|
+
if meta_out:
|
|
489
|
+
output_path = str(pathlib.Path(meta_out).resolve())
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
# Honor front-matter language if provided (overrides detection for both local and cloud)
|
|
494
|
+
if fm_meta and isinstance(fm_meta.get("language"), str) and fm_meta.get("language"):
|
|
495
|
+
language = fm_meta.get("language")
|
|
496
|
+
|
|
182
497
|
if output_path and pathlib.Path(output_path).exists():
|
|
183
498
|
try:
|
|
184
499
|
existing_code_content = pathlib.Path(output_path).read_text(encoding="utf-8")
|
|
@@ -303,7 +618,96 @@ def code_generator_main(
|
|
|
303
618
|
can_attempt_incremental = False
|
|
304
619
|
|
|
305
620
|
try:
|
|
306
|
-
|
|
621
|
+
# Resolve post-process script from env/CLI override, then front matter, then sensible default per template
|
|
622
|
+
post_process_script: Optional[str] = None
|
|
623
|
+
prompt_body_for_script: str = prompt_content
|
|
624
|
+
|
|
625
|
+
if verbose:
|
|
626
|
+
console.print(f"[blue]LLM enabled:[/blue] {llm_enabled}")
|
|
627
|
+
try:
|
|
628
|
+
post_process_script = None
|
|
629
|
+
script_override = None
|
|
630
|
+
if env_vars:
|
|
631
|
+
script_override = env_vars.get('POST_PROCESS_PYTHON') or env_vars.get('post_process_python')
|
|
632
|
+
if not script_override:
|
|
633
|
+
script_override = os.environ.get('POST_PROCESS_PYTHON') or os.environ.get('post_process_python')
|
|
634
|
+
if script_override and str(script_override).strip():
|
|
635
|
+
expanded = _expand_vars(str(script_override), env_vars)
|
|
636
|
+
pkg_dir = pathlib.Path(__file__).parent.resolve()
|
|
637
|
+
repo_root = pathlib.Path.cwd().resolve()
|
|
638
|
+
repo_pdd_dir = (repo_root / 'pdd').resolve()
|
|
639
|
+
candidate = pathlib.Path(expanded)
|
|
640
|
+
if not candidate.is_absolute():
|
|
641
|
+
# 1) As provided, relative to CWD
|
|
642
|
+
as_is = (repo_root / candidate)
|
|
643
|
+
# 2) Under repo pdd/
|
|
644
|
+
under_repo_pdd = (repo_pdd_dir / candidate.name) if not as_is.exists() else as_is
|
|
645
|
+
# 3) Under installed package dir
|
|
646
|
+
under_pkg = (pkg_dir / candidate.name) if not as_is.exists() and not under_repo_pdd.exists() else as_is
|
|
647
|
+
if as_is.exists():
|
|
648
|
+
candidate = as_is
|
|
649
|
+
elif under_repo_pdd.exists():
|
|
650
|
+
candidate = under_repo_pdd
|
|
651
|
+
elif under_pkg.exists():
|
|
652
|
+
candidate = under_pkg
|
|
653
|
+
else:
|
|
654
|
+
candidate = as_is # will fail later with not found
|
|
655
|
+
post_process_script = str(candidate.resolve())
|
|
656
|
+
elif fm_meta and isinstance(fm_meta, dict):
|
|
657
|
+
raw_script = fm_meta.get('post_process_python')
|
|
658
|
+
if isinstance(raw_script, str) and raw_script.strip():
|
|
659
|
+
# Expand variables like $VAR and ${VAR}
|
|
660
|
+
expanded = _expand_vars(raw_script, env_vars)
|
|
661
|
+
pkg_dir = pathlib.Path(__file__).parent.resolve()
|
|
662
|
+
repo_root = pathlib.Path.cwd().resolve()
|
|
663
|
+
repo_pdd_dir = (repo_root / 'pdd').resolve()
|
|
664
|
+
candidate = pathlib.Path(expanded)
|
|
665
|
+
if not candidate.is_absolute():
|
|
666
|
+
as_is = (repo_root / candidate)
|
|
667
|
+
under_repo_pdd = (repo_pdd_dir / candidate.name) if not as_is.exists() else as_is
|
|
668
|
+
under_pkg = (pkg_dir / candidate.name) if not as_is.exists() and not under_repo_pdd.exists() else as_is
|
|
669
|
+
if as_is.exists():
|
|
670
|
+
candidate = as_is
|
|
671
|
+
elif under_repo_pdd.exists():
|
|
672
|
+
candidate = under_repo_pdd
|
|
673
|
+
elif under_pkg.exists():
|
|
674
|
+
candidate = under_pkg
|
|
675
|
+
else:
|
|
676
|
+
candidate = as_is
|
|
677
|
+
post_process_script = str(candidate.resolve())
|
|
678
|
+
# Fallback default: for architecture template, use built-in render_mermaid.py
|
|
679
|
+
if not post_process_script:
|
|
680
|
+
try:
|
|
681
|
+
prompt_str = str(prompt_file)
|
|
682
|
+
looks_like_arch_template = (
|
|
683
|
+
(isinstance(prompt_file, str) and (
|
|
684
|
+
prompt_str.endswith("architecture/architecture_json.prompt") or
|
|
685
|
+
prompt_str.endswith("architecture/architecture_json") or
|
|
686
|
+
"architecture_json.prompt" in prompt_str or
|
|
687
|
+
"architecture/architecture_json" in prompt_str
|
|
688
|
+
))
|
|
689
|
+
)
|
|
690
|
+
looks_like_arch_output = (
|
|
691
|
+
bool(output_path) and pathlib.Path(str(output_path)).name == 'architecture.json'
|
|
692
|
+
)
|
|
693
|
+
if looks_like_arch_template or looks_like_arch_output:
|
|
694
|
+
pkg_dir = pathlib.Path(__file__).parent
|
|
695
|
+
repo_pdd_dir = pathlib.Path.cwd() / 'pdd'
|
|
696
|
+
if (pkg_dir / 'render_mermaid.py').exists():
|
|
697
|
+
post_process_script = str((pkg_dir / 'render_mermaid.py').resolve())
|
|
698
|
+
elif (repo_pdd_dir / 'render_mermaid.py').exists():
|
|
699
|
+
post_process_script = str((repo_pdd_dir / 'render_mermaid.py').resolve())
|
|
700
|
+
except Exception:
|
|
701
|
+
post_process_script = None
|
|
702
|
+
if verbose:
|
|
703
|
+
console.print(f"[blue]Post-process script resolved to:[/blue] {post_process_script if post_process_script else 'None'}")
|
|
704
|
+
except Exception:
|
|
705
|
+
post_process_script = None
|
|
706
|
+
# If LLM is disabled but no post-process script is provided, surface a helpful error
|
|
707
|
+
if not llm_enabled and not post_process_script:
|
|
708
|
+
console.print("[red]Error: llm: false requires 'post_process_python' to be specified in front matter.[/red]")
|
|
709
|
+
return "", was_incremental_operation, total_cost, "error"
|
|
710
|
+
if llm_enabled and can_attempt_incremental and existing_code_content is not None and original_prompt_content_for_incremental is not None:
|
|
307
711
|
if verbose:
|
|
308
712
|
console.print(Panel("Attempting incremental code generation...", title="[blue]Mode[/blue]", expand=False))
|
|
309
713
|
|
|
@@ -326,9 +730,18 @@ def code_generator_main(
|
|
|
326
730
|
if files_to_stage_for_rollback:
|
|
327
731
|
git_add_files(files_to_stage_for_rollback, verbose=verbose)
|
|
328
732
|
|
|
733
|
+
# Preprocess both prompts: expand includes, substitute vars, then double
|
|
734
|
+
orig_proc = pdd_preprocess(original_prompt_content_for_incremental, recursive=True, double_curly_brackets=False)
|
|
735
|
+
orig_proc = _expand_vars(orig_proc, env_vars)
|
|
736
|
+
orig_proc = pdd_preprocess(orig_proc, recursive=False, double_curly_brackets=True)
|
|
737
|
+
|
|
738
|
+
new_proc = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False)
|
|
739
|
+
new_proc = _expand_vars(new_proc, env_vars)
|
|
740
|
+
new_proc = pdd_preprocess(new_proc, recursive=False, double_curly_brackets=True)
|
|
741
|
+
|
|
329
742
|
generated_code_content, was_incremental_operation, total_cost, model_name = incremental_code_generator_func(
|
|
330
|
-
original_prompt=
|
|
331
|
-
new_prompt=
|
|
743
|
+
original_prompt=orig_proc,
|
|
744
|
+
new_prompt=new_proc,
|
|
332
745
|
existing_code=existing_code_content,
|
|
333
746
|
language=language,
|
|
334
747
|
strength=strength,
|
|
@@ -336,7 +749,7 @@ def code_generator_main(
|
|
|
336
749
|
time=time_budget,
|
|
337
750
|
force_incremental=force_incremental_flag,
|
|
338
751
|
verbose=verbose,
|
|
339
|
-
preprocess_prompt=
|
|
752
|
+
preprocess_prompt=False
|
|
340
753
|
)
|
|
341
754
|
|
|
342
755
|
if not was_incremental_operation:
|
|
@@ -345,7 +758,7 @@ def code_generator_main(
|
|
|
345
758
|
elif verbose:
|
|
346
759
|
console.print(Panel(f"Incremental update successful. Model: {model_name}, Cost: ${total_cost:.6f}", title="[green]Incremental Success[/green]", expand=False))
|
|
347
760
|
|
|
348
|
-
if not was_incremental_operation: # Full generation path
|
|
761
|
+
if llm_enabled and not was_incremental_operation: # Full generation path
|
|
349
762
|
if verbose:
|
|
350
763
|
console.print(Panel("Performing full code generation...", title="[blue]Mode[/blue]", expand=False))
|
|
351
764
|
|
|
@@ -353,8 +766,10 @@ def code_generator_main(
|
|
|
353
766
|
|
|
354
767
|
if not current_execution_is_local:
|
|
355
768
|
if verbose: console.print("Attempting cloud code generation...")
|
|
356
|
-
|
|
357
|
-
processed_prompt_for_cloud = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=
|
|
769
|
+
# Expand includes, substitute vars, then double
|
|
770
|
+
processed_prompt_for_cloud = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[])
|
|
771
|
+
processed_prompt_for_cloud = _expand_vars(processed_prompt_for_cloud, env_vars)
|
|
772
|
+
processed_prompt_for_cloud = pdd_preprocess(processed_prompt_for_cloud, recursive=False, double_curly_brackets=True, exclude_keys=[])
|
|
358
773
|
if verbose: console.print(Panel(Text(processed_prompt_for_cloud, overflow="fold"), title="[cyan]Preprocessed Prompt for Cloud[/cyan]", expand=False))
|
|
359
774
|
|
|
360
775
|
jwt_token: Optional[str] = None
|
|
@@ -389,7 +804,7 @@ def code_generator_main(
|
|
|
389
804
|
total_cost = float(response_data.get("totalCost", 0.0))
|
|
390
805
|
model_name = response_data.get("modelName", "cloud_model")
|
|
391
806
|
|
|
392
|
-
if generated_code_content
|
|
807
|
+
if not generated_code_content:
|
|
393
808
|
console.print("[yellow]Cloud execution returned no code. Falling back to local.[/yellow]")
|
|
394
809
|
current_execution_is_local = True
|
|
395
810
|
elif verbose:
|
|
@@ -410,36 +825,268 @@ def code_generator_main(
|
|
|
410
825
|
|
|
411
826
|
if current_execution_is_local:
|
|
412
827
|
if verbose: console.print("Executing code generator locally...")
|
|
828
|
+
# Expand includes, substitute vars, then double; pass to local generator with preprocess_prompt=False
|
|
829
|
+
local_prompt = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[])
|
|
830
|
+
local_prompt = _expand_vars(local_prompt, env_vars)
|
|
831
|
+
local_prompt = pdd_preprocess(local_prompt, recursive=False, double_curly_brackets=True, exclude_keys=[])
|
|
832
|
+
# Language already resolved (front matter overrides detection if present)
|
|
833
|
+
gen_language = language
|
|
834
|
+
|
|
835
|
+
# Extract output schema from front matter if available
|
|
836
|
+
output_schema = fm_meta.get("output_schema") if fm_meta else None
|
|
837
|
+
|
|
413
838
|
generated_code_content, total_cost, model_name = local_code_generator_func(
|
|
414
|
-
prompt=
|
|
415
|
-
language=
|
|
839
|
+
prompt=local_prompt,
|
|
840
|
+
language=gen_language,
|
|
416
841
|
strength=strength,
|
|
417
842
|
temperature=temperature,
|
|
418
843
|
time=time_budget,
|
|
419
844
|
verbose=verbose,
|
|
420
|
-
preprocess_prompt=
|
|
845
|
+
preprocess_prompt=False,
|
|
846
|
+
output_schema=output_schema,
|
|
421
847
|
)
|
|
422
848
|
was_incremental_operation = False
|
|
423
849
|
if verbose:
|
|
424
850
|
console.print(Panel(f"Full generation successful. Model: {model_name}, Cost: ${total_cost:.6f}", title="[green]Local Success[/green]", expand=False))
|
|
425
|
-
|
|
851
|
+
|
|
852
|
+
# Optional post-process Python hook (runs after LLM when enabled, or standalone when LLM is disabled)
|
|
853
|
+
if post_process_script:
|
|
854
|
+
try:
|
|
855
|
+
python_executable = detect_host_python_executable()
|
|
856
|
+
# Choose stdin for the script: LLM output if available and enabled, else prompt body
|
|
857
|
+
stdin_payload = generated_code_content if (llm_enabled and generated_code_content is not None) else prompt_body_for_script
|
|
858
|
+
env = os.environ.copy()
|
|
859
|
+
env['PDD_LANGUAGE'] = str(language or '')
|
|
860
|
+
env['PDD_OUTPUT_PATH'] = str(output_path or '')
|
|
861
|
+
env['PDD_PROMPT_FILE'] = str(pathlib.Path(prompt_file).resolve())
|
|
862
|
+
env['PDD_LLM'] = '1' if llm_enabled else '0'
|
|
863
|
+
try:
|
|
864
|
+
env['PDD_ENV_VARS'] = json.dumps(env_vars or {})
|
|
865
|
+
except Exception:
|
|
866
|
+
env['PDD_ENV_VARS'] = '{}'
|
|
867
|
+
# If front matter provides args, run in argv mode with a temp input file
|
|
868
|
+
fm_args = None
|
|
869
|
+
try:
|
|
870
|
+
# Env/CLI override for args (comma-separated or JSON list)
|
|
871
|
+
raw_args_env = None
|
|
872
|
+
if env_vars:
|
|
873
|
+
raw_args_env = env_vars.get('POST_PROCESS_ARGS') or env_vars.get('post_process_args')
|
|
874
|
+
if not raw_args_env:
|
|
875
|
+
raw_args_env = os.environ.get('POST_PROCESS_ARGS') or os.environ.get('post_process_args')
|
|
876
|
+
if raw_args_env:
|
|
877
|
+
s = str(raw_args_env).strip()
|
|
878
|
+
parsed_list = None
|
|
879
|
+
if s.startswith('[') and s.endswith(']'):
|
|
880
|
+
try:
|
|
881
|
+
parsed = json.loads(s)
|
|
882
|
+
if isinstance(parsed, list):
|
|
883
|
+
parsed_list = [str(a) for a in parsed]
|
|
884
|
+
except Exception:
|
|
885
|
+
parsed_list = None
|
|
886
|
+
if parsed_list is None:
|
|
887
|
+
if ',' in s:
|
|
888
|
+
parsed_list = [part.strip() for part in s.split(',') if part.strip()]
|
|
889
|
+
else:
|
|
890
|
+
parsed_list = [part for part in s.split() if part]
|
|
891
|
+
fm_args = parsed_list or None
|
|
892
|
+
if fm_args is None:
|
|
893
|
+
raw_args = fm_meta.get('post_process_args') if isinstance(fm_meta, dict) else None
|
|
894
|
+
if isinstance(raw_args, list):
|
|
895
|
+
fm_args = [str(a) for a in raw_args]
|
|
896
|
+
except Exception:
|
|
897
|
+
fm_args = None
|
|
898
|
+
proc = None
|
|
899
|
+
temp_input_path = None
|
|
900
|
+
try:
|
|
901
|
+
if fm_args is None:
|
|
902
|
+
# Provide sensible default args for architecture template with render_mermaid.py
|
|
903
|
+
try:
|
|
904
|
+
if post_process_script and pathlib.Path(post_process_script).name == 'render_mermaid.py':
|
|
905
|
+
if isinstance(prompt_file, str) and prompt_file.endswith('architecture/architecture_json.prompt'):
|
|
906
|
+
fm_args = ["{INPUT_FILE}", "{APP_NAME}", "{OUTPUT_HTML}"]
|
|
907
|
+
except Exception:
|
|
908
|
+
pass
|
|
909
|
+
if fm_args:
|
|
910
|
+
# When LLM is disabled, use the existing output file instead of creating a temp file
|
|
911
|
+
if not llm_enabled and output_path and pathlib.Path(output_path).exists():
|
|
912
|
+
temp_input_path = str(pathlib.Path(output_path).resolve())
|
|
913
|
+
env['PDD_POSTPROCESS_INPUT_FILE'] = temp_input_path
|
|
914
|
+
else:
|
|
915
|
+
# Write payload to a temp file for scripts expecting a file path input
|
|
916
|
+
suffix = '.json' if (isinstance(language, str) and str(language).lower().strip() == 'json') or (output_path and str(output_path).lower().endswith('.json')) else '.txt'
|
|
917
|
+
if output_path and llm_enabled:
|
|
918
|
+
temp_input_path = str(pathlib.Path(output_path).resolve())
|
|
919
|
+
pathlib.Path(temp_input_path).parent.mkdir(parents=True, exist_ok=True)
|
|
920
|
+
with open(temp_input_path, 'w', encoding='utf-8') as f:
|
|
921
|
+
f.write(stdin_payload or '')
|
|
922
|
+
else:
|
|
923
|
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=suffix, encoding='utf-8') as tf:
|
|
924
|
+
tf.write(stdin_payload or '')
|
|
925
|
+
temp_input_path = tf.name
|
|
926
|
+
env['PDD_POSTPROCESS_INPUT_FILE'] = temp_input_path
|
|
927
|
+
# Compute placeholder values
|
|
928
|
+
app_name_val = (env_vars or {}).get('APP_NAME') if env_vars else None
|
|
929
|
+
if not app_name_val:
|
|
930
|
+
app_name_val = 'System Architecture'
|
|
931
|
+
output_html_default = None
|
|
932
|
+
if output_path and str(output_path).lower().endswith('.json'):
|
|
933
|
+
output_html_default = str(pathlib.Path(output_path).with_name(f"{pathlib.Path(output_path).stem}_diagram.html").resolve())
|
|
934
|
+
placeholder_map = {
|
|
935
|
+
'INPUT_FILE': temp_input_path or '',
|
|
936
|
+
'OUTPUT': str(output_path or ''),
|
|
937
|
+
'PROMPT_FILE': str(pathlib.Path(prompt_file).resolve()),
|
|
938
|
+
'APP_NAME': str(app_name_val),
|
|
939
|
+
'OUTPUT_HTML': str(output_html_default or ''),
|
|
940
|
+
}
|
|
941
|
+
def _subst_arg(arg: str) -> str:
|
|
942
|
+
# First expand $VARS using existing helper, then {TOKENS}
|
|
943
|
+
expanded = _expand_vars(arg, env_vars)
|
|
944
|
+
for key, val in placeholder_map.items():
|
|
945
|
+
expanded = expanded.replace('{' + key + '}', val)
|
|
946
|
+
return expanded
|
|
947
|
+
args_list = [_subst_arg(a) for a in fm_args]
|
|
948
|
+
if verbose:
|
|
949
|
+
console.print(Panel(f"Post-process hook (argv)\nScript: {post_process_script}\nArgs: {args_list}", title="[blue]Post-process[/blue]", expand=False))
|
|
950
|
+
proc = subprocess.run(
|
|
951
|
+
[python_executable, post_process_script] + args_list,
|
|
952
|
+
text=True,
|
|
953
|
+
capture_output=True,
|
|
954
|
+
timeout=300,
|
|
955
|
+
cwd=str(pathlib.Path(post_process_script).parent),
|
|
956
|
+
env=env
|
|
957
|
+
)
|
|
958
|
+
else:
|
|
959
|
+
# Run the script with stdin payload, capture stdout as final content
|
|
960
|
+
if verbose:
|
|
961
|
+
console.print(Panel(f"Post-process hook (stdin)\nScript: {post_process_script}", title="[blue]Post-process[/blue]", expand=False))
|
|
962
|
+
proc = subprocess.run(
|
|
963
|
+
[python_executable, post_process_script],
|
|
964
|
+
input=stdin_payload or '',
|
|
965
|
+
text=True,
|
|
966
|
+
capture_output=True,
|
|
967
|
+
timeout=300,
|
|
968
|
+
cwd=str(pathlib.Path(post_process_script).parent),
|
|
969
|
+
env=env
|
|
970
|
+
)
|
|
971
|
+
finally:
|
|
972
|
+
if temp_input_path:
|
|
973
|
+
try:
|
|
974
|
+
# Only delete temp files, not the actual output file when llm=false
|
|
975
|
+
if llm_enabled or not (output_path and pathlib.Path(output_path).exists() and temp_input_path == str(pathlib.Path(output_path).resolve())):
|
|
976
|
+
os.unlink(temp_input_path)
|
|
977
|
+
except Exception:
|
|
978
|
+
pass
|
|
979
|
+
if proc and proc.returncode == 0:
|
|
980
|
+
if verbose:
|
|
981
|
+
console.print(Panel(f"Post-process success (rc=0)\nstdout: {proc.stdout[:150]}\nstderr: {proc.stderr[:150]}", title="[green]Post-process[/green]", expand=False))
|
|
982
|
+
# Do not modify generated_code_content to preserve architecture.json
|
|
983
|
+
else:
|
|
984
|
+
rc = getattr(proc, 'returncode', 'N/A')
|
|
985
|
+
err = getattr(proc, 'stderr', '')
|
|
986
|
+
console.print(f"[yellow]Post-process failed (rc={rc}). Stderr:\n{err[:500]}[/yellow]")
|
|
987
|
+
except FileNotFoundError:
|
|
988
|
+
console.print(f"[yellow]Post-process script not found: {post_process_script}. Skipping.[/yellow]")
|
|
989
|
+
except subprocess.TimeoutExpired:
|
|
990
|
+
console.print("[yellow]Post-process script timed out. Skipping.[/yellow]")
|
|
991
|
+
except Exception as e:
|
|
992
|
+
console.print(f"[yellow]Post-process script error: {e}. Skipping.[/yellow]")
|
|
426
993
|
if generated_code_content is not None:
|
|
994
|
+
# Optional output_schema JSON validation before writing (only when LLM ran)
|
|
995
|
+
if llm_enabled:
|
|
996
|
+
try:
|
|
997
|
+
if fm_meta and isinstance(fm_meta.get("output_schema"), dict):
|
|
998
|
+
is_json_output = False
|
|
999
|
+
if isinstance(language, str) and str(language).lower().strip() == "json":
|
|
1000
|
+
is_json_output = True
|
|
1001
|
+
elif output_path and str(output_path).lower().endswith(".json"):
|
|
1002
|
+
is_json_output = True
|
|
1003
|
+
if is_json_output:
|
|
1004
|
+
# Check if the generated content is an error message from llm_invoke
|
|
1005
|
+
if generated_code_content.strip().startswith("ERROR:"):
|
|
1006
|
+
raise click.UsageError(f"LLM generation failed: {generated_code_content}")
|
|
1007
|
+
|
|
1008
|
+
parsed = json.loads(generated_code_content)
|
|
1009
|
+
if _is_architecture_template(fm_meta):
|
|
1010
|
+
parsed, repaired = _repair_architecture_interface_types(parsed)
|
|
1011
|
+
if repaired:
|
|
1012
|
+
generated_code_content = json.dumps(parsed, indent=2)
|
|
1013
|
+
try:
|
|
1014
|
+
import jsonschema
|
|
1015
|
+
jsonschema.validate(instance=parsed, schema=fm_meta.get("output_schema"))
|
|
1016
|
+
except ModuleNotFoundError:
|
|
1017
|
+
if verbose and not quiet:
|
|
1018
|
+
console.print("[yellow]jsonschema not installed; skipping schema validation.[/yellow]")
|
|
1019
|
+
except Exception as ve:
|
|
1020
|
+
raise click.UsageError(f"Generated JSON does not match output_schema: {ve}")
|
|
1021
|
+
except json.JSONDecodeError as jde:
|
|
1022
|
+
raise click.UsageError(f"Generated output is not valid JSON: {jde}")
|
|
1023
|
+
|
|
427
1024
|
if output_path:
|
|
428
1025
|
p_output = pathlib.Path(output_path)
|
|
429
1026
|
p_output.parent.mkdir(parents=True, exist_ok=True)
|
|
430
1027
|
p_output.write_text(generated_code_content, encoding="utf-8")
|
|
431
1028
|
if verbose or not quiet:
|
|
432
1029
|
console.print(f"Generated code saved to: [green]{p_output.resolve()}[/green]")
|
|
433
|
-
|
|
434
|
-
|
|
1030
|
+
# Safety net: ensure architecture HTML is generated post-write if applicable
|
|
1031
|
+
try:
|
|
1032
|
+
# Prefer resolved script if available; else default for architecture outputs
|
|
1033
|
+
script_path2 = post_process_script
|
|
1034
|
+
if not script_path2:
|
|
1035
|
+
looks_like_arch_output2 = pathlib.Path(str(p_output)).name == 'architecture.json'
|
|
1036
|
+
if looks_like_arch_output2:
|
|
1037
|
+
pkg_dir2 = pathlib.Path(__file__).parent
|
|
1038
|
+
repo_pdd_dir2 = pathlib.Path.cwd() / 'pdd'
|
|
1039
|
+
if (pkg_dir2 / 'render_mermaid.py').exists():
|
|
1040
|
+
script_path2 = str((pkg_dir2 / 'render_mermaid.py').resolve())
|
|
1041
|
+
elif (repo_pdd_dir2 / 'render_mermaid.py').exists():
|
|
1042
|
+
script_path2 = str((repo_pdd_dir2 / 'render_mermaid.py').resolve())
|
|
1043
|
+
if script_path2 and pathlib.Path(script_path2).exists():
|
|
1044
|
+
app_name2 = os.environ.get('APP_NAME') or (env_vars or {}).get('APP_NAME') or 'System Architecture'
|
|
1045
|
+
out_html2 = os.environ.get('POST_PROCESS_OUTPUT') or str(p_output.with_name(f"{p_output.stem}_diagram.html").resolve())
|
|
1046
|
+
html_missing = not pathlib.Path(out_html2).exists()
|
|
1047
|
+
always_run_for_arch = pathlib.Path(str(p_output)).name == 'architecture.json'
|
|
1048
|
+
if always_run_for_arch or html_missing:
|
|
1049
|
+
try:
|
|
1050
|
+
py_exec2 = detect_host_python_executable()
|
|
1051
|
+
except Exception:
|
|
1052
|
+
py_exec2 = sys.executable
|
|
1053
|
+
if verbose:
|
|
1054
|
+
console.print(Panel(f"Safety net post-process\nScript: {script_path2}\nArgs: {[str(p_output.resolve()), app_name2, out_html2]}", title="[blue]Post-process[/blue]", expand=False))
|
|
1055
|
+
sp2 = subprocess.run([py_exec2, script_path2, str(p_output.resolve()), app_name2, out_html2],
|
|
1056
|
+
capture_output=True, text=True, cwd=str(pathlib.Path(script_path2).parent))
|
|
1057
|
+
if sp2.returncode == 0 and not quiet:
|
|
1058
|
+
print(f"✅ Generated: {out_html2}")
|
|
1059
|
+
elif verbose:
|
|
1060
|
+
console.print(f"[yellow]Safety net failed (rc={sp2.returncode}). stderr:\n{sp2.stderr[:300]}[/yellow]")
|
|
1061
|
+
except Exception:
|
|
1062
|
+
pass
|
|
1063
|
+
# Post-step now runs regardless of LLM value via the general post-process hook above.
|
|
1064
|
+
elif not quiet:
|
|
1065
|
+
# No destination resolved; surface the generated code directly to the console.
|
|
1066
|
+
console.print(Panel(Text(generated_code_content, overflow="fold"), title="[cyan]Generated Code[/cyan]", expand=False))
|
|
1067
|
+
console.print("[yellow]No output path resolved; skipping file write and stdout print.[/yellow]")
|
|
435
1068
|
else:
|
|
436
|
-
|
|
437
|
-
|
|
1069
|
+
# If LLM was disabled and post-process ran, that's a success (no error)
|
|
1070
|
+
if not llm_enabled and post_process_script:
|
|
1071
|
+
if verbose or not quiet:
|
|
1072
|
+
console.print("[green]Post-process completed successfully (LLM was disabled).[/green]")
|
|
1073
|
+
else:
|
|
1074
|
+
console.print("[red]Error: Code generation failed. No code was produced.[/red]")
|
|
1075
|
+
return "", was_incremental_operation, total_cost, model_name or "error"
|
|
438
1076
|
|
|
1077
|
+
except click.Abort:
|
|
1078
|
+
# User cancelled - re-raise to stop the sync loop
|
|
1079
|
+
raise
|
|
439
1080
|
except Exception as e:
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
1081
|
+
if isinstance(e, click.UsageError):
|
|
1082
|
+
raise
|
|
1083
|
+
|
|
1084
|
+
# For any other unexpected error, we should fail hard so the CLI exits non-zero
|
|
1085
|
+
# Log the detailed traceback first if verbose
|
|
1086
|
+
if verbose:
|
|
1087
|
+
import traceback
|
|
1088
|
+
console.print(traceback.format_exc())
|
|
1089
|
+
|
|
1090
|
+
raise click.UsageError(f"An unexpected error occurred: {e}")
|
|
444
1091
|
|
|
445
|
-
return generated_code_content or "", was_incremental_operation, total_cost, model_name
|
|
1092
|
+
return generated_code_content or "", was_incremental_operation, total_cost, model_name
|