From b7987e160f6eb987baf399d64b054bd0dc25070b Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 2 Oct 2026 18:49:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(blueprint/buff):=20files=20=E9=87=8D?= =?UTF-8?q?=E5=8F=A0=E6=8E=A8=E6=96=AD=E9=99=8D=E4=B8=BA=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=89=B9=E5=B7=B2=E6=9C=89=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=8E=A8=E8=BF=9B=E5=88=99=E4=B8=8D=E6=8E=A8=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两阶段锁下真正在做的卡提交落地时已是 🔵/✅,files 重叠只能碰到仍为 📋 的卡, 命中共享热点文件(mcp_tools.rs / onboarding.rs 等)的多是未开工卡。 回放 master 240 个 first-parent 提交:旧算法命中 50 次,明确正确仅 3 次; 新规则 50 → 5,PR #24/#25/#26 对 P6/P7/P8/A4 的误标全部消除。 - 本批蓝图 diff 新增 🔵/✅/🔴 标题行(非原样搬移)→ 归属已显式,不做 files 推断 - last_commit 非 HEAD 祖先(checkout/reset/rebase)→ 跳过;git 出错记日志 - 同一批次全程使用轮询读到的 HEAD sha,判定口径一致 - 匹配修正:去掉反向后缀、后缀须落在 / 边界、尾逗号空项丢弃、「、」「,」作分隔 - 蓝图同步提示词:核实仅共用文件的 🔵 可降回 📋 并写明原因 - 补 buff.rs 首批测试(纯函数 + 临时 git 仓库端到端) Rules-Applied: R09 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/blueprint.rs | 5 +- src-tauri/src/commands/buff.rs | 329 ++++++++++++++++++++++++++-- 2 files changed, 317 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/blueprint.rs b/src-tauri/src/commands/blueprint.rs index bea7f63..d529b0b 100644 --- a/src-tauri/src/commands/blueprint.rs +++ b/src-tauri/src/commands/blueprint.rs @@ -1062,8 +1062,9 @@ fn build_sync_prompt( 3. 将不再存在的功能标记为 abandoned 或删除\n\ 4. 更新模块的 status 和 progress 使其与任务卡状态一致\n\ 5. 更新 manifest.yaml 的 updated 日期为今天\n\n\ - ⚠️ 状态保护规则:任务卡中 🔵 in_progress 状态由炼境根据 git 提交自动标记,代表该任务的关联文件已有实际改动。\ - 同步时请将其视为有效进度信号——可根据代码完成度升为 ✅ done,但无充分理由不要降回 📋 todo。" + ⚠️ 状态保护规则:任务卡中 🔵 in_progress 来自 agent 开工加锁,或炼境在未见显式加锁的提交中按 files 重叠推断标记。\ + 同步时默认保留,可根据代码完成度升为 ✅ done;仅当核实该卡并无对应改动(只是与其他卡共用文件)时才降回 📋 todo,\ + 并在提交说明中写明原因。" }; let output_hint = if no_blueprint { diff --git a/src-tauri/src/commands/buff.rs b/src-tauri/src/commands/buff.rs index 2202fd3..e4d71b1 100644 --- a/src-tauri/src/commands/buff.rs +++ b/src-tauri/src/commands/buff.rs @@ -176,12 +176,8 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> { } } - // 文件路径匹配 → 标记命中的任务卡 🔵 - 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; - } + // 文件路径匹配 → 兜底标记命中的任务卡 🔵(本批已显式推进 / HEAD 非前进时不推断) + let tasks_updated = infer_task_marks(root, last_commit.as_deref(), ¤t_commit)? > 0; // 有新 commit 即刷新飞轮快照(rework_count 可能已变);任务卡有变动则通知前端重绘 let _ = crate::commands::blueprint::get_blueprint(project_path.to_string()); @@ -250,14 +246,19 @@ fn get_head_commit(root: &Path) -> Result { } } -fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result, String> { +/// head 传轮询时读到的 HEAD sha(而非字面量 "HEAD"),保证同一批次各项判定口径一致。 +fn get_changed_files( + root: &Path, + last_commit: Option<&str>, + head: &str, +) -> Result, String> { let path_str = root.to_string_lossy(); let out = if is_wsl_path(&path_str) { let linux = unc_to_linux(&path_str).ok_or_else(|| "无法转换 WSL 路径".to_string())?; let cmd = if let Some(prev) = last_commit { - format!("git -C {linux} diff --name-only {prev} HEAD") + format!("git -C {linux} diff --name-only {prev} {head}") } else { - format!("git -C {linux} diff-tree --no-commit-id -r --name-only HEAD") + format!("git -C {linux} diff-tree --no-commit-id -r --name-only {head}") }; crate::silent_cmd("wsl") .args(["bash", "-c", &cmd]) @@ -265,9 +266,9 @@ fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result = if let Some(prev) = last_commit { - vec!["diff", "--name-only", prev, "HEAD"] + vec!["diff", "--name-only", prev, head] } else { - vec!["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"] + vec!["diff-tree", "--no-commit-id", "-r", "--name-only", head] }; crate::silent_cmd("git") .args(&args) @@ -287,6 +288,59 @@ fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result bool { + let out = crate::silent_cmd("git") + .args(["merge-base", "--is-ancestor", prev, head]) + .current_dir(root) + .output(); + match out.as_ref().map(|o| o.status.code()) { + Ok(Some(0)) => true, + Ok(Some(1)) => false, + _ => { + let detail = match &out { + Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), + Err(e) => e.to_string(), + }; + crate::db::log_event( + "buff", + "warn", + &format!("祖先判定失败,本批跳过任务卡推断: {detail}"), + Some(&root.to_string_lossy()), + ); + false + } + } +} + +/// 本批提交对 .blueprint/modules 的 patch 文本;git 失败返回 None。 +fn get_blueprint_patch(root: &Path, last_commit: Option<&str>, head: &str) -> Option { + let mut cmd = crate::silent_cmd("git"); + match last_commit { + Some(prev) => cmd.args(["diff", "--no-color", "--no-ext-diff", prev, head]), + None => cmd.args([ + "diff-tree", + "-p", + "--no-color", + "--no-commit-id", + "--root", + head, + ]), + }; + let out = cmd + .args(["--", ".blueprint/modules"]) + .current_dir(root) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).into_owned()) +} + // ── 辅助:新增 commit 解析(飞轮 L0)───────────────────────────────────────── /// 首次接入时只解析最近这么多条 commit,避免一次性解析整个历史。 @@ -310,6 +364,53 @@ fn get_new_commits( // ── 辅助:任务状态更新 ──────────────────────────────────────────────────────── +/// files 重叠推断只作兜底:head 由 last_commit 前进而来、且本批提交没有显式推进任何任务卡时, +/// 才把 files 命中的 📋 卡标 🔵。返回被标记的卡数。 +/// +/// 为什么是兜底:agent 按两阶段锁开工前已把在做的卡标 🔵,提交落地时仍是 📋 的卡多半没在做, +/// files 重叠(尤其共享热点文件)命中的恰是它们——2026-10-02 回放 master 历史 50 次命中仅 3 次明确正确。 +/// 本批已有卡被推进说明归属已显式,不再猜;整批无推进才视为「agent 忘了加锁」。 +fn infer_task_marks(root: &Path, last_commit: Option<&str>, head: &str) -> Result { + if let Some(prev) = last_commit { + if !is_ancestor(root, prev, head) { + return Ok(0); + } + } + let changed = get_changed_files(root, last_commit, head)?; + if changed.is_empty() { + return Ok(0); + } + match get_blueprint_patch(root, last_commit, head) { + Some(patch) if !has_explicit_advance(&patch) => update_task_statuses(root, &changed), + _ => Ok(0), + } +} + +/// 蓝图 patch 中是否有任务卡被显式推进:新增了 `### 🔵` / `### ✅` / `### 🔴` 标题行, +/// 且不是同一行的原样搬移(删除侧有完全相同的行视为重排,不算推进)。 +/// 🔴 也算:agent 把卡标为受阻,说明它正在做那张卡,归属同样已显式。 +fn has_explicit_advance(patch: &str) -> bool { + let is_advanced = |l: &str| { + ["### 🔵 ", "### ✅ ", "### 🔴 "] + .iter() + .any(|p| l.starts_with(p)) + }; + let mut added = Vec::new(); + let mut removed = std::collections::HashSet::new(); + for line in patch.lines().map(|l| l.trim_end_matches('\r')) { + if let Some(rest) = line.strip_prefix('+') { + if is_advanced(rest) { + added.push(rest); + } + } else if let Some(rest) = line.strip_prefix('-') { + if is_advanced(rest) { + removed.insert(rest); + } + } + } + added.iter().any(|l| !removed.contains(l)) +} + fn update_task_statuses(root: &Path, changed_files: &[String]) -> Result { let modules_dir = root.join(".blueprint").join("modules"); if !modules_dir.exists() { @@ -367,13 +468,11 @@ fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, u } if let Some(fv) = files_value { - let task_files: Vec = fv.split(',').map(|s| normalize_path(s.trim())).collect(); + let task_files = parse_task_files(&fv); let hit = changed_files.iter().any(|cf| { let ncf = normalize_path(cf); - task_files.iter().any(|tf| { - tf == &ncf || ncf.ends_with(tf.as_str()) || tf.ends_with(ncf.as_str()) - }) + task_files.iter().any(|tf| file_matches(tf, &ncf)) }); if hit { @@ -397,6 +496,24 @@ fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, u (result, updated) } +/// 解析任务卡 files 字段:逗号、顿号、全角逗号均作分隔;空项丢弃(尾逗号产生的空串会命中一切改动)。 +fn parse_task_files(value: &str) -> Vec { + value + .split([',', '、', ',']) + .map(|s| normalize_path(s.trim())) + .filter(|s| !s.is_empty()) + .collect() +} + +/// 任务卡路径与改动路径是否匹配:完全相同,或任务卡写的是路径后缀(须落在 `/` 边界)。 +/// 不做反向后缀(任务卡路径更长)——它曾让未拆开的「a.rs、b.rs」整串命中 b.rs。 +fn file_matches(task_file: &str, changed: &str) -> bool { + changed == task_file + || changed + .strip_suffix(task_file) + .is_some_and(|prefix| prefix.ends_with('/')) +} + fn normalize_path(p: &str) -> String { p.trim_start_matches("./").replace('\\', "/").to_string() } @@ -435,3 +552,185 @@ fn query_buff(conn: &rusqlite::Connection, path: &str) -> Result String { + let status = if emoji == "📋" { + "todo" + } else { + "in_progress" + }; + format!("### {emoji} {title}\n- status: {status}\n- files: {files}\n- acceptance: x\n\n") + } + + fn changed(files: &[&str]) -> Vec { + files.iter().map(|s| s.to_string()).collect() + } + + // ── 匹配规则 ── + + #[test] + fn exact_match_marks_todo_card() { + let md = format!( + "## 任务卡\n\n{}", + card("📋", "P8 — x", "src/a.rs, src/b.rs") + ); + let (out, n) = mark_tasks_in_progress(&md, &changed(&["src/b.rs"])); + assert_eq!(n, 1); + assert!(out.contains("### 🔵 P8 — x")); + assert!(out.contains("- status: in_progress")); + assert!(out.ends_with('\n'), "保留末尾换行"); + } + + #[test] + fn non_todo_cards_untouched() { + let md = card("✅", "P1", "src/a.rs") + &card("🔵", "P2", "src/a.rs"); + let (out, n) = mark_tasks_in_progress(&md, &changed(&["src/a.rs"])); + assert_eq!((n, out.as_str()), (0, md.as_str())); + } + + #[test] + fn longer_task_path_does_not_match_shorter_change() { + // 反向后缀已移除:任务卡路径更长(如别的仓库的绝对路径)不命中 + let md = card("📋", "P3-E", "E:/other-repo/deploy/README.md"); + let (_, n) = mark_tasks_in_progress(&md, &changed(&["deploy/README.md"])); + assert_eq!(n, 0); + } + + #[test] + fn suffix_match_requires_path_boundary() { + assert!(file_matches("lib.rs", "src/lib.rs")); + assert!(file_matches( + "commands/buff.rs", + "src-tauri/src/commands/buff.rs" + )); + assert!(!file_matches("lib.rs", "src/mylib.rs")); + assert!(!file_matches("src/a.rs", "src/a.rs.bak")); + } + + #[test] + fn trailing_comma_empty_entry_matches_nothing() { + let md = card("📋", "P5", "src/a.rs, "); + let (_, n) = mark_tasks_in_progress(&md, &changed(&["unrelated.txt"])); + assert_eq!(n, 0); + } + + #[test] + fn chinese_separators_split_entries() { + assert_eq!( + parse_task_files("a.rs、 b.rs,c.rs, ./d.rs"), + vec!["a.rs", "b.rs", "c.rs", "d.rs"] + ); + // 2026-10-02 实例:A4 的 files 用「、」分隔,旧逻辑整串靠反向后缀误命中 + let md = card( + "📋", + "A4", + "src-tauri/src/mcp_server.rs、src-tauri/src/mcp_tools.rs", + ); + let (_, n) = mark_tasks_in_progress(&md, &changed(&["src-tauri/src/mcp_server.rs"])); + assert_eq!(n, 1); + } + + // ── 显式推进判定 ── + + #[test] + fn added_in_progress_or_done_heading_is_explicit_advance() { + let patch = "--- a/.blueprint/modules/m.md\n+++ b/.blueprint/modules/m.md\n\ + @@ -1,2 +1,2 @@\n-### 📋 P1 — x\n+### 🔵 P1 — x\n"; + assert!(has_explicit_advance(patch)); + assert!(has_explicit_advance("+### ✅ P9 — y\r\n")); + assert!( + has_explicit_advance("-### 🔵 P2 — z\n+### 🔴 P2 — z\n"), + "标为受阻同样是显式归因" + ); + } + + #[test] + fn todo_edits_and_moved_headings_are_not_advance() { + assert!(!has_explicit_advance( + "-### 📋 P8 — x\n+### 📋 P8 — x(改标题)\n" + )); + // 卡片整段搬移:同一行一删一增 + assert!(!has_explicit_advance("-### ✅ P1 — x\n+### ✅ P1 — x\n")); + assert!(!has_explicit_advance("")); + } + + // ── 端到端:临时 git 仓库 ── + + fn git(dir: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + ]) + .args(args) + .current_dir(dir) + .output() + .expect("git 可执行"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + fn commit_all(dir: &Path, msg: &str) -> String { + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "--no-verify", "-m", msg]); + git(dir, &["rev-parse", "HEAD"]) + } + + #[test] + fn infer_only_as_fallback_and_only_when_head_advances() { + let dir = std::env::temp_dir().join(format!("buff_infer_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let modules = dir.join(".blueprint").join("modules"); + std::fs::create_dir_all(&modules).unwrap(); + let md_path = modules.join("m.md"); + let md = + |p1: &str| card(p1, "P1 — 在做", "src/a.rs") + &card("📋", "P8 — 未开工", "src/a.rs"); + let read_md = || std::fs::read_to_string(&md_path).unwrap(); + + git(&dir, &["init", "-q"]); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write(dir.join("src/a.rs"), "1").unwrap(); + std::fs::write(&md_path, md("📋")).unwrap(); + let c1 = commit_all(&dir, "init"); + + // ① 本批 agent 已显式把 P1 标 🔵 → 共用文件的 P8 不被推断 + std::fs::write(dir.join("src/a.rs"), "2").unwrap(); + std::fs::write(&md_path, md("🔵")).unwrap(); + let c2 = commit_all(&dir, "feat: P1"); + assert_eq!(infer_task_marks(&dir, Some(&c1), &c2).unwrap(), 0); + assert!(read_md().contains("### 📋 P8")); + + // ② 本批无任何显式推进(agent 忘了加锁)→ files 重叠兜底标 🔵 + std::fs::write(dir.join("src/a.rs"), "3").unwrap(); + let c3 = commit_all(&dir, "fix: a"); + assert_eq!(infer_task_marks(&dir, Some(&c2), &c3).unwrap(), 1); + assert!(read_md().contains("### 🔵 P8")); + git(&dir, &["checkout", "--", "."]); + + // ⑦ checkout 到不含 last_commit 的分支:HEAD 非前进,不推断 + git(&dir, &["checkout", "-q", "-b", "side", &c1]); + std::fs::write(dir.join("src/a.rs"), "side").unwrap(); + let c4 = commit_all(&dir, "side"); + assert_eq!(infer_task_marks(&dir, Some(&c3), &c4).unwrap(), 0); + assert!(read_md().contains("### 📋 P8")); + + // 首次接入(无 last_commit):只看 HEAD 单个提交,无显式推进 → 兜底生效 + assert_eq!(infer_task_marks(&dir, None, &c4).unwrap(), 2); + + let _ = std::fs::remove_dir_all(&dir); + } +}