diff --git a/CHANGELOG.md b/CHANGELOG.md index 54618ee..2312476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +## 0.7.16 - 2026-09-13 + +对使用者的影响:新增开发线增量审查靶区提取与本地门禁核验(`dyro review verify` 与 `dyro review pack`),大幅降低多仓协同中代码审查与 LLM 交互的 Token 消耗与注意力稀释。 + +- 新增 `dyro review verify --line `:一键自动化运行指定开发线各挂载仓的本地静态门禁,0 Token 拦截编译错误、类型错误与语法测试报错。 +- 新增 `dyro review pack --line [--scope contracts|all|summary] [--out ]`:自动提取跨仓增量 Diff,自动识别高危 Schema/DTO/API 契约文件并建立索引;支持 `--scope contracts`(仅提取核心契约Diff,默认)、`--scope all`(全量Diff带防膨胀截断)与 `--scope summary`(仅改动索引总览),将输入 Token 压缩 90% 以上。 + ## 0.7.15 - 2026-09-12 对使用者的影响:`dyro dispatch` 的异步运行不再因为并发读取运行状态而 diff --git a/pyproject.toml b/pyproject.toml index 345055f..bbdf50b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dyro" -version = "0.7.15" +version = "0.7.16" description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dyro/bridge/skill/SKILL.md b/src/dyro/bridge/skill/SKILL.md index b5df812..cfeb739 100644 --- a/src/dyro/bridge/skill/SKILL.md +++ b/src/dyro/bridge/skill/SKILL.md @@ -50,7 +50,7 @@ release, publish, console, install, or any confirmation/approval field. ```json { "protocol": {"major": 1, "minor": 0}, - "client": {"name": "dyro-agent-bridge-skill", "version": "0.7.15"}, + "client": {"name": "dyro-agent-bridge-skill", "version": "0.7.16"}, "operation": "bridge.capabilities.compact", "input": {} } diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 5980f9c..9905d32 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -3232,6 +3232,50 @@ def cmd_changeset_verify(args: argparse.Namespace) -> None: raise DyroError(f"Change Set {args.id} 未通过核验") +def cmd_review_verify(args: argparse.Namespace) -> None: + from .review_pack import run_line_verify + from .workspace import get_line + + config = _config(args) + line = get_line(config, args.line) + results = run_line_verify(config, line, dry_run=args.dry_run) + if not results: + print(f"开发线 {line.id} 无已配置门禁指令。") + return + failures = [r for r in results if not r.passed] + for r in results: + status_str = "PASS" if r.passed else "FAIL" + print(f"[{status_str}] {r.repo_id}: {' '.join(r.argv)} ({r.elapsed_seconds:.2f}s)") + if not r.passed and r.stdout: + print(" --- 失败日志摘录 ---\n " + "\n ".join(r.stdout.splitlines()[-15:])) + if failures: + raise DyroError(f"开发线 {line.id} 本地静态门禁未通过({len(failures)}/{len(results)} 失败)") + print(f"\n全部门禁通过(共 {len(results)} 项)。") + + +def cmd_review_pack(args: argparse.Namespace) -> None: + from .review_pack import build_review_pack + from pathlib import Path + + config = _config(args) + content = build_review_pack( + config, + args.line, + base=args.base, + scope=args.scope, + run_verify=not args.no_verify, + max_lines_per_file=args.max_lines, + dry_run=args.dry_run, + ) + if args.out: + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(content, encoding="utf-8") + print(f"已生成审查靶区包:{out_path}") + else: + print(content) + + def cmd_task_create(args: argparse.Namespace) -> None: config = _config(args) validate_id(args.id, "任务 ID") @@ -5353,6 +5397,26 @@ def build_parser() -> argparse.ArgumentParser: ) changeset_verify.set_defaults(func=cmd_changeset_verify) + review = sub.add_parser("review", help="开发线增量审查靶区提取与门禁核验") + review_sub = review.add_subparsers(dest="review_command", required=True) + review_verify = review_sub.add_parser("verify", help="运行开发线各仓本地 verify 静态门禁") + review_verify.add_argument("--line", required=True, help="开发线 ID") + review_verify.set_defaults(func=cmd_review_verify) + + review_pack = review_sub.add_parser("pack", help="提取开发线跨仓精简 Diff 与审查上下文") + review_pack.add_argument("--line", required=True, help="开发线 ID") + review_pack.add_argument("--base", help="对比基线;默认开发线 base") + review_pack.add_argument( + "--scope", + choices=("contracts", "all", "summary"), + default="contracts", + help="审查靶区范围:contracts (仅核心契约/协议Diff,默认), all (全量文件Diff), summary (仅改动索引不含Diff)", + ) + review_pack.add_argument("--no-verify", action="store_true", help="跳过本地 verify 门禁检查") + review_pack.add_argument("--max-lines", type=int, default=250, help="单文件 Diff 最大保留行数(防 Token 膨胀)") + review_pack.add_argument("--out", help="输出 Markdown 审查包文件路径;默认标准输出") + review_pack.set_defaults(func=cmd_review_pack) + objective = sub.add_parser( "objective", help="持久化并观察一个跨任务 Objective;此阶段不执行 Task" ) diff --git a/src/dyro/review_pack.py b/src/dyro/review_pack.py new file mode 100644 index 0000000..66ac974 --- /dev/null +++ b/src/dyro/review_pack.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import fnmatch +from pathlib import Path +import time + +from .config import Config +from .process import git, run +from .workspace import get_line, line_repository_path, Line + +IGNORE_DIFF_PATTERNS = ( + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "uv.lock", + "poetry.lock", + "*.min.js", + "*.min.css", + "*.map", + "*.svg", + "*.png", + "*.jpg", + "*.jpeg", + "*.ico", + "*.pdf", + "*.log", + "dist/*", + "build/*", + "target/*", + "unpackage/*", + ".pytest_cache/*", + ".idea/*", + ".vscode/*", +) + +CONTRACT_PATTERNS = ( + "*dto*", + "*schema*", + "*schemas*", + "*model*", + "*models*", + "*entity*", + "*entities*", + "*api*", + "*controller*", + "*protocol*", + "*contract*", + "*constants*", + "*.sql", + "*ddl*", +) + + +@dataclass(frozen=True) +class GateResult: + repo_id: str + argv: tuple[str, ...] + code: int + passed: bool + stdout: str + elapsed_seconds: float + + +@dataclass(frozen=True) +class RepoDiffSummary: + repo_id: str + branch: str + base: str + commits_count: int + commits_log: str + stat_summary: str + contract_files: tuple[str, ...] + other_files: tuple[str, ...] + file_diffs: dict[str, str] + + +def is_ignored(filename: str) -> bool: + norm = filename.replace("\\", "/") + for pattern in IGNORE_DIFF_PATTERNS: + if fnmatch.fnmatch(norm, pattern) or fnmatch.fnmatch(Path(norm).name, pattern): + return True + return False + + +def is_contract(filename: str) -> bool: + norm = filename.replace("\\", "/").lower() + for pattern in CONTRACT_PATTERNS: + if fnmatch.fnmatch(norm, pattern) or fnmatch.fnmatch(Path(norm).name, pattern): + return True + return False + + +def run_line_verify( + config: Config, + line: Line, + *, + dry_run: bool = False, +) -> list[GateResult]: + results: list[GateResult] = [] + for repo_id in line.repositories: + repo_cfg = config.repositories.get(repo_id) + if not repo_cfg or not repo_cfg.verify: + continue + repo_path = line_repository_path(config, line, repo_id) + if not repo_path.is_dir(): + continue + for cmd in repo_cfg.verify: + start_time = time.perf_counter() + exec_res = run(cmd, cwd=repo_path, dry_run=dry_run) + elapsed = time.perf_counter() - start_time + passed = exec_res.code == 0 + results.append( + GateResult( + repo_id=repo_id, + argv=tuple(cmd), + code=exec_res.code, + passed=passed, + stdout=exec_res.stdout.strip(), + elapsed_seconds=elapsed, + ) + ) + return results + + +def analyze_repo_diff( + config: Config, + line: Line, + repo_id: str, + *, + base: str | None = None, + scope: str = "contracts", + max_lines_per_file: int = 250, +) -> RepoDiffSummary | None: + repo_path = line_repository_path(config, line, repo_id) + if not repo_path.is_dir(): + return None + + actual_base = base or line.base_for(repo_id) + + verify_base = git(repo_path, "rev-parse", "--verify", "-q", actual_base) + if verify_base.code != 0: + alt_base = f"origin/{actual_base}" + if git(repo_path, "rev-parse", "--verify", "-q", alt_base).code == 0: + actual_base = alt_base + + branch_res = git(repo_path, "branch", "--show-current") + branch = branch_res.stdout.strip() or line.branch + + log_res = git(repo_path, "log", "--oneline", f"{actual_base}...HEAD") + commits = [line for line in log_res.stdout.strip().splitlines() if line.strip()] + commits_count = len(commits) + commits_log = "\n".join(commits[:15]) + if commits_count > 15: + commits_log += f"\n... (共 {commits_count} 个提交,省略剩余 {commits_count - 15} 个)" + + stat_res = git(repo_path, "diff", f"{actual_base}...HEAD", "--stat") + stat_summary = stat_res.stdout.strip() + + names_res = git(repo_path, "diff", f"{actual_base}...HEAD", "--name-status") + changed_names = [line.strip() for line in names_res.stdout.strip().splitlines() if line.strip()] + + contract_files: list[str] = [] + other_files: list[str] = [] + file_diffs: dict[str, str] = {} + + for line_item in changed_names: + parts = line_item.split(maxsplit=1) + if len(parts) < 2: + continue + _status, file_path = parts[0], parts[1] + if " -> " in file_path: + file_path = file_path.split(" -> ")[1].strip() + + if is_ignored(file_path): + continue + + if is_contract(file_path): + contract_files.append(file_path) + else: + other_files.append(file_path) + + # Determine which files need full diff based on scope + files_to_diff: list[str] = [] + if scope == "contracts": + files_to_diff = list(contract_files) + elif scope == "all": + files_to_diff = list(contract_files) + list(other_files) + + for file_path in files_to_diff: + diff_res = git(repo_path, "diff", f"{actual_base}...HEAD", "--", file_path) + raw_diff = diff_res.stdout + diff_lines = raw_diff.splitlines() + + if len(diff_lines) > max_lines_per_file: + truncated_diff = "\n".join(diff_lines[:max_lines_per_file]) + truncated_diff += f"\n... [Dyro 提示:该文件改动超过 {max_lines_per_file} 行,已智能截断以降低 Token 消耗。原文件共 {len(diff_lines)} 行]" + file_diffs[file_path] = truncated_diff + else: + file_diffs[file_path] = raw_diff + + return RepoDiffSummary( + repo_id=repo_id, + branch=branch, + base=actual_base, + commits_count=commits_count, + commits_log=commits_log, + stat_summary=stat_summary, + contract_files=tuple(contract_files), + other_files=tuple(other_files), + file_diffs=file_diffs, + ) + + +def build_review_pack( + config: Config, + line_id: str, + *, + base: str | None = None, + scope: str = "contracts", + run_verify: bool = True, + max_lines_per_file: int = 250, + dry_run: bool = False, +) -> str: + line = get_line(config, line_id) + now_str = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + sections: list[str] = [ + "# Dyro 开发线复核审查靶区包(Review Pack)", + f"- **开发线 ID**:`{line.id}`", + f"- **分支**:`{line.branch}`", + f"- **对比基线(Base)**:`{base or line.base}`", + f"- **审查模式(Scope)**:`{scope}`({'契约与协议靶区高亮·Token优化' if scope == 'contracts' else '全量改动Diff' if scope == 'all' else '改动概览与索引'})", + f"- **生成时间**:{now_str}", + "", + ] + + # 1. Verification Gates + gates_failed = False + if run_verify: + gate_results = run_line_verify(config, line, dry_run=dry_run) + sections.append("## 一、 本地静态门禁验证(Verify Gates)") + if not gate_results: + sections.append("> ℹ️ 当前开发线仓库未配置或未执行本地 verify 门禁。\n") + else: + sections.append("| 仓库 | 门禁指令 | 耗时 | 状态 |") + sections.append("|:---|:---|:---|:---|") + for gr in gate_results: + status_label = "✅ PASS" if gr.passed else "❌ FAIL" + cmd_str = " ".join(gr.argv) + sections.append(f"| `{gr.repo_id}` | `{cmd_str}` | {gr.elapsed_seconds:.2f}s | {status_label} |") + if not gr.passed: + gates_failed = True + + sections.append("") + if gates_failed: + sections.append("> 🚨 **门禁阻断告警**:本地静态门禁未全部通过!请优先解决机器检查报错,避免消耗 AI Token 进行无意义的肉眼语法排查。\n") + sections.append("### 门禁失败日志摘录") + for gr in gate_results: + if not gr.passed: + snippet = "\n".join(gr.stdout.splitlines()[-25:]) + sections.append(f"#### `{gr.repo_id}`: `{' '.join(gr.argv)}`") + sections.append(f"```text\n{snippet}\n```\n") + + # 2. Repo Diff Analysis + repo_summaries: list[RepoDiffSummary] = [] + for repo_id in line.repositories: + summary = analyze_repo_diff( + config, + line, + repo_id, + base=base, + scope=scope, + max_lines_per_file=max_lines_per_file, + ) + if summary is not None: + repo_summaries.append(summary) + + sections.append("## 二、 跨仓改动总览(Cross-Repo Diff Overview)") + sections.append("| 仓库 | 分支 | 基线 | 提交数 | 契约文件数 | 业务文件数 |") + sections.append("|:---|:---|:---|:---|:---|:---|") + total_commits = 0 + total_contracts = 0 + total_others = 0 + + for rs in repo_summaries: + c_count = len(rs.contract_files) + o_count = len(rs.other_files) + total_commits += rs.commits_count + total_contracts += c_count + total_others += o_count + sections.append(f"| `{rs.repo_id}` | `{rs.branch}` | `{rs.base}` | {rs.commits_count} | {c_count} | {o_count} |") + + sections.append("") + sections.append(f"**合计**:共 {len(repo_summaries)} 个仓库,{total_commits} 次提交,{total_contracts} 个核心契约变更,{total_others} 个业务实现变更。\n") + + # 3. High-Priority Contract Index + sections.append("## 三、 核心协议与契约变更索引(高危审查靶区)") + has_any_contract = False + for rs in repo_summaries: + if rs.contract_files: + has_any_contract = True + sections.append(f"### 仓 `{rs.repo_id}` 契约文件({len(rs.contract_files)} 个):") + for cf in rs.contract_files: + sections.append(f"- `{cf}`") + sections.append("") + + if not has_any_contract: + sections.append("> 无显式 DTO / Schema / API 契约文件名变更。\n") + + has_other_files = any(bool(rs.other_files) for rs in repo_summaries) + if has_other_files: + sections.append("### 其他业务实现文件变更清单(不展开 Diff 以免消耗大量 Token):") + for rs in repo_summaries: + if rs.other_files: + sections.append(f"**仓 `{rs.repo_id}`({len(rs.other_files)} 个文件)**:") + for of in rs.other_files[:60]: + sections.append(f"- `{of}`") + if len(rs.other_files) > 60: + sections.append(f"- ... (省略剩余 {len(rs.other_files) - 60} 个文件)") + sections.append("") + + # 4. Recommended Review Seats + sections.append("## 四、 分席位独立审查建议(阻断上下文二次方膨胀)") + sections.append("> 💡 **审查指南**:不要把整个大包塞进同一个 AI 会话。推荐使用独立对话分别委派以下三个席位,各席位完成审查后向主会话仅汇总结论:\n") + sections.append( + "1. **`Reviewer-Java` 席位**:专注于 `common-msv` 仓的 DDL、业务事务回滚、鉴权注解与对外接口边界。\n" + "2. **`Reviewer-Python` 席位**:专注于 `ai-agent` 仓的模型路由、Prompt 构造、Pydantic Schema 校验与供应商容灾。\n" + "3. **`Reviewer-Contract` 席位**:专注于 Java DTO ↔ Python Schema ↔ 前端(小程序/PC)实时态与历史态消费者对齐。\n" + ) + + # 5. Compact Code Diffs + if scope != "summary": + sections.append("## 五、 增量代码 Diff(Compact Diff)") + for rs in repo_summaries: + if not rs.file_diffs: + continue + sections.append(f"### 仓库 `{rs.repo_id}`") + if rs.commits_log: + sections.append("
最近提交记录(点击展开)\n") + sections.append(f"```text\n{rs.commits_log}\n```\n
\n") + + # Contract diffs first + if rs.contract_files: + sections.append(f"#### 1. 契约与协议代码 Diff({len(rs.contract_files)} 个)") + for cf in rs.contract_files: + diff_text = rs.file_diffs.get(cf, "") + if diff_text.strip(): + sections.append(f"**文件:`{cf}`**") + sections.append(f"```diff\n{diff_text.strip()}\n```\n") + + # Other diffs + if scope == "all" and rs.other_files: + sections.append(f"#### 2. 业务实现代码 Diff({len(rs.other_files)} 个)") + for of in rs.other_files: + diff_text = rs.file_diffs.get(of, "") + if diff_text.strip(): + sections.append(f"
{of}(点击展开 Diff)\n") + sections.append(f"```diff\n{diff_text.strip()}\n```\n
\n") + + return "\n".join(sections) diff --git a/tests/test_cli.py b/tests/test_cli.py index c3b0174..1fe8e34 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2270,6 +2270,6 @@ def test_package_version_matches_pyproject(self) -> None: from dyro import __version__ metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - self.assertEqual(metadata["project"]["version"], "0.7.15") - self.assertEqual(__version__, "0.7.15") + self.assertEqual(metadata["project"]["version"], "0.7.16") + self.assertEqual(__version__, "0.7.16") self.assertEqual(__version__, metadata["project"]["version"]) diff --git a/tests/test_console_artifacts.py b/tests/test_console_artifacts.py index 6148dae..f3c8180 100644 --- a/tests/test_console_artifacts.py +++ b/tests/test_console_artifacts.py @@ -225,12 +225,12 @@ def test_page_and_manifest_keep_p3_fail_closed_pins(self) -> None: self.assertNotEqual(refresh_at, -1) self.assertNotIn(b"/artifacts", script.body[refresh_at:next_fn]) - def test_package_version_is_0_7_15(self) -> None: + def test_package_version_is_0_7_16(self) -> None: import tomllib from pathlib import Path metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - self.assertEqual(metadata["project"]["version"], "0.7.15") + self.assertEqual(metadata["project"]["version"], "0.7.16") class ConsoleArtifactServiceTests(WorkspaceCase): diff --git a/tests/test_release_gates.py b/tests/test_release_gates.py index cb1007c..e02b04e 100644 --- a/tests/test_release_gates.py +++ b/tests/test_release_gates.py @@ -32,7 +32,7 @@ def test_physics_train_refuses_published_0_6_9_tag(self) -> None: def test_0_7_release_runs_gates_without_claiming_1_0(self) -> None: stdout = StringIO() with redirect_stdout(stdout): - code = main(["--root", str(ROOT), "--release-tag", "v0.7.15"]) + code = main(["--root", str(ROOT), "--release-tag", "v0.7.16"]) self.assertEqual(code, 0) self.assertIn("0.7 gates present", stdout.getvalue()) self.assertNotIn("1.0 gates present", stdout.getvalue()) diff --git a/tests/test_review_pack.py b/tests/test_review_pack.py new file mode 100644 index 0000000..548b81c --- /dev/null +++ b/tests/test_review_pack.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path + +from dyro.config import load +from dyro.review_pack import build_review_pack, run_line_verify +from dyro.workspace import create_line, line_repository_path + +from tests.support import WorkspaceCase, shell + + +def commit_file(worktree: Path, name: str, message: str, content: str = "") -> None: + target = worktree / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content or f"{message}\n", encoding="utf-8") + shell("git", "add", str(target), cwd=worktree) + shell("git", "commit", "-m", message, cwd=worktree) + + +class ReviewPackTests(WorkspaceCase): + def test_run_line_verify_pass_and_fail(self) -> None: + config_path = self.root / "dyro.toml" + config_path.write_text( + config_path.read_text(encoding="utf-8").replace( + 'verify = [["git", "diff", "--check"]]', + 'verify = [["git", "status"], ["sh", "-c", "exit 0"]]', + ), + encoding="utf-8", + ) + config = load(self.root) + create_line(config, line_id="feat-1", branch="feat/1", base="main") + from dyro.workspace import get_line + + line = get_line(config, "feat-1") + results = run_line_verify(config, line) + self.assertEqual(len(results), 2) + self.assertTrue(all(r.passed for r in results)) + + def test_build_review_pack_generates_markdown_with_contracts(self) -> None: + config_path = self.root / "dyro.toml" + config_path.write_text( + config_path.read_text(encoding="utf-8").replace( + 'verify = [["git", "diff", "--check"]]', + 'verify = [["sh", "-c", "echo all-good"]]', + ), + encoding="utf-8", + ) + config = load(self.root) + create_line(config, line_id="feat-review", branch="feat/review", base="main") + + from dyro.workspace import get_line + + line = get_line(config, "feat-review") + api_path = line_repository_path(config, line, "api") + + # Commit a contract change and an implementation change + commit_file( + api_path, + "src/dto/UserDTO.java", + "feat(api): add UserDTO", + "public class UserDTO { String id; }", + ) + commit_file( + api_path, + "src/service/UserService.java", + "feat(api): update service", + "class UserService {}", + ) + + pack = build_review_pack(config, "feat-review", run_verify=True) + self.assertIn("Dyro 开发线复核审查靶区包", pack) + self.assertIn("UserDTO.java", pack) + self.assertIn("UserService.java", pack) + self.assertIn("✅ PASS", pack) + self.assertIn("Reviewer-Java", pack) + self.assertIn("Reviewer-Contract", pack) + + def test_cli_review_pack_and_verify(self) -> None: + from dyro.cli import main + + config_path = self.root / "dyro.toml" + config_path.write_text( + config_path.read_text(encoding="utf-8").replace( + 'verify = [["git", "diff", "--check"]]', + 'verify = [["sh", "-c", "echo verify-ok"]]', + ), + encoding="utf-8", + ) + config = load(self.root) + create_line(config, line_id="feat-cli", branch="feat/cli", base="main") + + buf = StringIO() + with redirect_stdout(buf): + main(["--root", str(self.root), "review", "verify", "--line", "feat-cli"]) + self.assertIn("全部门禁通过", buf.getvalue()) + + buf_pack = StringIO() + with redirect_stdout(buf_pack): + main(["--root", str(self.root), "review", "pack", "--line", "feat-cli"]) + self.assertIn("Dyro 开发线复核审查靶区包", buf_pack.getvalue()) diff --git a/uv.lock b/uv.lock index 459980c..b7d18cd 100644 --- a/uv.lock +++ b/uv.lock @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "dyro" -version = "0.7.15" +version = "0.7.16" source = { editable = "." } dependencies = [ { name = "cryptography" },