diff --git a/.blueprint/modules/agent-infrastructure.md b/.blueprint/modules/agent-infrastructure.md index caae551..399c341 100644 --- a/.blueprint/modules/agent-infrastructure.md +++ b/.blueprint/modules/agent-infrastructure.md @@ -99,8 +99,8 @@ - files: src-tauri/src/commands/agent_infra.rs, src/lib/commands.ts - acceptance: `generate_agent_infra_files(project_path, project_id, contracts)` 正确写入 `docs/ai-context/project-context.yaml`(含实际契约路径)、向 AGENTS.md 注入元规则段落(已有则跳过)、生成含项目特定 globs 的 `.claude/rules/doc-freshness.md`;对同一项目重复执行幂等 -### 🔵 C. 文档腐化评分 -- status: in_progress +### ✅ C. 文档腐化评分 +- status: done - complexity: M - files: src-tauri/src/commands/agent_infra.rs, src-tauri/src/db.rs, src/lib/commands.ts - acceptance: `scan_doc_freshness(project_path, project_id)` 用 `silent_cmd("git").current_dir(project_path)` 读取契约路径 vs `docs/ai-context/project-context.yaml` 的最后 commit 时间戳,project-context.yaml 不存在直接返回 RED;结果写入 `doc_freshness_cache` 表(migration 补齐);前端封装就绪 diff --git a/src-tauri/src/commands/agent_infra.rs b/src-tauri/src/commands/agent_infra.rs index ab1bc33..151c82f 100644 --- a/src-tauri/src/commands/agent_infra.rs +++ b/src-tauri/src/commands/agent_infra.rs @@ -476,6 +476,226 @@ pub fn generate_agent_infra_files( 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, +} + // ── 单元测试 ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -617,6 +837,37 @@ mod tests { 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())); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index c84449a..9fd8746 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -834,6 +834,16 @@ fn migrate(conn: &rusqlite::Connection) { updated_at TEXT DEFAULT (datetime('now')) ); ").expect("create docker_apps table"); + + // 增量迁移:Agent 基础设施文档腐化评分缓存 + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS doc_freshness_cache ( + project_id TEXT PRIMARY KEY, + score TEXT NOT NULL, + details TEXT NOT NULL, + checked_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + ").expect("create doc_freshness_cache table"); } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 164671a..37259de 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -270,6 +270,8 @@ pub fn run() { // agent infrastructure commands::agent_infra::scan_agent_infra_stack, commands::agent_infra::generate_agent_infra_files, + commands::agent_infra::scan_doc_freshness, + commands::agent_infra::get_doc_freshness_cache, commands::commit_metrics::get_commit_stats, // mentor get_mentor_context, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 28d36a0..c2b6144 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1652,6 +1652,32 @@ export interface GenerateReport { export const generateAgentInfraFiles = (projectPath: string, contracts: ContractInput[]) => invoke('generate_agent_infra_files', { projectPath, contracts }); +export interface FreshnessDetail { + contract_path: string; + contract_ts: number | null; + yaml_ts: number | null; + gap_days: number | null; + level: 'green' | 'yellow' | 'red'; +} + +export interface FreshnessResult { + level: 'green' | 'yellow' | 'red'; + details: FreshnessDetail[]; + checked_at: string; +} + +export interface CachedFreshness { + score: 'green' | 'yellow' | 'red'; + details: string; // JSON string + checked_at: string; +} + +export const scanDocFreshness = (projectPath: string, projectId: string) => + invoke('scan_doc_freshness', { projectPath, projectId }); + +export const getDocFreshnessCache = (projectId: string) => + invoke('get_doc_freshness_cache', { projectId }); + export interface CommitStats { total: number; conventional: number;