diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 8493f46..016d2bf 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -306,7 +306,7 @@ modules: area: backend status: in_progress progress: 92 - note: "L0+F1+F2+R1+R2 完成(2026-07-02,rule_effectiveness 死链已接通);阶段三待数据覆盖 ≥10 项目" + note: "阶段三已过 architect+splitter(2026-07-04):P3-A~E 五卡待执行;P0 发现 R1 trailer 管道死链(%s 只取 subject),P3-A 修复" position: [1750, 650] - id: cloud-products diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 1ffe8cb..6e3fa98 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -144,20 +144,48 @@ > 背景:"失败定边界、经验细化为变体"是经验飞轮的因果自洽核心,当前规则库只有正向 scope,无反例结构。 > 完成记录(2026-07-02):CONVENTIONS 升 v1.9.0,新增 boundaries/failure_cases 字段说明 + R11 + abandoned 模块状态定义;向其他项目的分发随下次发版生效(CONVENTIONS 编译进二进制)。 -### 阶段三:执行质量预测层 +### 阶段三:执行质量预测层(已过 /architect + /splitter,2026-07-04) + +> 触发条件已达标:commit_metrics 覆盖 17 项目 / 735 commit(459 conventional)。 +> 设计裁决:规则置信度闭环 + 风险预警本期做;时长分布(无任务时间戳)与模式库推荐(笔记进料枯竭)缓做。 +> 预测消费端 = MCP 工具 `predict_task_risk`(agent 开工前查询),梦核数据段为辅——预测给干活的 agent 用,不是给看板前的人看。 +> **P0 侦察发现**:R1 trailer 管道是死链——git log 用 `%s` 只取 subject,Rules-Applied trailer 在 body 里永远读不到(炼境自身 11 条带 trailer 的 commit,DB rules_applied 全空);单测喂全文所以全绿。P3-A 修复。 + +### ✅ P3-A. trailer 链路修复 + UPSERT 回填 +- status: done +- done_note: 2026-07-04 完成。GIT_LOG_FMT 加 %(trailers:key=Rules-Applied,valueonly)(git 原生 trailer 解析,保持单行记录);record_commit_entry + upsert_metrics(ON CONFLICT 仅回填 NULL,绝不覆盖);buff.rs get_new_commits 委托 commit_metrics::read_git_log(消灭重复 FMT + 顺手清理该函数 WSL 分支);ingest_full_git_history 改走 record_commit_entry(消灭第三处重复 INSERT)。测试:真 git 仓库 e2e trailer 提取回归 + UPSERT 四态语义,82 测试全绿。存量回填待新 build 重启后跑全量导入终验 +- complexity: M +- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs +- acceptance: "[cargo test] read_git_log 用 %(trailers:key=Rules-Applied,valueonly) 提取 trailer,record_commit 改 UPSERT(仅补 rules_applied IS NULL 的行,不覆盖已有值)单测覆盖;[Tauri command] 对炼境自身重跑 ingest_full_git_history → commit_metrics 中 rules_applied 非空行 ≥10,rule_hit_stats 返回非空" +- notes: buff.rs 与 commit_metrics.rs 两处重复的 FMT 常量统一为 commit_metrics::read_git_log 单一来源;git 原生 trailer 解析保持单行一条记录,避免 %B 多行解析 + +### 📋 P3-B. commit_files 采集(增量 + 全量) - status: todo -- complexity: L -- depends: flywheel-intelligence(阶段二完成且数据积累达标后启动) -- files: TBD(需 /architect 后确定) -- acceptance: TBD +- 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 +- acceptance: "[cargo test] 新表 commit_files(sha,path 联合主键) migration 到位(R05),git log --name-only 解析纯函数单测覆盖多 commit 多文件;[Tauri command] ingest_full_git_history 对炼境自身重跑 → commit_files 行数 >0 且重跑幂等(行数不变)" -阶段三方向(待 /architect 设计): -- 任务完成时长分布(为 S/M/L 定义时间基准) -- 高风险任务预警(特定文件组合 → 历史高返工率) -- 跨项目模式库推荐 -- **自动推荐规则置信度更新**:梦核读取复盘笔记中的 `auto_applied_rules` + `rule_effectiveness` 字段,输出 CONVENTIONS 规则库的 confidence/evidence_count 更新建议;新增规则提案(从高频踩坑模式自动归纳) +### 📋 P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具 +- status: todo +- complexity: M +- depends: flywheel-intelligence(卡 P3-B) +- files: src-tauri/src/commands/risk.rs, src-tauri/src/commands/mod.rs, src-tauri/src/mcp_tools.rs +- acceptance: "[MCP tool] predict_task_risk(project_id, scope 或 files) → JSON 含 risk_level/rework_rate/sample_size/confidence/hot_files,无 error 字段;sample_size<5 时 confidence=low;[cargo test] 风险聚合纯函数(conn 注入 + in-memory DB)覆盖高返工 scope、小样本、脏历史(conventional 率低标不可信)三种输入;tools_list_result 含 predict_task_risk 断言" -> ⚠️ 阶段三为 L 级复杂度,到时须先走 /architect → /splitter 流程,禁止直接写代码。 +### 📋 P3-D. 梦核 prompt 高风险 scope 数据段 +- status: todo +- complexity: S +- depends: flywheel-intelligence(卡 P3-C) +- files: src-tauri/src/commands/blueprint.rs +- acceptance: "[cargo test] 梦核 prompt 构建函数单测断言输出含「跨项目高风险 scope」数据段(返工率 top scope + 项目分布);规则置信度段在 P3-A 数据流入后自然激活(注入点 blueprint.rs rule_hit_stats 已存在)" + +### 📋 P3-E. FlywheelPanel 风险展示 + commands.ts 同步(可选收尾) +- status: todo +- complexity: S +- depends: flywheel-intelligence(卡 P3-C) +- files: src/lib/commands.ts, src/components/dashboard/BlueprintModal.tsx +- acceptance: "[pnpm typecheck] exit 0;FlywheelPanel「Git 执行质量」卡片新增各项目返工率 top scope 列(get_scope_risk_profile 封装同步,R01)" ## 决策记录 diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs index 524638c..cd998d0 100644 --- a/src-tauri/src/commands/buff.rs +++ b/src-tauri/src/commands/buff.rs @@ -160,14 +160,8 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> { } // 批量写入用事务包裹(首次可达数十条,避免逐条 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()), - ); + for entry in &commits { + let _ = crate::commands::commit_metrics::record_commit_entry(&conn, &project_id, entry); } if let Ok(tx) = tx { let _ = tx.commit(); @@ -292,57 +286,20 @@ 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() +/// 取数统一走 commit_metrics::read_git_log(单一 format 来源,trailer 不再丢失)。 +/// WSL 路径分支已随 2026-07-04「放弃 WSL 路径项目」裁决清理。 +fn get_new_commits( + root: &Path, + last_commit: Option<&str>, +) -> Vec { + use crate::commands::commit_metrics::{read_git_log, GitLogRange}; + let range = match last_commit { + Some(prev) => GitLogRange::Since(prev), + None => GitLogRange::Limit(FIRST_TIME_LIMIT), }; - - 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() + read_git_log(root, range).unwrap_or_default() } // ── 辅助:任务状态更新 ──────────────────────────────────────────────────────── diff --git a/src-tauri/src/commands/commit_metrics.rs b/src-tauri/src/commands/commit_metrics.rs index fe8a993..4b4595e 100644 --- a/src-tauri/src/commands/commit_metrics.rs +++ b/src-tauri/src/commands/commit_metrics.rs @@ -75,6 +75,21 @@ fn non_conventional(is_ci_auto: bool) -> ParsedCommit { } } +/// 把 trailer 原始值(如 "R01, r06 R01")归一化为大写去重逗号串。 +/// 只接受 R+数字 形式的 token;无有效 token 返回 None。 +pub fn normalize_rule_tokens(raw: &str) -> Option { + let mut ids: Vec = raw + .split([',', ' ', ',']) + .map(|t| t.trim().to_uppercase()) + .filter(|t| { + t.len() >= 2 && t.starts_with('R') && t[1..].chars().all(|c| c.is_ascii_digit()) + }) + .collect(); + ids.sort(); + ids.dedup(); + if ids.is_empty() { None } else { Some(ids.join(",")) } +} + /// 解析 commit message 中的 `Rules-Applied: R01,R06` trailer(飞轮 R1 规则有效性数据源)。 /// 只接受 R+数字 形式的 token,规范化为大写去重逗号串;无 trailer 或无有效 token 返回 None。 pub fn parse_rules_applied(message: &str) -> Option { @@ -87,24 +102,47 @@ pub fn parse_rules_applied(message: &str) -> Option { else { continue; }; - let rest = rest.trim(); - let mut ids: Vec = rest - .split([',', ' ', ',']) - .map(|t| t.trim().to_uppercase()) - .filter(|t| { - t.len() >= 2 - && t.starts_with('R') - && t[1..].chars().all(|c| c.is_ascii_digit()) - }) - .collect(); - ids.sort(); - ids.dedup(); - return if ids.is_empty() { None } else { Some(ids.join(",")) }; + return normalize_rule_tokens(rest); } None } -/// 解析并写入一条 commit 记录(sha 为主键,重复写入忽略)。 +/// UPSERT 写入一条 commit 记录:新 sha 插入;已存在且 rules_applied 为空时仅回填 +/// rules_applied(绝不覆盖已有值——回填幂等的关键约束)。返回是否产生了写入。 +fn upsert_metrics( + conn: &Connection, + project_id: &str, + sha: &str, + parsed: &ParsedCommit, + rules_applied: Option<&str>, + committed_at: Option<&str>, +) -> Result { + let n = conn + .execute( + "INSERT INTO commit_metrics + (sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at, rules_applied) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(sha) DO UPDATE SET rules_applied = excluded.rules_applied + WHERE commit_metrics.rules_applied IS NULL + AND excluded.rules_applied IS NOT NULL", + rusqlite::params![ + sha, + project_id, + parsed.commit_type, + parsed.scope, + parsed.is_rework as i32, + parsed.is_ci_auto as i32, + committed_at, + rules_applied, + ], + ) + .map_err(|e| e.to_string())?; + Ok(n > 0) +} + +/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer)。 +/// 生产链路的 git log 只给 subject,trailer 走 `record_commit_entry`;本函数保留给 +/// 持有完整 message 的调用方与测试。 pub fn record_commit( conn: &Connection, project_id: &str, @@ -114,25 +152,26 @@ pub fn record_commit( ) -> Result<(), String> { let p = parse_conventional(message); let rules_applied = parse_rules_applied(message); - conn.execute( - "INSERT OR IGNORE INTO commit_metrics - (sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at, rules_applied) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - rusqlite::params![ - sha, - project_id, - p.commit_type, - p.scope, - p.is_rework as i32, - p.is_ci_auto as i32, - committed_at, - rules_applied, - ], - ) - .map_err(|e| e.to_string())?; + upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?; Ok(()) } +/// 写入一条来自 `read_git_log` 的记录(subject 与 trailer 分列,trailer 由 git 原生解析)。 +pub fn record_commit_entry( + conn: &Connection, + project_id: &str, + entry: &GitLogEntry, +) -> Result { + let p = parse_conventional(&entry.subject); + let rules = normalize_rule_tokens(&entry.rules_trailer); + let date = if entry.committed_at.is_empty() { + None + } else { + Some(entry.committed_at.as_str()) + }; + upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date) +} + /// 单条规则的命中统计(跨项目聚合) #[derive(Debug, Serialize, Deserialize, Clone)] pub struct RuleHitStat { @@ -248,17 +287,63 @@ const FULL_HISTORY_LIMIT: usize = 500; /// 全量历史补录结果。 #[derive(Debug, Serialize)] pub struct IngestResult { - /// 本次新写入 commit 数 + /// 本次产生写入的 commit 数(新插入 + rules_applied 回填) pub imported: u32, - /// 已存在(INSERT OR IGNORE 跳过) + /// 已存在且无需回填(跳过) pub skipped: u32, } -/// 读取 git log 最近 limit 条:返回 (sha, subject, committed_at_iso)。 -fn read_git_log(root: &std::path::Path, limit: usize) -> Result, String> { - const FMT: &str = "--format=%H%x1f%s%x1f%cI"; - let out = crate::silent_cmd("git") - .args(["log", "-n", &limit.to_string(), FMT]) +/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值 +/// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到, +/// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitLogEntry { + pub sha: String, + pub subject: String, + pub committed_at: String, + pub rules_trailer: String, +} + +/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD(增量 watcher)。 +pub enum GitLogRange<'a> { + Limit(usize), + Since(&'a str), +} + +/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值(逗号分隔),每条一行。 +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 { + let mut parts = line.split('\u{1f}'); + let sha = parts.next()?.trim().to_string(); + if sha.is_empty() { + return None; + } + Some(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(), + }) +} + +/// 读取 git log(含 Rules-Applied trailer)。增量与全量两条链路的单一取数入口, +/// buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。 +pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result, String> { + let mut cmd = crate::silent_cmd("git"); + cmd.arg("log"); + match range { + GitLogRange::Limit(n) => { + cmd.args(["-n", &n.to_string()]); + } + GitLogRange::Since(prev) => { + cmd.arg(format!("{prev}..HEAD")); + } + } + let out = cmd + .arg(GIT_LOG_FMT) .current_dir(root) .output() .map_err(|e| format!("git log 失败: {e}"))?; @@ -267,14 +352,7 @@ fn read_git_log(root: &std::path::Path, limit: usize) -> Result GitLogEntry { + GitLogEntry { + sha: sha.into(), + subject: subject.into(), + committed_at: "2026-07-04T00:00:00+09:00".into(), + rules_trailer: trailer.into(), + } + } + + fn rules_of(conn: &rusqlite::Connection, sha: &str) -> Option { + conn.query_row( + "SELECT rules_applied FROM commit_metrics WHERE sha = ?1", + [sha], + |r| r.get(0), + ) + .unwrap() + } + + #[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"); + + 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()); + } + + #[test] + fn normalize_rule_tokens_filters_and_dedups() { + assert_eq!(normalize_rule_tokens("R01, r06 R01").as_deref(), Some("R01,R06")); + assert_eq!(normalize_rule_tokens("无效, abc"), None); + assert_eq!(normalize_rule_tokens(""), None); + } + + #[test] + fn upsert_backfills_null_rules_only() { + let conn = crate::db::conn_with_schema(); + // 模拟存量行:%s 时代写入,rules_applied 为 NULL + record_commit(&conn, "proj", "old", "feat(a): x", None).unwrap(); + assert_eq!(rules_of(&conn, "old"), None); + + // 回填:同 sha 带 trailer 重写 → 补上 + let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06")).unwrap(); + assert!(changed, "回填应计为写入"); + assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06")); + + // 不覆盖:已有值的行,不同 trailer 重写 → 保持原值 + let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99")).unwrap(); + assert!(!changed, "已有值不应被覆盖"); + assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06")); + + // 无 trailer 重写已有 NULL 行 → 不产生无意义 UPDATE + record_commit(&conn, "proj", "old2", "chore: y", None).unwrap(); + let changed = record_commit_entry(&conn, "proj", &entry("old2", "chore: y", "")).unwrap(); + assert!(!changed); + + // 新行带 trailer 直接写入 + assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05")).unwrap()); + assert_eq!(rules_of(&conn, "new").as_deref(), Some("R05")); + } + + /// 端到端:真 git 仓库验证 %(trailers) 提取——这是 R1 死链(%s 只取 subject)的回归测试。 + #[test] + fn read_git_log_extracts_trailer_from_real_repo() { + let dir = std::env::temp_dir().join(format!("lj-trailer-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .args(args) + .current_dir(&dir) + .output() + .expect("git 可执行"); + assert!(out.status.success(), "git {:?} 失败: {}", args, String::from_utf8_lossy(&out.stderr)); + }; + 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", + "-m", "fix(b): with trailer", "-m", "Rules-Applied: R01,R06"]); + + let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(entries.len(), 2); + // git log 逆序:最新在前 + assert_eq!(entries[0].subject, "fix(b): with trailer"); + assert_eq!(entries[0].rules_trailer, "R01,R06", "trailer 必须被提取(%s 死链回归)"); + assert_eq!(entries[1].rules_trailer, ""); + } + #[test] fn conventional_stats_counts_ratio() { let conn = crate::db::conn_with_schema();