dev-manager-tauri/src-tauri/src/commands/agent_infra.rs
lanrtop 4f5cf431f6 fix(agent-infra): 腐化诊断修复指引死循环——明示评分基于 git 提交时间
用户按指引"重新生成三件套"两轮无法清除红色诊断:生成只改工作区文件,
而评分比较的是 git 提交时间戳。修复指引现明确要求 commit。
enterprise-system 实测:commit project-context.yaml 后诊断转绿。

Rules-Applied: R11
2026-07-03 08:55:15 +09:00

1136 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 {
/// 人读标签,如"数据库 schemaDrizzle"
pub label: String,
/// 推断的路径列表(可含 glob如 "src/routes/**"
pub paths: Vec<String>,
/// 变更时的文档同步提示
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<DetectedContract>,
/// 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<String> {
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<String> {
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<String> {
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<DetectedContract> {
let mut contracts: Vec<DetectedContract> = 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: "数据库 schemaDrizzle".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: "数据库 schemaPrisma".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<String> = 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<String> = 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: "数据库 schemaSeaORM".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<AgentInfraStack, String> {
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<String>,
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<FileResult>,
}
// 元规则段落内容(技术栈无关,编译期内嵌)
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<String> = 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<ContractInput>,
) -> Result<GenerateReport, String> {
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<i64>,
/// project-context.yaml 最后一次 commit 的 Unix 时间戳
pub yaml_ts: Option<i64>,
/// 契约比 yaml 新了多少天(负数 = yaml 更新)
pub gap_days: Option<i64>,
/// "green" | "yellow" | "red"
pub level: String,
}
/// scan_doc_freshness 的完整返回值。
#[derive(Debug, Serialize)]
pub struct FreshnessResult {
/// 综合评分(所有 detail 中最差的)
pub level: String,
pub details: Vec<FreshnessDetail>,
/// 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<i64> {
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::<i64>().ok()
}
/// 从生成的 project-context.yaml 中提取 contracts.*.paths 列表。
fn extract_yaml_contract_paths(content: &str) -> Vec<String> {
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<i64>) -> &'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<FreshnessResult, String> {
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<FreshnessDetail> = 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<CachedFreshness> {
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<AgentHealthIssue>,
/// 可直接复制给子项目 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<AgentHealthReport, String> {
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<AgentHealthIssue> = 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(&"数据库 schemaDrizzle"), "应识别 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(&"数据库 schemaPrisma"), "应识别 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: "数据库 schemaDrizzle".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();
}
}