//! Agent 友好基础设施生成(接入包 v2)。 //! //! 卡 A:扫描项目框架层技术栈,推断外部契约路径,为后续生成三件套做准备。 //! 与 project_scan.rs(工具层)互补:本模块专注框架/ORM/API 层识别。 use serde::{Deserialize, Serialize}; use std::path::Path; // ── 公共数据结构 ────────────────────────────────────────────────────────────── /// 推断出的外部契约条目(由用户确认后传给 generate_agent_infra_files)。 #[derive(Debug, Serialize, Deserialize, Clone)] pub struct DetectedContract { /// 人读标签,如"数据库 schema(Drizzle)" pub label: String, /// 推断的路径列表(可含 glob,如 "src/routes/**") pub paths: Vec, /// 变更时的文档同步提示 pub doc: String, /// "high":路径文件在磁盘上存在;"medium":仅从依赖推断;"low":弱信号 pub confidence: String, } /// scan_agent_infra_stack 的完整返回值。 #[derive(Debug, Serialize, Deserialize)] pub struct AgentInfraStack { /// 识别到的契约列表(已去重,按 label 排序) pub detected_contracts: Vec, /// AGENTS.md 是否已含"## 文档防腐元规则"段落 pub meta_rules_present: bool, /// docs/ai-context/project-context.yaml 是否已存在 pub context_yaml_exists: bool, } // ── package.json 解析 ───────────────────────────────────────────────────────── /// 从指定 package.json 文件提取 dependencies + devDependencies 的所有 key。 fn pkg_json_dep_keys_from_file(path: &Path) -> Vec { let content = match std::fs::read_to_string(path) { Ok(c) => c, Err(_) => return vec![], }; let v: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, Err(_) => return vec![], }; let mut keys = Vec::new(); for section in &["dependencies", "devDependencies"] { if let Some(obj) = v.get(section).and_then(|d| d.as_object()) { keys.extend(obj.keys().cloned()); } } keys } /// 收集根目录 + monorepo 子包(apps/*、packages/*)的所有依赖 key。 fn collect_all_pkg_deps(root: &Path) -> Vec { let mut keys = pkg_json_dep_keys_from_file(&root.join("package.json")); if root.join("pnpm-workspace.yaml").exists() { for dir in &["apps", "packages"] { if let Ok(entries) = std::fs::read_dir(root.join(dir)) { for entry in entries.flatten() { if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { let pkg = entry.path().join("package.json"); if pkg.exists() { keys.extend(pkg_json_dep_keys_from_file(&pkg)); } } } } } keys.sort(); keys.dedup(); } keys } /// monorepo 中在 apps/* 子包里找第一个含 sub_path 的目录,返回相对根的路径。 fn find_in_apps(root: &Path, sub_path: &str) -> Option { let apps_dir = root.join("apps"); if !apps_dir.is_dir() { return None; } let mut names: Vec<_> = std::fs::read_dir(&apps_dir) .ok()? .flatten() .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) .map(|e| e.file_name().to_string_lossy().to_string()) .collect(); names.sort(); for name in names { let rel = format!("apps/{name}/{sub_path}"); if root.join(&rel).exists() { return Some(rel); } } None } /// 从 Cargo.toml 内容字符串中粗略判断某 crate 是否在依赖中。 fn cargo_has(content: &str, crate_name: &str) -> bool { // 匹配 `crate_name = ...` 或 `crate_name = { ... }` 形式 content.contains(&format!("\n{crate_name} ")) || content.contains(&format!("\n{crate_name}=")) || content.contains(&format!("\"{crate_name}\"")) } // ── 框架识别核心 ────────────────────────────────────────────────────────────── fn path_exists(root: &Path, rel: &str) -> bool { root.join(rel).exists() } fn confidence(root: &Path, path: &str) -> String { if path.contains('*') { "medium".into() } else if path_exists(root, path) { "high".into() } else { "medium".into() } } fn detect_contracts(root: &Path) -> Vec { let mut contracts: Vec = Vec::new(); let pkg_deps = collect_all_pkg_deps(root); // 纯 Rust 项目:Cargo.toml 在根;Tauri 项目:Cargo.toml 在 src-tauri/ let cargo_content = std::fs::read_to_string(root.join("Cargo.toml")) .or_else(|_| std::fs::read_to_string(root.join("src-tauri/Cargo.toml"))) .unwrap_or_default(); // Tauri 项目:commands 目录在 src-tauri/src/commands/ let tauri_commands_path = if path_exists(root, "src-tauri/src/commands") { "src-tauri/src/commands" } else { "src/commands" }; // ── Node.js 框架层 ──────────────────────────────────────────────────────── // Drizzle ORM if pkg_deps.iter().any(|k| k == "drizzle-orm") { // monorepo:优先在 apps/* 子包里找 drizzle 目录 let p: String = find_in_apps(root, "src/drizzle") .map(|d| format!("{d}/**")) .or_else(|| find_in_apps(root, "drizzle").map(|d| format!("{d}/**"))) .unwrap_or_else(|| { if path_exists(root, "drizzle/schema.ts") { "drizzle/schema.ts".into() } else if path_exists(root, "src/db/schema.ts") { "src/db/schema.ts".into() } else { "drizzle/schema.ts".into() } }); contracts.push(DetectedContract { label: "数据库 schema(Drizzle)".into(), paths: vec![p.clone()], doc: "schema 变更需同步更新数据库文档,检查 project-context.yaml".into(), confidence: confidence(root, &p), }); } // Prisma if pkg_deps.iter().any(|k| k == "prisma" || k == "@prisma/client") { let p = "prisma/schema.prisma"; contracts.push(DetectedContract { label: "数据库 schema(Prisma)".into(), paths: vec![p.into()], doc: "schema 变更需同步更新数据库文档,检查 project-context.yaml".into(), confidence: confidence(root, p), }); } // Hono if pkg_deps.iter().any(|k| k == "hono") { // monorepo:优先在 apps/* 子包里找 routes 目录 let p: String = find_in_apps(root, "src/routes") .map(|d| format!("{d}/**")) .unwrap_or_else(|| { if path_exists(root, "src/routes") { "src/routes/**".into() } else { "src/index.ts".into() } }); contracts.push(DetectedContract { label: "API 接口(Hono)".into(), paths: vec![p], doc: "接口路由变更需同步更新 API 文档或 openapi.yaml".into(), confidence: "medium".into(), }); } // Express if pkg_deps.iter().any(|k| k == "express") { contracts.push(DetectedContract { label: "API 接口(Express)".into(), paths: vec!["src/routes/**".into()], doc: "接口路由变更需同步更新 API 文档".into(), confidence: "medium".into(), }); } // Fastify if pkg_deps.iter().any(|k| k == "fastify") { contracts.push(DetectedContract { label: "API 接口(Fastify)".into(), paths: vec!["src/routes/**".into()], doc: "接口路由变更需同步更新 API 文档".into(), confidence: "medium".into(), }); } // tRPC if pkg_deps.iter().any(|k| k == "@trpc/server") { let p = if path_exists(root, "src/router") { "src/router/**" } else { "src/trpc/**" }; contracts.push(DetectedContract { label: "API 接口(tRPC)".into(), paths: vec![p.into()], doc: "router 变更需同步 client 类型,检查 project-context.yaml".into(), confidence: "medium".into(), }); } // pnpm monorepo 共享类型 if path_exists(root, "pnpm-workspace.yaml") { // 优先已知命名约定;否则列出 packages/ 下实际存在的子包目录 let paths: Vec = if path_exists(root, "packages/types") { vec!["packages/types/**".into()] } else if path_exists(root, "packages/shared") { vec!["packages/shared/**".into()] } else if let Ok(entries) = std::fs::read_dir(root.join("packages")) { let mut dirs: Vec = entries .flatten() .filter(|e| { e.file_type().map(|t| t.is_dir()).unwrap_or(false) && e.path().join("package.json").exists() }) .map(|e| format!("packages/{}/**", e.file_name().to_string_lossy())) .collect(); dirs.sort(); dirs.truncate(3); if dirs.is_empty() { vec!["packages/**".into()] } else { dirs } } else { vec!["packages/**".into()] }; contracts.push(DetectedContract { label: "共享包(pnpm monorepo)".into(), paths, doc: "共享包变更影响所有 apps,禁止在各 app 内手抄类型".into(), confidence: "medium".into(), }); } // .env.example → 环境变量声明 if path_exists(root, ".env.example") { contracts.push(DetectedContract { label: "环境变量声明".into(), paths: vec![".env.example".into()], doc: "新增环境变量必须在 .env.example 中同步声明".into(), confidence: "high".into(), }); } // ── Rust 框架层 ─────────────────────────────────────────────────────────── if !cargo_content.is_empty() { // Axum if cargo_has(&cargo_content, "axum") { // Tauri 项目:Axum 通常用于嵌入式 MCP/HTTP 服务器,路由在 src-tauri/src/ let p = if path_exists(root, "src-tauri/src/mcp_server.rs") { "src-tauri/src/mcp_server.rs" } else if path_exists(root, "src/routes.rs") { "src/routes.rs" } else if path_exists(root, "src/routes") { "src/routes/**" } else { "src/routes/**" }; contracts.push(DetectedContract { label: "API 接口(Axum)".into(), paths: vec![p.into()], doc: "路由变更需同步 API 文档".into(), confidence: if p.contains('*') { "medium".into() } else { "high".into() }, }); } // SeaORM if cargo_has(&cargo_content, "sea-orm") { let p = "src/entities"; contracts.push(DetectedContract { label: "数据库 schema(SeaORM)".into(), paths: vec![format!("{p}/**")], doc: "entity 变更需同步数据库文档".into(), confidence: if path_exists(root, p) { "high".into() } else { "medium".into() }, }); } // Tauri(自身的 command 层) let tauri_frontend_ts = if path_exists(root, "src/lib/commands.ts") { "src/lib/commands.ts" } else { "src/commands.ts" }; if cargo_has(&cargo_content, "tauri") && path_exists(root, tauri_commands_path) { contracts.push(DetectedContract { label: "Tauri command 接口(前后端契约)".into(), paths: vec![ format!("{tauri_commands_path}/**/*.rs"), tauri_frontend_ts.into(), ], doc: "新增 command 时前后端必须同步".into(), confidence: "high".into(), }); } } contracts } // ── Tauri Command ───────────────────────────────────────────────────────────── /// 扫描项目框架层技术栈,推断外部契约路径。 /// project_path 为 Windows 本地项目根目录(绝对路径)。 #[tauri::command] pub fn scan_agent_infra_stack( project_path: String, ) -> Result { let root = Path::new(project_path.trim_end_matches(['/', '\\'])); if !root.exists() { return Err(format!("目录不存在: {}", root.display())); } let detected_contracts = detect_contracts(root); let meta_rules_present = std::fs::read_to_string(root.join("AGENTS.md")) .map(|c| c.contains("## 文档防腐元规则")) .unwrap_or(false); let context_yaml_exists = root.join("docs/ai-context/project-context.yaml").exists(); Ok(AgentInfraStack { detected_contracts, meta_rules_present, context_yaml_exists, }) } // ── 三件套生成 ──────────────────────────────────────────────────────────────── /// 用户确认后的契约条目(前端传入)。 #[derive(Debug, Deserialize, Clone)] pub struct ContractInput { pub label: String, pub paths: Vec, pub doc: String, } /// 单个文件的生成结果。 #[derive(Debug, Serialize)] pub struct FileResult { /// 相对项目根的路径 pub path: String, /// "written" | "updated" | "skipped" | "failed" pub action: String, pub detail: String, } /// generate_agent_infra_files 的完整返回值。 #[derive(Debug, Serialize)] pub struct GenerateReport { pub files: Vec, } // 元规则段落内容(技术栈无关,编译期内嵌) const META_RULES_SECTION: &str = r#" --- ## Agent 友好工程四支柱 修改代码前,确认这个项目满足四支柱: **可导航**:凭文件名/目录名可在 30 秒内定位某功能的代码。通用能力有独立家,不寄生在业务模块下。 **可推理**:公共类型只在一处定义。改一个接口,类型检查能报出所有受影响处。 **可验证**:typecheck / lint / test 各有一条命令真能跑。关键路径有冒烟测试。 **可记忆**:规则按场景注入(不是一份巨型文档)。决策只写"为什么",代码自己回答"是什么"。 --- ## 文档防腐元规则 做任何改动时,判断它属于哪类变更: ### T1 外部契约变更(最高优先级) 其他代码、服务、或 agent 依赖的定义——接口、共享类型、数据结构、配置键名、环境变量名。 → 查 `docs/ai-context/project-context.yaml` 找到记录该契约的文档,检查并同步更新。 ### T2 架构边界变更 模块划分、目录结构、依赖方向、包边界调整。 → 检查架构文档是否需要同步。 ### T3 工具链变更 引入或替换库、框架、构建工具。 → 更新 AGENTS.md 技术栈声明;通知炼境重新扫描并更新 `project-context.yaml`。 ### T4 常驻上下文过期 AGENTS.md 或 `docs/ai-context/project-brief.md` 描述的现实已经改变。 → 立即更新。这两份文档常驻 agent 上下文,过期危害最大。 ### 不触发检查 内部重构、性能优化、bug fix(外部行为不变)、测试文件、临时脚本。 --- ## 文档防腐铁律 - 文档只写稳定的:为什么、铁律、意图、架构决策。 - 易变的(端口、路由、表名、命令清单)交给工具自省,不写死进常驻文档。写死 = 定时炸弹。 - 单一事实来源:两处说同一事时,删掉派生的那处。 - 两份文档打架时,信代码,不信文档。 --- ## 定期防腐自查 每隔一阵或大改后,对照以下清单: - [ ] AGENTS.md 里的技术栈声明还和代码一致吗? - [ ] `project-brief.md` 最近 30 天有没有随架构改动更新? - [ ] 有没有两份文档说同一件事且打架? - [ ] `project-context.yaml` 的契约路径还准确吗? "#; fn write_file(path: &std::path::PathBuf, content: &str, rel: &str) -> FileResult { if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { return FileResult { path: rel.into(), action: "failed".into(), detail: format!("建目录失败: {e}"), }; } } match std::fs::write(path, content) { Ok(_) => FileResult { path: rel.into(), action: "written".into(), detail: "已写入".into() }, Err(e) => FileResult { path: rel.into(), action: "failed".into(), detail: format!("写入失败: {e}") }, } } /// 生成 docs/ai-context/project-context.yaml fn gen_context_yaml(root: &Path, contracts: &[ContractInput]) -> FileResult { let rel = "docs/ai-context/project-context.yaml"; let target = root.join(rel); let existed = target.exists(); let mut yaml = String::from( "# 项目契约路径映射\n\ # 由炼境根据技术栈扫描生成,技术栈变更后重新运行接入包生成\n\ # 供 AGENTS.md 元规则 T1 查询:「哪个文件是这个项目的外部契约?」\n\n\ contracts:\n", ); for c in contracts { yaml.push_str(&format!(" - label: {}\n paths:\n", c.label)); for p in &c.paths { yaml.push_str(&format!(" - \"{p}\"\n")); } yaml.push_str(&format!(" doc: \"{}\"\n\n", c.doc.replace('"', "'"))); } yaml.push_str( "architecture_docs:\n - \"AGENTS.md\"\n\n\ agent_docs:\n - \"AGENTS.md\"\n - \"docs/ai-context/project-brief.md\"\n", ); let mut result = write_file(&target, &yaml, rel); if result.action == "written" && existed { result.action = "updated".into(); result.detail = "已更新(覆盖上次生成版本)".into(); } result } /// 向 AGENTS.md 注入元规则段落(已有则跳过,幂等) fn inject_meta_rules(root: &Path) -> FileResult { let rel = "AGENTS.md"; let target = root.join(rel); match std::fs::read_to_string(&target) { Ok(content) if content.contains("## 文档防腐元规则") => FileResult { path: rel.into(), action: "skipped".into(), detail: "元规则段落已存在,未修改".into(), }, Ok(mut content) => { content.push_str(META_RULES_SECTION); match std::fs::write(&target, content) { Ok(_) => FileResult { path: rel.into(), action: "updated".into(), detail: "已追加元规则段落".into(), }, Err(e) => FileResult { path: rel.into(), action: "failed".into(), detail: format!("写入失败: {e}"), }, } } Err(_) => FileResult { path: rel.into(), action: "skipped".into(), detail: "AGENTS.md 不存在,请先应用接入包(v1)".into(), }, } } /// 生成 .claude/rules/doc-freshness.md(含项目特定 globs) fn gen_doc_freshness_rule(root: &Path, contracts: &[ContractInput]) -> FileResult { let rel = ".claude/rules/doc-freshness.md"; let target = root.join(rel); // 收集所有非 glob 的具体文件路径作为 globs frontmatter let globs: Vec = contracts .iter() .flat_map(|c| c.paths.iter().cloned()) .collect(); let globs_str = globs.join(", "); let content = format!( "---\nglobs: {globs_str}\n---\n\n\ 这些是本项目的外部契约文件。修改时检查 `docs/ai-context/project-context.yaml` 是否需要同步更新。\n\n\ 参考 AGENTS.md 元规则 **T1 外部契约变更**。\n" ); write_file(&target, &content, rel) } /// 根据用户确认的契约列表生成三件套文件。 #[tauri::command] pub fn generate_agent_infra_files( project_path: String, contracts: Vec, ) -> Result { let root = Path::new(project_path.trim_end_matches(['/', '\\'])); if !root.exists() { return Err(format!("目录不存在: {}", root.display())); } let files = vec![ gen_context_yaml(root, &contracts), inject_meta_rules(root), gen_doc_freshness_rule(root, &contracts), ]; Ok(GenerateReport { files }) } // ── 文档腐化评分 ────────────────────────────────────────────────────────────── /// 单条契约的腐化评估。 #[derive(Debug, Serialize, Deserialize, Clone)] pub struct FreshnessDetail { /// 契约路径(来自 project-context.yaml) pub contract_path: String, /// 契约文件最后一次 commit 的 Unix 时间戳;None = 从未提交 pub contract_ts: Option, /// project-context.yaml 最后一次 commit 的 Unix 时间戳 pub yaml_ts: Option, /// 契约比 yaml 新了多少天(负数 = yaml 更新) pub gap_days: Option, /// "green" | "yellow" | "red" pub level: String, } /// scan_doc_freshness 的完整返回值。 #[derive(Debug, Serialize)] pub struct FreshnessResult { /// 综合评分(所有 detail 中最差的) pub level: String, pub details: Vec, /// ISO 8601 检查时间 pub checked_at: String, } /// 对 git log 路径做简化:glob 路径取目录部分供 git log 使用。 fn git_query_path(path: &str) -> &str { if let Some(pos) = path.find("/**") { &path[..pos] } else if path.ends_with("**") { &path[..path.len() - 2] } else { path } } /// 获取某文件(或目录)在本地 git 仓库的最后 commit 时间戳(Unix 秒)。 /// 返回 None 表示路径从未被提交。 fn git_last_commit_ts(project_root: &Path, rel_path: &str) -> Option { let query = git_query_path(rel_path); let output = crate::silent_cmd("git") .args(["log", "--format=%at", "-1", "--", query]) .current_dir(project_root) .output() .ok()?; if !output.status.success() { return None; } let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); s.parse::().ok() } /// 从生成的 project-context.yaml 中提取 contracts.*.paths 列表。 fn extract_yaml_contract_paths(content: &str) -> Vec { let mut paths = Vec::new(); let mut in_paths_block = false; for line in content.lines() { // " paths:" 在 contracts 条目内(4 空格缩进) if line.starts_with(" paths:") { in_paths_block = true; continue; } if in_paths_block { // 路径条目:6 空格 + "- " if line.starts_with(" - ") { let raw = line .trim_start_matches(|c: char| c.is_whitespace()) .trim_start_matches("- ") .trim_matches('"'); if !raw.is_empty() { paths.push(raw.to_string()); } } else { in_paths_block = false; } } } paths } fn level_from_gap(gap_days: Option) -> &'static str { match gap_days { Some(d) if d > 30 => "red", Some(d) if d > 14 => "yellow", _ => "green", } } fn worst_level(a: &str, b: &str) -> String { match (a, b) { ("red", _) | (_, "red") => "red".into(), ("yellow", _) | (_, "yellow") => "yellow".into(), _ => "green".into(), } } /// 扫描文档腐化风险,结果写入 doc_freshness_cache。 #[tauri::command] pub fn scan_doc_freshness( project_path: String, project_id: String, ) -> Result { let root = Path::new(project_path.trim_end_matches(['/', '\\'])); if !root.exists() { return Err(format!("目录不存在: {}", root.display())); } let yaml_rel = "docs/ai-context/project-context.yaml"; let yaml_path = root.join(yaml_rel); // project-context.yaml 不存在 → 直接 RED if !yaml_path.exists() { let result = FreshnessResult { level: "red".into(), details: vec![FreshnessDetail { contract_path: yaml_rel.into(), contract_ts: None, yaml_ts: None, gap_days: None, level: "red".into(), }], checked_at: chrono::Local::now().to_rfc3339(), }; cache_result(&project_id, &result); return Ok(result); } let yaml_content = std::fs::read_to_string(&yaml_path).map_err(|e| e.to_string())?; let contract_paths = extract_yaml_contract_paths(&yaml_content); let yaml_ts = git_last_commit_ts(root, yaml_rel); let mut details: Vec = Vec::new(); let mut overall = String::from("green"); if contract_paths.is_empty() { // yaml 存在但无契约 → green(基础设施已就绪,只是项目没有外部契约) details.push(FreshnessDetail { contract_path: "(无契约路径)".into(), contract_ts: None, yaml_ts, gap_days: None, level: "green".into(), }); } else { for cp in &contract_paths { let contract_ts = git_last_commit_ts(root, cp); let gap_days = match (contract_ts, yaml_ts) { (Some(ct), Some(yt)) => { let diff = ct - yt; Some(diff / 86400) // 秒转天 } (Some(_), None) => Some(999), // 有契约提交但 yaml 从未提交 → 高风险 _ => None, }; let level = level_from_gap(gap_days).to_string(); overall = worst_level(&overall, &level); details.push(FreshnessDetail { contract_path: cp.clone(), contract_ts, yaml_ts, gap_days, level, }); } } let checked_at = chrono::Local::now().to_rfc3339(); let result = FreshnessResult { level: overall, details, checked_at, }; cache_result(&project_id, &result); Ok(result) } fn cache_result(project_id: &str, result: &FreshnessResult) { let details_json = serde_json::to_string(&result.details).unwrap_or_default(); if let Some(Ok(conn)) = crate::db::try_pool().map(|p| p.get()) { let _ = conn.execute( "INSERT INTO doc_freshness_cache (project_id, score, details, checked_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(project_id) DO UPDATE SET score = excluded.score, details = excluded.details, checked_at = excluded.checked_at", rusqlite::params![project_id, result.level, details_json, result.checked_at], ); } } /// 读取缓存的腐化评分(供看板 E 卡使用,不触发 git 扫描)。 #[tauri::command] pub fn get_doc_freshness_cache(project_id: String) -> Option { let conn = crate::db::try_pool()?.get().ok()?; conn.query_row( "SELECT score, details, checked_at FROM doc_freshness_cache WHERE project_id = ?1", rusqlite::params![project_id], |row| { Ok(CachedFreshness { score: row.get(0)?, details: row.get(1)?, checked_at: row.get(2)?, }) }, ).ok() } /// 看板 E 卡读取的缓存结构。 #[derive(Debug, Serialize)] pub struct CachedFreshness { pub score: String, pub details: String, // JSON 字符串,前端自行解析 pub checked_at: String, } // ── Agent 健康诊断 ───────────────────────────────────────────────────────────── #[derive(Debug, Serialize)] pub struct AgentHealthIssue { pub level: String, // "error" | "warn" pub title: String, pub fix: String, } #[derive(Debug, Serialize)] pub struct AgentHealthReport { pub overall: String, // "green" | "yellow" | "red" pub issues: Vec, /// 可直接复制给子项目 agent 的修正指令文本(Markdown) pub fix_prompt: String, pub checked_at: String, } /// 诊断项目 agent 友好度,生成可复制的修正指令。 /// 内部调用 scan_agent_infra_stack + scan_doc_freshness,同时刷新 doc_freshness_cache。 #[tauri::command] pub fn get_agent_health( project_path: String, project_id: String, ) -> Result { let root = Path::new(project_path.trim_end_matches(['/', '\\'])); if !root.exists() { return Err(format!("目录不存在: {}", root.display())); } let stack = scan_agent_infra_stack(project_path.clone())?; let freshness = scan_doc_freshness(project_path.clone(), project_id.clone())?; let mut issues: Vec = Vec::new(); let mut overall = "green".to_string(); if !stack.context_yaml_exists { issues.push(AgentHealthIssue { level: "error".into(), title: "缺少 docs/ai-context/project-context.yaml".into(), fix: "在炼境「Agent 基础设施」面板点击「扫描技术栈」→「确认并生成三件套」".into(), }); overall = "red".to_string(); } if !stack.meta_rules_present { issues.push(AgentHealthIssue { level: "warn".into(), title: "AGENTS.md 缺少「文档防腐元规则」段落".into(), fix: "在炼境「Agent 基础设施」面板生成三件套,或手动在 AGENTS.md 末尾添加 ## 文档防腐元规则 章节".into(), }); if overall != "red" { overall = "yellow".to_string(); } } match freshness.level.as_str() { "red" if !stack.context_yaml_exists => { // yaml 不存在导致的 red 已在上面记录,不重复 } "red" => { let stale_count = freshness.details.iter().filter(|d| d.level == "red").count(); issues.push(AgentHealthIssue { level: "error".into(), title: format!("文档严重腐化:{stale_count} 个契约文件更新超 30 天未同步 project-context.yaml"), fix: "更新(或重新生成)docs/ai-context/project-context.yaml 后【必须 git commit 该文件】——评分基于 git 提交时间而非文件修改时间,只改不提交无法清除本项".into(), }); overall = "red".to_string(); } "yellow" => { let stale_count = freshness.details.iter() .filter(|d| d.level == "yellow" || d.level == "red") .count(); issues.push(AgentHealthIssue { level: "warn".into(), title: format!("文档腐化风险:{stale_count} 个契约文件更新后 14-30 天未同步"), fix: "更新 docs/ai-context/project-context.yaml 中的契约描述并 git commit(评分基于提交时间)".into(), }); if overall != "red" { overall = "yellow".to_string(); } } _ => {} } let proj_name = root .file_name() .and_then(|n| n.to_str()) .unwrap_or("未知项目"); let fix_prompt = build_fix_prompt(proj_name, &overall, &issues, &stack, &freshness); Ok(AgentHealthReport { overall, issues, fix_prompt, checked_at: freshness.checked_at, }) } fn build_fix_prompt( project_name: &str, overall: &str, issues: &[AgentHealthIssue], stack: &AgentInfraStack, freshness: &FreshnessResult, ) -> String { let status_emoji = match overall { "red" => "🔴", "yellow" => "🟡", _ => "🟢", }; let status_label = match overall { "red" => "需要修复", "yellow" => "存在风险", _ => "良好", }; let date_label = freshness.checked_at.get(..10).unwrap_or(&freshness.checked_at); let mut lines = vec![ format!("# Agent 友好度诊断 — {project_name}"), format!("整体状态:{status_emoji} {status_label}(检测时间:{date_label})"), String::new(), ]; if issues.is_empty() { lines.push("✅ 所有检查通过,项目 agent 友好度良好,无需修复。".into()); } else { lines.push("## 待修复项".into()); lines.push(String::new()); for issue in issues { let icon = if issue.level == "error" { "❌" } else { "⚠️" }; lines.push(format!("{icon} **{}**", issue.title)); lines.push(format!(" 修复方法:{}", issue.fix)); lines.push(String::new()); } } let mut passed = Vec::new(); if stack.context_yaml_exists { passed.push("docs/ai-context/project-context.yaml 已存在".to_string()); } if stack.meta_rules_present { passed.push("AGENTS.md 已包含文档防腐元规则".to_string()); } if !stack.detected_contracts.is_empty() && freshness.level == "green" { passed.push(format!( "{} 个契约路径均在 14 天内同步", stack.detected_contracts.len() )); } if !passed.is_empty() { lines.push("## 已通过项".into()); lines.push(String::new()); for p in &passed { lines.push(format!("✅ {p}")); } lines.push(String::new()); } lines.push("---".into()); lines.push("*由炼境 dev-manager 自动生成。请根据上述修复项逐项处理,完成后在炼境中点击「重新检测」刷新状态。*".into()); lines.join("\n") } // ── 单元测试 ────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use std::fs; fn setup(dir: &std::path::Path) { fs::create_dir_all(dir).unwrap(); } fn write(dir: &std::path::Path, rel: &str, content: &str) { let p = dir.join(rel); if let Some(parent) = p.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(p, content).unwrap(); } #[test] fn detects_drizzle() { let dir = std::env::temp_dir().join(format!("ai_infra_test_drizzle_{}", std::process::id())); setup(&dir); write(&dir, "package.json", r#"{"dependencies":{"drizzle-orm":"^0.30.0","hono":"^4.0.0"}}"#); write(&dir, "drizzle/schema.ts", "export const users = {};"); let contracts = detect_contracts(&dir); let labels: Vec<&str> = contracts.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"数据库 schema(Drizzle)"), "应识别 Drizzle"); assert!(labels.contains(&"API 接口(Hono)"), "应识别 Hono"); let drizzle = contracts.iter().find(|c| c.label.contains("Drizzle")).unwrap(); assert_eq!(drizzle.confidence, "high", "schema.ts 存在应为 high"); fs::remove_dir_all(&dir).unwrap(); } #[test] fn detects_prisma() { let dir = std::env::temp_dir().join(format!("ai_infra_test_prisma_{}", std::process::id())); setup(&dir); write(&dir, "package.json", r#"{"devDependencies":{"prisma":"^5.0.0"},"dependencies":{"@prisma/client":"^5.0.0"}}"#); let contracts = detect_contracts(&dir); let labels: Vec<&str> = contracts.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"数据库 schema(Prisma)"), "应识别 Prisma"); let prisma = contracts.iter().find(|c| c.label.contains("Prisma")).unwrap(); // schema.prisma 不存在,confidence 应为 medium assert_eq!(prisma.confidence, "medium"); fs::remove_dir_all(&dir).unwrap(); } #[test] fn detects_axum_from_cargo() { let dir = std::env::temp_dir().join(format!("ai_infra_test_axum_{}", std::process::id())); setup(&dir); write(&dir, "Cargo.toml", "[dependencies]\naxum = \"0.7\"\ntokio = { version = \"1\" }\n"); let contracts = detect_contracts(&dir); let labels: Vec<&str> = contracts.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"API 接口(Axum)"), "应识别 Axum"); fs::remove_dir_all(&dir).unwrap(); } #[test] fn detects_tauri_contract() { let dir = std::env::temp_dir().join(format!("ai_infra_test_tauri_{}", std::process::id())); setup(&dir); write(&dir, "Cargo.toml", "[dependencies]\ntauri = \"2\"\n"); fs::create_dir_all(dir.join("src-tauri/src/commands")).unwrap(); let contracts = detect_contracts(&dir); let found = contracts.iter().any(|c| c.label.contains("Tauri command")); assert!(found, "应识别 Tauri command 契约"); fs::remove_dir_all(&dir).unwrap(); } #[test] fn empty_project_returns_empty() { let dir = std::env::temp_dir().join(format!("ai_infra_test_empty_{}", std::process::id())); setup(&dir); let contracts = detect_contracts(&dir); assert!(contracts.is_empty(), "无框架文件应返回空列表"); fs::remove_dir_all(&dir).unwrap(); } #[test] fn generate_creates_three_files() { let dir = std::env::temp_dir().join(format!("ai_infra_test_gen_{}", std::process::id())); setup(&dir); // AGENTS.md 存在但无元规则 write(&dir, "AGENTS.md", "# My Project\n\n## 项目概况\n\nsome content\n"); let contracts = vec![ ContractInput { label: "数据库 schema(Drizzle)".into(), paths: vec!["drizzle/schema.ts".into()], doc: "schema 变更需同步".into(), }, ContractInput { label: "API 接口(Hono)".into(), paths: vec!["src/routes/**".into()], doc: "接口变更需同步".into(), }, ]; let report = generate_agent_infra_files(dir.to_str().unwrap().to_string(), contracts).unwrap(); let actions: Vec<(&str, &str)> = report.files.iter() .map(|f| (f.path.as_str(), f.action.as_str())) .collect(); // project-context.yaml 写入 assert!(actions.iter().any(|(p, a)| p.contains("project-context.yaml") && *a == "written"), "应写入 project-context.yaml,实际: {actions:?}"); // AGENTS.md 注入元规则 assert!(actions.iter().any(|(p, a)| p.contains("AGENTS.md") && *a == "updated"), "应追加元规则到 AGENTS.md,实际: {actions:?}"); // .claude/rules/doc-freshness.md 写入 assert!(actions.iter().any(|(p, a)| p.contains("doc-freshness.md") && *a == "written"), "应写入 doc-freshness.md,实际: {actions:?}"); // 验证 doc-freshness.md 含 globs let freshness = fs::read_to_string(dir.join(".claude/rules/doc-freshness.md")).unwrap(); assert!(freshness.contains("drizzle/schema.ts"), "doc-freshness 应含契约路径"); assert!(freshness.contains("globs:"), "doc-freshness 应有 globs frontmatter"); // 验证幂等:重复生成 AGENTS.md 应 skipped let contracts2 = vec![ContractInput { label: "L".into(), paths: vec!["x.ts".into()], doc: "d".into(), }]; let report2 = generate_agent_infra_files(dir.to_str().unwrap().to_string(), contracts2).unwrap(); let agents_result = report2.files.iter().find(|f| f.path.contains("AGENTS.md")).unwrap(); assert_eq!(agents_result.action, "skipped", "元规则已存在,第二次应跳过"); fs::remove_dir_all(&dir).unwrap(); } #[test] fn extract_yaml_paths_works() { let yaml = "\ contracts:\n - label: \"数据库 schema\"\n paths:\n - \"drizzle/schema.ts\"\n - \"src/db.ts\"\n doc: \"xxx\"\n - label: \"API\"\n paths:\n - \"src/routes/**\"\n doc: \"yyy\"\narchitecture_docs:\n - \"AGENTS.md\"\n"; let paths = extract_yaml_contract_paths(yaml); assert_eq!(paths, vec!["drizzle/schema.ts", "src/db.ts", "src/routes/**"]); } #[test] fn freshness_red_when_no_yaml() { let dir = std::env::temp_dir().join(format!("ai_infra_test_fresh_{}", std::process::id())); setup(&dir); // 初始化一个假 git 仓库(让 git log 不报错) let _ = std::process::Command::new("git").args(["init"]).current_dir(&dir).output(); let result = scan_doc_freshness( dir.to_str().unwrap().to_string(), "test-proj-id".into(), ).unwrap(); assert_eq!(result.level, "red", "无 project-context.yaml 应返回 RED"); fs::remove_dir_all(&dir).unwrap(); } #[test] fn git_query_path_strips_glob() { assert_eq!(git_query_path("src/routes/**"), "src/routes"); assert_eq!(git_query_path("drizzle/schema.ts"), "drizzle/schema.ts"); assert_eq!(git_query_path("packages/types/**"), "packages/types"); } #[test] fn meta_rules_detection() { let dir = std::env::temp_dir().join(format!("ai_infra_test_meta_{}", std::process::id())); setup(&dir); write(&dir, "AGENTS.md", "# Title\n## 文档防腐元规则\nsome content"); write(&dir, "package.json", "{}"); let result = scan_agent_infra_stack(dir.to_str().unwrap().to_string()).unwrap(); assert!(result.meta_rules_present); assert!(!result.context_yaml_exists); fs::remove_dir_all(&dir).unwrap(); } }