feat(agent-infra): 卡C——文档腐化评分 + doc_freshness_cache 表

scan_doc_freshness():git log 读取契约路径 vs project-context.yaml
时间戳差值,project-context.yaml 不存在直接 RED;结果写入 DB 缓存。
get_doc_freshness_cache():看板读缓存不触发扫描。
10 条单元测试全过。
This commit is contained in:
lanrtop 2026-06-29 18:26:47 +09:00
parent 5e824027e5
commit 0c5730381d
5 changed files with 291 additions and 2 deletions

View File

@ -99,8 +99,8 @@
- files: src-tauri/src/commands/agent_infra.rs, src/lib/commands.ts - 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`;对同一项目重复执行幂等 - 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. 文档腐化评分 ### C. 文档腐化评分
- status: in_progress - status: done
- complexity: M - complexity: M
- files: src-tauri/src/commands/agent_infra.rs, src-tauri/src/db.rs, src/lib/commands.ts - 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 补齐);前端封装就绪 - 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 补齐);前端封装就绪

View File

@ -476,6 +476,226 @@ pub fn generate_agent_infra_files(
Ok(GenerateReport { 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<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,
}
// ── 单元测试 ────────────────────────────────────────────────────────────────── // ── 单元测试 ──────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
@ -617,6 +837,37 @@ mod tests {
fs::remove_dir_all(&dir).unwrap(); 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] #[test]
fn meta_rules_detection() { fn meta_rules_detection() {
let dir = std::env::temp_dir().join(format!("ai_infra_test_meta_{}", std::process::id())); let dir = std::env::temp_dir().join(format!("ai_infra_test_meta_{}", std::process::id()));

View File

@ -834,6 +834,16 @@ fn migrate(conn: &rusqlite::Connection) {
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
); );
").expect("create docker_apps table"); ").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)] #[cfg(test)]

View File

@ -270,6 +270,8 @@ pub fn run() {
// agent infrastructure // agent infrastructure
commands::agent_infra::scan_agent_infra_stack, commands::agent_infra::scan_agent_infra_stack,
commands::agent_infra::generate_agent_infra_files, 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, commands::commit_metrics::get_commit_stats,
// mentor // mentor
get_mentor_context, get_mentor_context,

View File

@ -1652,6 +1652,32 @@ export interface GenerateReport {
export const generateAgentInfraFiles = (projectPath: string, contracts: ContractInput[]) => export const generateAgentInfraFiles = (projectPath: string, contracts: ContractInput[]) =>
invoke<GenerateReport>('generate_agent_infra_files', { projectPath, contracts }); invoke<GenerateReport>('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<FreshnessResult>('scan_doc_freshness', { projectPath, projectId });
export const getDocFreshnessCache = (projectId: string) =>
invoke<CachedFreshness | null>('get_doc_freshness_cache', { projectId });
export interface CommitStats { export interface CommitStats {
total: number; total: number;
conventional: number; conventional: number;