diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 433a6d0..f0fdb14 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -99,11 +99,11 @@ --- -### 📋 L0:Conventional Commit 执行质量解析层(数据地基,先于阶段三) -- status: concept +### ✅ L0:Conventional Commit 执行质量解析层(数据地基,先于阶段三) +- status: done(2026-06-19,实现见 onboarding-pack 卡 C1+C2) - complexity: L - depends: onboarding-pack(项目须先种 lefthook + conventional 规范) -- files: src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs +- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/db.rs - acceptance: 炼境后端从本地 git 解析 conventional commit —— type=fix 计返工、scope 映射模块、commit 数≈轮数;自动填充 usage.json 的 rework_count/avg_rounds_M/complexity_dist,取代「从复盘笔记解析」的枯竭路径;CI 自修 commit(如 `[DeepSeek-V3]`)须从返工率剔除;squash merge 项目须降级处理 > 背景:实测复盘笔记进料口枯竭(70 条仅 4 条结构化)。改由 git 自吸,客户端无关、工作流无关,是阶段三预测层的真实数据基础。 diff --git a/.blueprint/modules/onboarding-pack.md b/.blueprint/modules/onboarding-pack.md index b48d31e..6f61644 100644 --- a/.blueprint/modules/onboarding-pack.md +++ b/.blueprint/modules/onboarding-pack.md @@ -65,7 +65,7 @@ git 是「过去时 + 量化」(做了什么),蓝图是「将来时 + 语 ## 任务卡 -> 已过 /architect。下一步 /splitter 派发。A→B、C→D 两条链可并行;C(飞轮地基)建议优先。 +> 已过 /architect + /splitter。A→B、C1→C2 两条链可并行,D 依赖 C1+B;**C1(飞轮地基)建议优先开工**。 ### 📋 A 模板分发引擎 - status: planned @@ -80,15 +80,22 @@ git 是「过去时 + 量化」(做了什么),蓝图是「将来时 + 语 - files: src-tauri/src/commands/onboarding.rs, src-tauri/src/lib.rs, src/lib/commands.ts, src/components/dashboard/ProjectCard.tsx - acceptance: apply_onboarding_pack(project_path, project_id) 组合「模板分发 + apply_blueprint_buff」返回 OnboardingReport;看板卡片有"接入包"入口,展示分发报告(written/skipped/failed) -### 📋 C L0 Conventional Commit 解析 -- status: planned -- complexity: L -- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/db.rs -- acceptance: 新增 commit_metrics 表;buff watcher 的 check_project 用 last_commit..HEAD 取 message → 解析 type/scope/is_rework/is_ci_auto 写表;append_usage_snapshot 的 rework_count 改查 commit_metrics(is_rework=1 AND is_ci_auto=0);首次接入限解析最近 N 条 +### ✅ C1 commit_metrics 表 + Conventional 解析器 +- status: done +- complexity: M +- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/db.rs +- acceptance: 新增 commit_metrics 表(sha PK / project_id / commit_type / scope / is_rework / is_ci_auto / committed_at / parsed_at);`parse_conventional(message)` 解析 type/scope,type=fix→is_rework,`[DeepSeek-V3]`/`[R1]` 等标记→is_ci_auto,非 conventional→commit_type=null 单列;提供聚合查询 `rework_count(project_id)`;解析逻辑纯函数可单测(覆盖 feat/fix/带scope/无scope/CI自修/非规范 六种输入) -### 📋 D 数据质量保护 +### ✅ C2 挂接 buff watcher + 飞轮快照 +- status: done +- complexity: M +- depends: onboarding-pack(卡 C1) +- files: src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs +- acceptance: check_project 用 `last_commit..HEAD` 取 commit message(git log,复用现有 WSL 路径处理)→ 调 C1 解析写 commit_metrics;首次接入(last_commit 空)限解析最近 N 条;append_usage_snapshot 的 rework_count 改查 commit_metrics(is_rework=1 AND is_ci_auto=0)取代 parse_notes_metrics + +### 📋 D 数据可信度提示 - status: planned - complexity: S -- depends: onboarding-pack(卡C) -- files: src-tauri/src/commands/commit_metrics.rs -- acceptance: CI 自修 commit([DeepSeek-V3] 等标记)置 is_ci_auto=1 从返工率剔除;非 conventional commit 记 commit_type=null 单列不污染 fix 率;接入报告提示"种 lefthook 后数据才准" +- depends: onboarding-pack(卡 C1、卡 B) +- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/commands/commit_metrics.rs +- acceptance: OnboardingReport 与飞轮展示标注该项目 commit 规范化比例(conventional / 总数);比例低时提示"种 lefthook 后数据才准",避免脏数据被误读为可信(CI 过滤 / 非规范单列已在 C1 落实) diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index 7296826..33dcd46 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -2010,6 +2010,10 @@ pub fn generate_muhe_prompt(stats: FlywheelStats) -> String { /// 从项目的 project_notes 解析返工次数和 M 级任务平均实际轮数。 /// 返回 (rework_count, avg_rounds_m),avg_rounds_m 为 None 表示无 M 级复盘数据。 +/// +/// 旧路径:飞轮 L0 起 rework_count 改由 commit_metrics(git 自吸)提供, +/// 此函数保留作参考与未来回退,暂不调用。 +#[allow(dead_code)] fn parse_notes_metrics(bp_dir: &Path) -> (u32, Option) { let project_path = match bp_dir.parent().and_then(|p| p.to_str()) { Some(p) => p.to_string(), @@ -2121,6 +2125,20 @@ fn parse_notes_metrics(bp_dir: &Path) -> (u32, Option) { (rework_count, avg) } +/// 从项目路径反查 project_id,再聚合 commit_metrics 的有效返工次数。 +/// 项目未施加 buff(查不到 project_id)时返回 None。 +fn commit_rework_for_path(project_path: &str) -> Option { + let conn = crate::db::pool().get().ok()?; + let pid: String = conn + .query_row( + "SELECT project_id FROM blueprint_buffs WHERE project_path = ?1", + rusqlite::params![project_path.trim_end_matches(['/', '\\'])], + |r| r.get(0), + ) + .ok()?; + crate::commands::commit_metrics::rework_count(&conn, &pid).ok() +} + /// 向项目 .blueprint/usage.json 追加一条每日飞轮快照。 /// 节流:同一 UTC 日期只追加一次;写入失败静默忽略(不影响主流程)。 fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &BlueprintStats) { @@ -2201,8 +2219,14 @@ fn append_usage_snapshot(bp_dir: &Path, manifest: &BlueprintManifest, stats: &Bl } } - // 从 project_notes 解析返工次数和 M 级任务平均轮数 - let (rework_count, avg_rounds_m) = parse_notes_metrics(bp_dir); + // 飞轮 L0:rework_count 从 commit_metrics(git 自吸)取,取代枯竭的复盘笔记解析; + // avg_rounds_M 需 PR 边界,基线档暂不出(见 gitea-integration 档2) + let rework_count = bp_dir + .parent() + .and_then(|p| p.to_str()) + .and_then(commit_rework_for_path) + .unwrap_or(0); + let avg_rounds_m: Option = None; // 停滞模块:in_progress 且任务全为 in_progress/blocked(没有 todo 可推进) let stalled_modules: Vec = manifest.modules.iter() diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs index c829e73..524638c 100644 --- a/src-tauri/src/commands/buff.rs +++ b/src-tauri/src/commands/buff.rs @@ -131,47 +131,72 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> { Err(_) => return Ok(()), // 不是 git 仓库或无提交,跳过 }; - // 查询上次已处理的 commit + // 查询 project_id 与上次已处理的 commit let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - let last_commit: Option = conn + let (project_id, last_commit): (String, Option) = conn .query_row( - "SELECT last_commit FROM blueprint_buffs WHERE project_path = ?1", + "SELECT project_id, last_commit FROM blueprint_buffs WHERE project_path = ?1", rusqlite::params![project_path], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) - .unwrap_or(None); + .unwrap_or_else(|_| (String::new(), None)); // 没有变化,跳过 if last_commit.as_deref() == Some(current_commit.as_str()) { return Ok(()); } - // 获取变更文件列表 - let changed = get_changed_files(root, last_commit.as_deref())?; - - let mut synced = false; - if !changed.is_empty() { - let updated = update_task_statuses(root, &changed)?; - if updated > 0 { - // 触发飞轮快照(调用 get_blueprint 会触发内部 append_usage_snapshot) - let _ = crate::commands::blueprint::get_blueprint(project_path.to_string()); - - // 通知前端刷新 - use tauri::Emitter; - let _ = app.emit( - "blueprint://changed", - serde_json::json!({ "project_path": project_path }), + // 飞轮 L0:解析新增 commit 的 conventional 指标写 commit_metrics(与下方文件匹配互不干扰) + if !project_id.is_empty() { + let first_time = last_commit.is_none(); + let commits = get_new_commits(root, last_commit.as_deref()); + if first_time && commits.len() >= FIRST_TIME_LIMIT { + crate::db::log_event( + "buff", + "info", + &format!("首次接入仅解析最近 {FIRST_TIME_LIMIT} 条 commit,更早历史跳过"), + Some(project_path), ); - synced = true; } + // 批量写入用事务包裹(首次可达数十条,避免逐条 fsync) + let tx = conn.unchecked_transaction(); + for (sha, subject, date) in &commits { + let _ = crate::commands::commit_metrics::record_commit( + &conn, + &project_id, + sha, + subject, + Some(date.as_str()), + ); + } + if let Ok(tx) = tx { + let _ = tx.commit(); + } + } + + // 文件路径匹配 → 标记命中的任务卡 🔵 + let changed = get_changed_files(root, last_commit.as_deref())?; + let mut tasks_updated = false; + if !changed.is_empty() && update_task_statuses(root, &changed)? > 0 { + tasks_updated = true; + } + + // 有新 commit 即刷新飞轮快照(rework_count 可能已变);任务卡有变动则通知前端重绘 + let _ = crate::commands::blueprint::get_blueprint(project_path.to_string()); + if tasks_updated { + use tauri::Emitter; + let _ = app.emit( + "blueprint://changed", + serde_json::json!({ "project_path": project_path }), + ); } // 更新 DB:记录已处理的 commit let _ = conn.execute( "UPDATE blueprint_buffs - SET last_commit = ?1, last_sync_at = CASE WHEN ?3 THEN datetime('now') ELSE last_sync_at END + SET last_commit = ?1, last_sync_at = datetime('now') WHERE project_path = ?2", - rusqlite::params![current_commit, project_path, synced], + rusqlite::params![current_commit, project_path], ); Ok(()) @@ -262,6 +287,64 @@ fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result) -> Vec<(String, String, String)> { + // sha \x1f subject \x1f committer-date(ISO),每条 commit 一行 + const FMT: &str = "--format=%H%x1f%s%x1f%cI"; + let path_str = root.to_string_lossy(); + + let out = if is_wsl_path(&path_str) { + let linux = match unc_to_linux(&path_str) { + Some(l) => l, + None => return vec![], + }; + let range = match last_commit { + Some(prev) => format!("{prev}..HEAD"), + None => format!("-n {FIRST_TIME_LIMIT}"), + }; + let cmd = format!("git -C {linux} log {range} {FMT}"); + crate::silent_cmd("wsl").args(["bash", "-c", &cmd]).output() + } else { + let mut cmd = crate::silent_cmd("git"); + cmd.arg("log"); + match last_commit { + Some(prev) => { + cmd.arg(format!("{prev}..HEAD")); + } + None => { + cmd.arg("-n").arg(FIRST_TIME_LIMIT.to_string()); + } + } + cmd.arg(FMT); + cmd.current_dir(root).output() + }; + + let out = match out { + Ok(o) if o.status.success() => o, + _ => return vec![], + }; + + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let mut parts = line.split('\u{1f}'); + let sha = parts.next()?.trim().to_string(); + if sha.is_empty() { + return None; + } + let subject = parts.next().unwrap_or("").to_string(); + let date = parts.next().unwrap_or("").to_string(); + Some((sha, subject, date)) + }) + .collect() +} + // ── 辅助:任务状态更新 ──────────────────────────────────────────────────────── fn update_task_statuses(root: &Path, changed_files: &[String]) -> Result { diff --git a/src-tauri/src/commands/commit_metrics.rs b/src-tauri/src/commands/commit_metrics.rs new file mode 100644 index 0000000..eede0d2 --- /dev/null +++ b/src-tauri/src/commands/commit_metrics.rs @@ -0,0 +1,231 @@ +//! Conventional Commit 解析与执行质量指标(飞轮 L0 数据地基)。 +//! +//! 从 git commit message 解析出 type/scope/返工/CI 自修信号,写入 commit_metrics 表, +//! 取代枯竭的「复盘笔记解析」路径。纯解析逻辑(`parse_conventional`)与 DB 无关,可独立单测。 + +use rusqlite::Connection; + +/// 一条 commit 的解析结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedCommit { + /// conventional type(feat/fix/chore...);None 表示非 conventional commit。 + pub commit_type: Option, + /// scope(如 estate1/cad);None 表示无 scope。 + pub scope: Option, + /// 是否返工:type == fix。 + pub is_rework: bool, + /// 是否 CI 自动修复(含 [DeepSeek-V3]/[R1] 等标记),聚合返工率时剔除。 + pub is_ci_auto: bool, +} + +/// CI 自动修复 commit 的标记(来自 enterprise-system CI:`fix(ci): auto-fix ... [DeepSeek-V3]`)。 +const CI_AUTO_MARKERS: [&str; 3] = ["[DeepSeek", "[R1]", "[V3]"]; + +/// 解析一条 commit message 为 Conventional Commit 结构。 +/// +/// 纯函数,不触碰 DB。无法识别为 conventional 的(merge commit、随手写的) +/// `commit_type` 记为 None,聚合时单列、不污染 fix 率。 +pub fn parse_conventional(message: &str) -> ParsedCommit { + let is_ci_auto = CI_AUTO_MARKERS.iter().any(|m| message.contains(m)); + + // 只看首行(subject) + let subject = message.lines().next().unwrap_or("").trim(); + + // 必须有 ": " 结构 + let prefix = match subject.split_once(':') { + Some((p, _desc)) => p.trim(), + None => return non_conventional(is_ci_auto), + }; + + // 去掉 breaking change 的末尾 '!' + let prefix = prefix.trim_end_matches('!'); + + // 拆出 type 与可选 scope:type(scope) + let (typ, scope) = match prefix.split_once('(') { + Some((t, rest)) => { + let scope = rest + .strip_suffix(')') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + (t.trim(), scope) + } + None => (prefix, None), + }; + + // type 必须是纯小写字母(feat/fix/...),否则视为非 conventional(如 "Merge pull request") + if typ.is_empty() || !typ.chars().all(|c| c.is_ascii_lowercase()) { + return non_conventional(is_ci_auto); + } + + ParsedCommit { + is_rework: typ == "fix", + commit_type: Some(typ.to_string()), + scope, + is_ci_auto, + } +} + +fn non_conventional(is_ci_auto: bool) -> ParsedCommit { + ParsedCommit { + commit_type: None, + scope: None, + is_rework: false, + is_ci_auto, + } +} + +/// 解析并写入一条 commit 记录(sha 为主键,重复写入忽略)。 +pub fn record_commit( + conn: &Connection, + project_id: &str, + sha: &str, + message: &str, + committed_at: Option<&str>, +) -> Result<(), String> { + let p = parse_conventional(message); + conn.execute( + "INSERT OR IGNORE INTO commit_metrics + (sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + sha, + project_id, + p.commit_type, + p.scope, + p.is_rework as i32, + p.is_ci_auto as i32, + committed_at, + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// 项目的有效返工次数:type=fix 且非 CI 自动修复。 +pub fn rework_count(conn: &Connection, project_id: &str) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM commit_metrics + WHERE project_id = ?1 AND is_rework = 1 AND is_ci_auto = 0", + rusqlite::params![project_id], + |r| r.get::<_, i64>(0), + ) + .map(|n| n as u32) + .map_err(|e| e.to_string()) +} + +/// commit 规范化统计:(总数, conventional 数)。供数据可信度提示(卡 D)使用。 +#[allow(dead_code)] +pub fn conventional_stats(conn: &Connection, project_id: &str) -> Result<(u32, u32), String> { + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1", + rusqlite::params![project_id], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + let conv: i64 = conn + .query_row( + "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND commit_type IS NOT NULL", + rusqlite::params![project_id], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + Ok((total as u32, conv as u32)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_feat_with_scope() { + let p = parse_conventional("feat(hub/rpc): add shared type system"); + assert_eq!(p.commit_type.as_deref(), Some("feat")); + assert_eq!(p.scope.as_deref(), Some("hub/rpc")); + assert!(!p.is_rework); + assert!(!p.is_ci_auto); + } + + #[test] + fn parses_fix_as_rework() { + let p = parse_conventional("fix(estate1/view): correct sort order"); + assert_eq!(p.commit_type.as_deref(), Some("fix")); + assert_eq!(p.scope.as_deref(), Some("estate1/view")); + assert!(p.is_rework); + assert!(!p.is_ci_auto); + } + + #[test] + fn parses_type_without_scope() { + let p = parse_conventional("chore: update AGENTS.md"); + assert_eq!(p.commit_type.as_deref(), Some("chore")); + assert_eq!(p.scope, None); + assert!(!p.is_rework); + } + + #[test] + fn detects_ci_auto_fix() { + let p = parse_conventional("fix(ci): auto-fix type/lint errors [DeepSeek-V3]"); + assert_eq!(p.commit_type.as_deref(), Some("fix")); + assert!(p.is_rework); + assert!(p.is_ci_auto, "应识别 [DeepSeek-V3] 为 CI 自修"); + } + + #[test] + fn breaking_change_marker_stripped() { + let p = parse_conventional("feat(api)!: drop legacy endpoint"); + assert_eq!(p.commit_type.as_deref(), Some("feat")); + assert_eq!(p.scope.as_deref(), Some("api")); + } + + #[test] + fn merge_commit_is_non_conventional() { + let p = parse_conventional("Merge pull request 'xxx' (#1) from dev into main"); + assert_eq!(p.commit_type, None); + assert!(!p.is_rework); + } + + #[test] + fn freeform_message_is_non_conventional() { + let p = parse_conventional("update stuff"); + assert_eq!(p.commit_type, None); + assert_eq!(p.scope, None); + } + + #[test] + fn uses_first_line_only() { + let p = parse_conventional("feat(x): title\n\nbody line with: colon"); + assert_eq!(p.commit_type.as_deref(), Some("feat")); + assert_eq!(p.scope.as_deref(), Some("x")); + } + + #[test] + fn rework_count_excludes_ci_auto_and_non_fix() { + let conn = crate::db::conn_with_schema(); + record_commit(&conn, "proj", "sha1", "fix(a): real bug", None).unwrap(); + record_commit(&conn, "proj", "sha2", "fix(ci): auto-fix [DeepSeek-V3]", None).unwrap(); + record_commit(&conn, "proj", "sha3", "feat(a): feature", None).unwrap(); + record_commit(&conn, "proj", "sha4", "update stuff", None).unwrap(); + // 仅 sha1 算有效返工(sha2 是 CI 自修、sha3 是 feat、sha4 非规范) + assert_eq!(rework_count(&conn, "proj").unwrap(), 1); + } + + #[test] + fn record_commit_is_idempotent() { + let conn = crate::db::conn_with_schema(); + record_commit(&conn, "proj", "dup", "fix: x", None).unwrap(); + record_commit(&conn, "proj", "dup", "fix: x", None).unwrap(); + let (total, _) = conventional_stats(&conn, "proj").unwrap(); + assert_eq!(total, 1, "同 sha 重复写入应被忽略"); + } + + #[test] + fn conventional_stats_counts_ratio() { + let conn = crate::db::conn_with_schema(); + record_commit(&conn, "proj", "s1", "feat: a", None).unwrap(); + record_commit(&conn, "proj", "s2", "random text", None).unwrap(); + let (total, conv) = conventional_stats(&conn, "proj").unwrap(); + assert_eq!(total, 2); + assert_eq!(conv, 1); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9aa0891..ed2343a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ pub mod tags; pub mod cicd; pub mod blueprint; pub mod buff; +pub mod commit_metrics; pub mod canvas; pub mod claude_config; pub mod mentor; diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index acea873..c84449a 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -520,6 +520,21 @@ fn migrate(conn: &rusqlite::Connection) { ); ").expect("create blueprint_buffs table"); + // 飞轮 L0:从 git 解析的 conventional commit 执行质量指标 + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS commit_metrics ( + sha TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + commit_type TEXT, + scope TEXT, + is_rework INTEGER NOT NULL DEFAULT 0, + is_ci_auto INTEGER NOT NULL DEFAULT 0, + committed_at TEXT, + parsed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_commit_metrics_project ON commit_metrics(project_id); + ").expect("create commit_metrics table"); + // 仪表盘全局快启应用(无项目 FK 约束) conn.execute_batch(" CREATE TABLE IF NOT EXISTS global_tools (