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:
parent
5e824027e5
commit
0c5730381d
@ -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 补齐);前端封装就绪
|
||||||
|
|||||||
@ -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()));
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user