feat(flywheel): 阶段三执行质量预测层(trailer 死链修复 + 风险画像) #2

Merged
lanrtop merged 14 commits from feat/flywheel-intelligence into master 2026-07-04 14:45:28 +08:00
3 changed files with 100 additions and 34 deletions
Showing only changes of commit 0ac809970f - Show all commits

View File

@ -159,8 +159,9 @@
- acceptance: "[cargo test] read_git_log 用 %(trailers:key=Rules-Applied,valueonly) 提取 trailerrecord_commit 改 UPSERT仅补 rules_applied IS NULL 的行,不覆盖已有值)单测覆盖;[Tauri command] 对炼境自身重跑 ingest_full_git_history → commit_metrics 中 rules_applied 非空行 ≥10rule_hit_stats 返回非空"
- notes: buff.rs 与 commit_metrics.rs 两处重复的 FMT 常量统一为 commit_metrics::read_git_log 单一来源git 原生 trailer 解析保持单行一条记录,避免 %B 多行解析
### 📋 P3-B. commit_files 采集(增量 + 全量)
- status: todo
### ✅ P3-B. commit_files 采集(增量 + 全量)
- status: done
- done_note: 2026-07-04 完成。commit_files(sha,path 联合主键 + path 索引) migration 到位read_git_log 加 --name-only单次 git 调用同时取指标与文件parse_git_log_output 纯函数解析 header/文件行混排record_commit_entry 落库文件INSERT OR IGNORE 幂等buff 增量与 ingest 全量两条链路自动覆盖。83 测试全绿(真仓库 e2e 含文件断言)
- complexity: M
- depends: flywheel-intelligence卡 P3-A共用 read_git_log 改造)
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/db.rs

View File

@ -157,6 +157,7 @@ pub fn record_commit(
}
/// 写入一条来自 `read_git_log` 的记录subject 与 trailer 分列trailer 由 git 原生解析)。
/// 同时把触碰文件写入 commit_filesINSERT OR IGNORE 幂等,供文件级风险画像)。
pub fn record_commit_entry(
conn: &Connection,
project_id: &str,
@ -169,7 +170,15 @@ pub fn record_commit_entry(
} else {
Some(entry.committed_at.as_str())
};
upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date)
let changed = upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date)?;
for path in &entry.files {
conn.execute(
"INSERT OR IGNORE INTO commit_files (sha, path) VALUES (?1, ?2)",
rusqlite::params![entry.sha, path],
)
.map_err(|e| e.to_string())?;
}
Ok(changed)
}
/// 单条规则的命中统计(跨项目聚合)
@ -296,12 +305,14 @@ pub struct IngestResult {
/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值
/// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到,
/// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。
/// `files` 来自 --name-onlyP3-B 文件级风险画像数据源merge commit 为空。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitLogEntry {
pub sha: String,
pub subject: String,
pub committed_at: String,
pub rules_trailer: String,
pub files: Vec<String>,
}
/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD增量 watcher
@ -310,27 +321,43 @@ pub enum GitLogRange<'a> {
Since(&'a str),
}
/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值逗号分隔每条一行。
/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值逗号分隔
/// 配合 --name-onlyheader 行后跟随该 commit 触碰的文件路径行。
const GIT_LOG_FMT: &str =
"--format=%H%x1f%s%x1f%cI%x1f%(trailers:key=Rules-Applied,valueonly,separator=%x2C)";
/// 解析 GIT_LOG_FMT 输出的一行。纯函数,可独立单测。
pub fn parse_git_log_line(line: &str) -> Option<GitLogEntry> {
/// 解析 GIT_LOG_FMT + --name-only 的完整输出。纯函数,可独立单测。
/// 规则:含 \x1f 的行是 commit header其后的非空行是该 commit 的文件路径。
pub fn parse_git_log_output(output: &str) -> Vec<GitLogEntry> {
let mut entries: Vec<GitLogEntry> = Vec::new();
for line in output.lines() {
if line.contains('\u{1f}') {
let mut parts = line.split('\u{1f}');
let sha = parts.next()?.trim().to_string();
let sha = parts.next().unwrap_or("").trim().to_string();
if sha.is_empty() {
return None;
continue;
}
Some(GitLogEntry {
entries.push(GitLogEntry {
sha,
subject: parts.next().unwrap_or("").to_string(),
committed_at: parts.next().unwrap_or("").to_string(),
rules_trailer: parts.next().unwrap_or("").trim().to_string(),
})
files: Vec::new(),
});
} else {
let path = line.trim();
if !path.is_empty() {
if let Some(cur) = entries.last_mut() {
cur.files.push(path.to_string());
}
}
}
}
entries
}
/// 读取 git log含 Rules-Applied trailer。增量与全量两条链路的单一取数入口
/// buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
/// 读取 git log含 Rules-Applied trailer 与触碰文件)。增量与全量两条链路的
/// 单一取数入口,buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<GitLogEntry>, String> {
let mut cmd = crate::silent_cmd("git");
cmd.arg("log");
@ -343,17 +370,14 @@ pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<Gi
}
}
let out = cmd
.arg(GIT_LOG_FMT)
.args([GIT_LOG_FMT, "--name-only"])
.current_dir(root)
.output()
.map_err(|e| format!("git log 失败: {e}"))?;
if !out.status.success() {
return Err(format!("git log 错误: {}", String::from_utf8_lossy(&out.stderr)));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(parse_git_log_line)
.collect())
Ok(parse_git_log_output(&String::from_utf8_lossy(&out.stdout)))
}
/// 全量历史导入:扫描最近 500 条 commit幂等写入 commit_metrics 表。
@ -707,6 +731,7 @@ mod tests {
subject: subject.into(),
committed_at: "2026-07-04T00:00:00+09:00".into(),
rules_trailer: trailer.into(),
files: Vec::new(),
}
}
@ -720,17 +745,28 @@ mod tests {
}
#[test]
fn parse_git_log_line_with_and_without_trailer() {
let e = parse_git_log_line("abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06")
.unwrap();
assert_eq!(e.sha, "abc");
assert_eq!(e.subject, "feat(x): y");
assert_eq!(e.rules_trailer, "R01,R06");
fn parse_git_log_output_headers_trailers_and_files() {
// 两条 commit首条带 trailer + 2 个文件,次条无 trailer + 1 个文件(--name-only 真实形状)
let output = "abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06\n\
\n\
src/a.rs\n\
src/b.rs\n\
def\u{1f}fix: z\u{1f}2026-07-04T00:00:00+09:00\u{1f}\n\
\n\
src/c.rs\n";
let entries = parse_git_log_output(output);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].sha, "abc");
assert_eq!(entries[0].subject, "feat(x): y");
assert_eq!(entries[0].rules_trailer, "R01,R06");
assert_eq!(entries[0].files, vec!["src/a.rs", "src/b.rs"]);
assert_eq!(entries[1].rules_trailer, "", "无 trailer 时为空串");
assert_eq!(entries[1].files, vec!["src/c.rs"]);
let e2 = parse_git_log_line("def\u{1f}fix: z\u{1f}2026-07-04T00:00:00+09:00\u{1f}").unwrap();
assert_eq!(e2.rules_trailer, "", "无 trailer 时为空串");
assert!(parse_git_log_line("").is_none());
assert!(parse_git_log_output("").is_empty());
// 空 commitmerge / --allow-emptyheader 后无文件行
let no_files = parse_git_log_output("abc\u{1f}chore: m\u{1f}d\u{1f}\n");
assert!(no_files[0].files.is_empty());
}
#[test]
@ -785,7 +821,10 @@ mod tests {
git(&["init", "-q"]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q",
"-m", "feat(a): no trailer"]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q",
std::fs::write(dir.join("x.rs"), "// x").unwrap();
std::fs::write(dir.join("y.rs"), "// y").unwrap();
git(&["add", "."]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q",
"-m", "fix(b): with trailer", "-m", "Rules-Applied: R01,R06"]);
let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap();
@ -795,7 +834,23 @@ mod tests {
// git log 逆序:最新在前
assert_eq!(entries[0].subject, "fix(b): with trailer");
assert_eq!(entries[0].rules_trailer, "R01,R06", "trailer 必须被提取(%s 死链回归)");
assert_eq!(entries[0].files, vec!["x.rs", "y.rs"], "--name-only 文件必须被采集");
assert_eq!(entries[1].rules_trailer, "");
assert!(entries[1].files.is_empty(), "空 commit 无文件");
}
#[test]
fn commit_files_written_and_idempotent() {
let conn = crate::db::conn_with_schema();
let mut e = entry("s1", "feat(a): x", "");
e.files = vec!["src/a.rs".into(), "src/b.rs".into()];
record_commit_entry(&conn, "proj", &e).unwrap();
record_commit_entry(&conn, "proj", &e).unwrap(); // 重跑
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM commit_files WHERE sha = 's1'", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 2, "文件写入且重跑幂等");
}
#[test]

View File

@ -538,6 +538,16 @@ fn migrate(conn: &rusqlite::Connection) {
// 增量迁移(飞轮 R1Rules-Applied trailer 规则命中列(已存在则忽略)
let _ = conn.execute("ALTER TABLE commit_metrics ADD COLUMN rules_applied TEXT", []);
// 飞轮阶段三P3-Bcommit 触碰的文件清单,供文件级返工风险画像
conn.execute_batch("
CREATE TABLE IF NOT EXISTS commit_files (
sha TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (sha, path)
);
CREATE INDEX IF NOT EXISTS idx_commit_files_path ON commit_files(path);
").expect("create commit_files table");
// 飞轮 L1PR 生命周期事件opened / closed / merged
conn.execute_batch("
CREATE TABLE IF NOT EXISTS pr_events (