From eba9bd586362f3d458d1bc94b7a1babc3bb16dfb Mon Sep 17 00:00:00 2001 From: songsenand Date: Thu, 19 Mar 2026 00:31:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E4=BF=AE=E5=A4=8D=20process=5Fis?= =?UTF-8?q?sue=20=E6=96=B9=E6=B3=95=E4=BB=A5=E9=81=B5=E5=BE=AA=20design.js?= =?UTF-8?q?on=20=E4=B8=AD=E7=9A=84=E6=96=87=E4=BB=B6=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- issues/dependency-order-fix-enhance-bug.issue | 25 ++++ pyproject.toml | 1 + src/llm_codegen/cli.py | 134 ++++++++++++----- src/llm_codegen/core.py | 136 ++++++++++++------ tests/test_diff_applier.py | 12 +- 6 files changed, 225 insertions(+), 87 deletions(-) create mode 100644 issues/dependency-order-fix-enhance-bug.issue diff --git a/.gitignore b/.gitignore index 8bd93b9..a75ca5d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ uv.lock test_output/ */__pycache__/ *.egg-info -*/*.egg-info \ No newline at end of file +*/*.egg-info +/logs/* +/llm_responses/* \ No newline at end of file diff --git a/issues/dependency-order-fix-enhance-bug.issue b/issues/dependency-order-fix-enhance-bug.issue new file mode 100644 index 0000000..df8580d --- /dev/null +++ b/issues/dependency-order-fix-enhance-bug.issue @@ -0,0 +1,25 @@ +# Bug 工单 + +name: fix/enhance 命令未遵循文件依赖关系进行修改或生成 +description: | + 在执行 `llm-codegen fix` 或 `llm-codegen enhance` 命令时,代码生成过程没有严格遵循 `design.json` 中定义的文件依赖关系。 + 例如,如果文件 A 依赖于文件 B,在修改工单中同时包含了 A 和 B,那么在生成 A 的新内容时,其上下文可能仍然是 B 修改前的旧内容, + 因为 B 的修改尚未应用到文件系统,或者应用顺序没有被强制保证。 + +steps_to_reproduce: | + 1. 初始化一个项目,使其 `design.json` 中包含具有明确依赖关系的文件(例如,A 依赖于 B)。 + 2. 创建一个 `enhance` 或 `fix` 工单,该工单要求同时修改文件 A 和 B。 + 3. 运行 `llm-codegen enhance` 或 `llm-codegen fix` 命令。 + 4. 观察生成的日志和最终代码,可以发现 A 的内容可能是基于 B 的旧内容生成的,违背了依赖关系。 + +expected_behavior: | + `process_issue` 方法应该像 `run` 方法一样,解析 `design.json` 中的依赖关系。 + 在处理 `affected_files` 列表时,必须确保在处理文件 A 之前,其所有依赖项(B, C, D...)都已经被成功修改或生成并写入磁盘。 + 这样可以保证 LLM 在生成 A 时,能够看到最新的依赖文件内容。 + +actual_behavior: | + `process_issue` 方法只是简单地顺序处理 `affected_files` 列表,没有考虑它们之间在 `design.json` 中定义的依赖关系。 + 这导致了生成的代码可能基于陈旧的上下文,从而产生错误或不一致的代码。 + +affected_files: + - src/llm_codegen/core.py diff --git a/pyproject.toml b/pyproject.toml index 9e6dd42..cc62f5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pathspec>=1.0.4", "python-patch>=0.0.1", "unidiff2>=0.7.8", + "pendulum>=3.2.0", ] authors = [ {name = "Your Name", email = "your.email@example.com"} diff --git a/src/llm_codegen/cli.py b/src/llm_codegen/cli.py index d02a1e9..b4ab595 100644 --- a/src/llm_codegen/cli.py +++ b/src/llm_codegen/cli.py @@ -20,7 +20,9 @@ app = typer.Typer(help="基于LLM的自动化代码生成与维护工具") console = Console() -def init_logging(output_dir: Path, log_file: Optional[str] = None, command_name: str = "cli") -> str: +def init_logging( + output_dir: Path, log_file: Optional[str] = None, command_name: str = "cli" +) -> str: """初始化日志配置到logs/目录""" log_dir = output_dir / "logs" log_dir.mkdir(parents=True, exist_ok=True) @@ -35,28 +37,38 @@ def init_logging(output_dir: Path, log_file: Optional[str] = None, command_name: @app.command() def init( - readme: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, help="README.md 文件路径"), - output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="输出根目录,默认为当前目录"), - api_key: Optional[str] = typer.Option(None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥"), - base_url: str = typer.Option("https://api.deepseek.com", "--base-url", help="API基础URL"), + readme: Path = typer.Argument( + ..., exists=True, file_okay=True, dir_okay=False, help="README.md 文件路径" + ), + output_dir: Optional[Path] = typer.Option( + None, "--output", "-o", help="输出根目录,默认为当前目录" + ), + api_key: Optional[str] = typer.Option( + None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥" + ), + base_url: str = typer.Option( + "https://api.deepseek.com", "--base-url", help="API基础URL" + ), model: str = typer.Option("deepseek-reasoner", "--model", "-m", help="使用的模型"), log_file: Optional[str] = typer.Option(None, "--log", help="日志文件路径"), - max_concurrency: int = typer.Option(4, "--max-concurrency", help="并发生成的最大工作线程数,默认4"), + max_concurrency: int = typer.Option( + 4, "--max-concurrency", help="并发生成的最大工作线程数,默认4" + ), ): """初始化项目:根据 README.md 自动生成完整的代码。""" if output_dir is None: output_dir = Path.cwd() - + # 初始化日志配置 log_file_path = init_logging(output_dir, log_file, command_name="init") - + # 处理致命错误:检查README文件存在性(已由typer处理),其他错误在try块中捕获 try: with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), - console=console + console=console, ) as progress: task_id = progress.add_task("正在初始化项目...", total=None) generator = CodeGenerator( @@ -78,13 +90,27 @@ def init( @app.command() def enhance( - issue_file: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, help="需求工单文件路径(如 feature.issue)"), - output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="项目根目录,默认为当前目录"), - api_key: Optional[str] = typer.Option(None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥"), - base_url: str = typer.Option("https://api.deepseek.com", "--base-url", help="API基础URL"), + issue_file: Path = typer.Argument( + ..., + exists=True, + file_okay=True, + dir_okay=False, + help="需求工单文件路径(如 feature.issue)", + ), + output_dir: Optional[Path] = typer.Option( + None, "--output", "-o", help="项目根目录,默认为当前目录" + ), + api_key: Optional[str] = typer.Option( + None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥" + ), + base_url: str = typer.Option( + "https://api.deepseek.com", "--base-url", help="API基础URL" + ), model: str = typer.Option("deepseek-reasoner", "--model", "-m", help="使用的模型"), log_file: Optional[str] = typer.Option(None, "--log", help="日志文件路径"), - max_concurrency: int = typer.Option(4, "--max-concurrency", help="并发生成的最大工作线程数,默认4"), + max_concurrency: int = typer.Option( + 4, "--max-concurrency", help="并发生成的最大工作线程数,默认4" + ), ): """增强项目:根据需求工单添加新功能。""" if output_dir is None: @@ -96,7 +122,9 @@ def enhance( # 处理致命错误:检查design.json是否存在 design_path = output_dir / "design.json" if not design_path.exists(): - logger.error(f"design.json 不存在于 {output_dir},请先运行 init 命令初始化项目。") + logger.error( + f"design.json 不存在于 {output_dir},请先运行 init 命令初始化项目。" + ) raise typer.Exit(code=1) # 读取工单文件 @@ -137,7 +165,7 @@ def enhance( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), - console=console + console=console, ) as progress: task_id = progress.add_task("正在增强项目...", total=None) generator = CodeGenerator( @@ -158,13 +186,27 @@ def enhance( @app.command() def fix( - issue_file: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, help="Bug工单文件路径(如 bug.issue)"), - output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="项目根目录,默认为当前目录"), - api_key: Optional[str] = typer.Option(None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥"), - base_url: str = typer.Option("https://api.deepseek.com", "--base-url", help="API基础URL"), + issue_file: Path = typer.Argument( + ..., + exists=True, + file_okay=True, + dir_okay=False, + help="Bug工单文件路径(如 bug.issue)", + ), + output_dir: Optional[Path] = typer.Option( + None, "--output", "-o", help="项目根目录,默认为当前目录" + ), + api_key: Optional[str] = typer.Option( + None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥" + ), + base_url: str = typer.Option( + "https://api.deepseek.com", "--base-url", help="API基础URL" + ), model: str = typer.Option("deepseek-reasoner", "--model", "-m", help="使用的模型"), log_file: Optional[str] = typer.Option(None, "--log", help="日志文件路径"), - max_concurrency: int = typer.Option(4, "--max-concurrency", help="并发生成的最大工作线程数,默认4"), + max_concurrency: int = typer.Option( + 4, "--max-concurrency", help="并发生成的最大工作线程数,默认4" + ), ): """修复项目:根据Bug工单自动修复 Bug。""" if output_dir is None: @@ -186,7 +228,6 @@ def fix( except Exception as e: logger.error(f"读取工单文件失败: {e}") raise typer.Exit(code=1) - try: with Progress( SpinnerColumn(), @@ -212,18 +253,35 @@ def fix( except Exception as e: logger.error(f"修复失败: {e}") raise typer.Exit(code=1) + console.print("[green]修复处理完成。成功处理文件,详情请查看日志。[/green]") @app.command() def design( - file: Path = typer.Option(..., "--file", "-f", help="README文件路径,用于生成design.json", exists=True, file_okay=True, dir_okay=False), - output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="输出目录,design.json将保存在此,默认为当前目录"), + file: Path = typer.Option( + ..., + "--file", + "-f", + help="README文件路径,用于生成design.json", + exists=True, + file_okay=True, + dir_okay=False, + ), + output_dir: Optional[Path] = typer.Option( + None, "--output", "-o", help="输出目录,design.json将保存在此,默认为当前目录" + ), force: bool = typer.Option(False, "--force", help="强制覆盖已存在的design.json"), - api_key: Optional[str] = typer.Option(None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥"), - base_url: str = typer.Option("https://api.deepseek.com", "--base-url", help="API基础URL"), + api_key: Optional[str] = typer.Option( + None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥" + ), + base_url: str = typer.Option( + "https://api.deepseek.com", "--base-url", help="API基础URL" + ), model: str = typer.Option("deepseek-reasoner", "--model", "-m", help="使用的模型"), log_file: Optional[str] = typer.Option(None, "--log", help="日志文件路径"), - max_concurrency: int = typer.Option(4, "--max-concurrency", help="并发生成的最大工作线程数,默认4"), + max_concurrency: int = typer.Option( + 4, "--max-concurrency", help="并发生成的最大工作线程数,默认4" + ), ): """生成或更新design.json:根据README文件生成中间设计文件,不生成完整代码。""" if output_dir is None: @@ -235,7 +293,9 @@ def design( # 检查design.json是否存在并处理强制覆盖 design_path = output_dir / "design.json" if not force and design_path.exists(): - logger.error(f"design.json 已存在于 {design_path}。使用 --force 参数以强制覆盖。") + logger.error( + f"design.json 已存在于 {design_path}。使用 --force 参数以强制覆盖。" + ) raise typer.Exit(code=1) try: @@ -243,7 +303,7 @@ def design( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), - console=console + console=console, ) as progress: task_id = progress.add_task("正在生成design.json...", total=None) generator = CodeGenerator( @@ -267,13 +327,21 @@ def design( @app.command() def check( - output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="项目根目录,默认为当前目录"), - api_key: Optional[str] = typer.Option(None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥"), - base_url: str = typer.Option("https://api.deepseek.com", "--base-url", help="API基础URL"), + output_dir: Optional[Path] = typer.Option( + None, "--output", "-o", help="项目根目录,默认为当前目录" + ), + api_key: Optional[str] = typer.Option( + None, "--api-key", envvar="DEEPSEEK_APIKEY", help="API密钥" + ), + base_url: str = typer.Option( + "https://api.deepseek.com", "--base-url", help="API基础URL" + ), model: str = typer.Option("deepseek-reasoner", "--model", "-m", help="使用的模型"), log_file: Optional[str] = typer.Option(None, "--log", help="日志文件路径"), max_retries: int = typer.Option(3, "--max-retries", help="最大修复重试次数"), - max_concurrency: int = typer.Option(4, "--max-concurrency", help="并发生成的最大工作线程数,默认4"), + max_concurrency: int = typer.Option( + 4, "--max-concurrency", help="并发生成的最大工作线程数,默认4" + ), ): """运行代码检查和自动修复(不依赖于工单)""" if output_dir is None: diff --git a/src/llm_codegen/core.py b/src/llm_codegen/core.py index c1a27d0..f82c6e9 100644 --- a/src/llm_codegen/core.py +++ b/src/llm_codegen/core.py @@ -3,7 +3,7 @@ import os import subprocess import sys import concurrent.futures -import difflib +import pendulum from typing import List, Dict, Optional, Any, Tuple from pathlib import Path from collections import deque @@ -15,7 +15,7 @@ from loguru import logger from openai import OpenAI from .utils import is_dangerous_command -from .models import DesignModel, StateModel, LLMResponse, FileModel +from .models import DesignModel, StateModel, FileModel from .diff_applier import parse_diff, apply_diff @@ -98,10 +98,35 @@ class CodeGenerator: content = message.content # 记录思考过程(如果存在) + reasoning_content = None if hasattr(message, "reasoning_content") and message.reasoning_content: - logger.info(f"模型思考过程: {message.reasoning_content}") + reasoning_content = message.reasoning_content + logger.info("模型思考过程已记录") - logger.debug(f"LLM原始响应: {content[:500]}...") + # 创建响应目录 + responses_dir = self.output_dir / "llm_responses" + responses_dir.mkdir(parents=True, exist_ok=True) + + # 生成文件名(使用当前时间) + timestamp = pendulum.now().format("YYYYMMDD_HHmmss_SSS") + response_file = responses_dir / f"response_{timestamp}.json" + + # 保存响应到JSON文件 + response_data = { + "timestamp": timestamp, + "model": self.model, + "content": content, + "reasoning_content": reasoning_content, + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "temperature": temperature, + "expect_json": expect_json + } + + with open(response_file, "w", encoding="utf-8") as f: + json.dump(response_data, f, indent=2, ensure_ascii=False) + + logger.debug(f"LLM原始响应: {response_file.name}") if expect_json: result = json.loads(content) @@ -119,6 +144,7 @@ class CodeGenerator: self.console.print(f"[bold red]❌ LLM调用失败: {e}[/bold red]") raise + def parse_readme(self, readme_path: Path) -> str: """ 读取README文件内容 @@ -362,8 +388,8 @@ class CodeGenerator: # 根据 output_format 设置 system_prompt if output_format == "diff": if existing_content is None: - logger.error(f"对于 output_format='diff',必须提供 existing_content") - self.console.print(f"[bold red]❌ 对于 output_format='diff',必须提供 existing_content[/bold red]") + logger.error("对于 output_format='diff',必须提供 existing_content") + self.console.print("[bold red]❌ 对于 output_format='diff',必须提供 existing_content[/bold red]") return "# 错误:缺少现有内容", "生成失败,缺少现有内容", [] system_prompt = ( "你是一个专业的编程助手。根据用户指令和提供的上下文文件,生成文件的差异(diff)。" @@ -405,7 +431,7 @@ class CodeGenerator: diff = result.get("diff") description = result.get("description", "") commands = result.get("commands", []) - output_format_resp = result.get("output_format", "diff") + result.get("output_format", "diff") if diff is None: raise ValueError("LLM 响应中没有 diff 字段") # 调用 diff_applier 应用 diff @@ -424,7 +450,7 @@ class CodeGenerator: code = result.get("code") description = result.get("description", "") commands = result.get("commands", []) - output_format_resp = result.get("output_format", "full") + result.get("output_format", "full") if code is None: raise ValueError("LLM 响应中没有 code 字段") return code, description, commands @@ -791,9 +817,37 @@ class CodeGenerator: self.console.print(f"[green]✅ 分析完成,将处理 {len(affected_files)} 个文件[/green]") - # 步骤2: 逐个处理文件 - generated_files = [] + # 添加依赖关系排序:解析 design.json 中的依赖,确保依赖项先于被依赖项处理 + # 构建依赖关系字典用于拓扑排序 + dependencies_dict = {} for file_info in affected_files: + path = file_info["path"] + # 从 design.json 中获取依赖关系 + deps = [] + for f in self.design.files: + if f.path == path: + deps = f.dependencies + break + # 只考虑在 affected_files 中的依赖文件,以确保内部依赖顺序 + affected_paths_set = set(info["path"] for info in affected_files) + filtered_deps = [dep for dep in deps if dep in affected_paths_set] + dependencies_dict[path] = filtered_deps + + # 对 affected_files 进行拓扑排序 + try: + sorted_paths = self._topological_sort([info["path"] for info in affected_files], dependencies_dict) + except ValueError as e: + logger.error(f"依赖关系排序失败: {e}") + self.console.print(f"[bold red]❌ 依赖关系排序失败: {e}[/bold red]") + return False # 排序失败,处理中止 + + # 重新排序 affected_files 基于 sorted_paths + file_info_map = {info["path"]: info for info in affected_files} + sorted_affected_files = [file_info_map[path] for path in sorted_paths] + + # 步骤2: 逐个处理文件(按依赖顺序) + generated_files = [] + for file_info in sorted_affected_files: file_path = file_info["path"] action = file_info.get("action", "modify") # modify 或 create description = file_info.get("description", "") @@ -839,42 +893,34 @@ class CodeGenerator: instruction += "请生成完整的代码文件。" # 调用 generate_file - output_format = "full" if action == "create" else "diff" - try: - code, desc, commands = self.generate_file( - file_path, - instruction, - dep_paths, - existing_content=existing, - output_format=output_format, + code, desc, commands = self.generate_file( + file_path, + instruction, + dep_paths, + existing_content=existing, + output_format="full", ) - logger.info(f"生成完成: {file_path} - {desc}") - - # 写入文件 - full_path.parent.mkdir(parents=True, exist_ok=True) - try: - with open(full_path, "w", encoding="utf-8") as f: - f.write(code) - logger.info(f"已写入: {full_path}") - generated_files.append(file_path) - except Exception as e: - logger.error(f"写入文件 {file_path} 失败: {e}") - self.console.print(f"[bold red]❌ 写入文件 {file_path} 失败: {e}[/bold red]") - # 跳过命令执行 - commands = [] - - # 执行关联命令 - for cmd in commands: - logger.info(f"准备执行命令: {cmd}") - success = self.execute_command(cmd, cwd=self.output_dir) - if not success: - logger.warning(f"命令执行失败,但继续处理: {cmd}") + logger.info(f"生成完成: {file_path} - {desc}") + # 写入文件 + full_path.parent.mkdir(parents=True, exist_ok=True) + try: + with open(full_path, "w", encoding="utf-8") as f: + f.write(code) + logger.info(f"已写入: {full_path}") + generated_files.append(file_path) except Exception as e: - logger.error(f"处理文件 {file_path} 失败: {e}") - self.console.print(f"[bold red]❌ 处理文件 {file_path} 失败: {e}[/bold red]") - # 继续处理其他文件 - continue + logger.error(f"写入文件 {file_path} 失败: {e}") + self.console.print(f"[bold red]❌ 写入文件 {file_path} 失败: {e}[/bold red]") + # 跳过命令执行 + commands = [] + + # 执行关联命令 + for cmd in commands: + logger.info(f"准备执行命令: {cmd}") + success = self.execute_command(cmd, cwd=self.output_dir) + if not success: + logger.warning(f"命令执行失败,但继续处理: {cmd}") # 步骤3: 更新 design.json if generated_files: @@ -978,7 +1024,7 @@ class CodeGenerator: return False else: logger.error("没有README内容,且README.md文件不存在,无法刷新design") - self.console.print(f"[bold red]❌ 没有README内容,且README.md文件不存在,无法刷新design[/bold red]") + self.console.print("[bold red]❌ 没有README内容,且README.md文件不存在,无法刷新design[/bold red]") return False try: @@ -1162,4 +1208,4 @@ class CodeGenerator: except Exception as e: logger.error(f"同步README.md失败: {e}") self.console.print(f"[bold red]❌ 同步README.md失败: {e}[/bold red]") - return False \ No newline at end of file + return False diff --git a/tests/test_diff_applier.py b/tests/test_diff_applier.py index 2f7f59b..a04c5f4 100644 --- a/tests/test_diff_applier.py +++ b/tests/test_diff_applier.py @@ -1,10 +1,6 @@ -#!/usr/bin/env python3 -"""Unit tests for diff_applier.py, covering various scenarios such as new file creation, -modification of existing files, conflict handling, and error cases.""" import os import sys import tempfile -import shutil import pytest # Add src directory to path for module import @@ -64,7 +60,7 @@ def test_apply_diff_new_file(): +This is a newly created file.""" result = apply_diff(diff, temp_dir) - assert result['success'] == True + assert result['success'] assert 'test_new.txt' in result['applied_files'] new_file_path = os.path.join(temp_dir, 'test_new.txt') @@ -122,7 +118,7 @@ def test_apply_diff_conflict_handling(): def test_apply_diff_empty_diff(): """Test applying an empty diff string.""" result = apply_diff('', '.') - assert result['success'] == False + assert not result['success'] assert 'empty' in result['message'].lower() def test_apply_diff_invalid_directory(): @@ -135,7 +131,7 @@ def test_apply_diff_invalid_directory(): +new""" result = apply_diff(diff, non_existent_dir) - assert result['success'] == False + assert not result['success'] assert 'does not exist' in result['message'].lower() def test_apply_diff_no_git_repo_initialization(): @@ -153,7 +149,7 @@ def test_apply_diff_no_git_repo_initialization(): +Updated content""" result = apply_diff(diff, temp_dir) - assert result['success'] == True + assert result['success'] assert 'non_git.txt' in result['applied_files'] with open(non_git_file, 'r') as f: