dev-manager-tauri/src-tauri/src/commands/buff.rs
lanrtop 44f7f667a6
All checks were successful
Push & PR Check / check (push) Successful in 1m1s
fix(blueprint-buff): 跟进维护既有蓝图内容也算归属已显式,不再按 files 重叠误标(B2)
2026-10-03 主仓库快进到 19b9388(PR #31 只补记 ✅ P7 的 known_limits,无标题
变化)后,P6/P8(onboarding.rs)与 A4(mcp_server.rs)再次被误标 🔵:B1 只把
「新增 🔵/✅/🔴 标题行」当显式推进,跟进已完成卡被判为无推进而走兜底。

- infer_task_marks 增加 edits_existing_lines:本批 .blueprint/modules patch 在
  hunk 内有 `-` 行(删改既有内容)即视为 agent 在维护蓝图,不推断
- 纯追加(新立 📋 卡、新模块)仍走兜底,保留「立卡即实现、忘了加锁」的捕获
- 新增单测与端到端 ⑧⑨;变异(去掉条件)确认 ⑧ 失败;回放 master 260 批次,
  B2 新增抑制 27 批,含代码改动 6 批
- 蓝图 blueprint-buff 立 B2 卡并标 ✅

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:30:38 +09:00

798 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::AppHandle;
// ── 数据结构 ─────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BuffStatus {
pub project_path: String,
pub project_id: String,
pub applied_at: String,
pub last_sync_at: Option<String>,
pub last_commit: Option<String>,
pub status: String,
}
// ── Tauri 命令 ───────────────────────────────────────────────────────────────
/// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB
// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)]
pub fn apply_blueprint_buff(
project_path: String,
project_id: String,
) -> Result<BuffStatus, String> {
let path = project_path.trim_end_matches(['/', '\\']).to_string();
// 注入蓝图规则(CONVENTIONS.md + CLAUDE.md)
crate::commands::blueprint::sync_blueprint_rules(path.clone())
.map_err(|e| format!("注入蓝图规则失败: {e}"))?;
// 独立项目自动创建产品组(含 MCP 注入)
if let Err(e) = crate::commands::groups::ensure_standalone_group(&project_id) {
// 非致命,不阻断 Buff 应用
crate::db::log_event("buff", "warn", &format!("独立产品组创建失败: {e}"), None);
}
// 写入 DB
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO blueprint_buffs
(project_path, project_id, applied_at, status)
VALUES (?1, ?2, datetime('now'), 'active')",
rusqlite::params![path, project_id],
)
.map_err(|e| e.to_string())?;
query_buff(&conn, &path)
}
/// 撤销蓝图 Buff
#[tauri::command]
pub fn remove_blueprint_buff(project_path: String) -> Result<(), String> {
let path = project_path.trim_end_matches(['/', '\\']).to_string();
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM blueprint_buffs WHERE project_path = ?1",
rusqlite::params![path],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// 查询单个项目的 Buff 状态
#[tauri::command]
pub fn get_buff_status(project_path: String) -> Result<Option<BuffStatus>, String> {
let path = project_path.trim_end_matches(['/', '\\']).to_string();
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
match query_buff(&conn, &path) {
Ok(s) => Ok(Some(s)),
Err(_) => Ok(None),
}
}
/// 列出所有已施加 Buff 的项目
#[tauri::command]
pub fn list_buffed_projects() -> Result<Vec<BuffStatus>, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT project_path, project_id, applied_at, last_sync_at, last_commit, status
FROM blueprint_buffs WHERE status = 'active'",
)
.map_err(|e| e.to_string())?;
let items = stmt
.query_map([], |row| {
Ok(BuffStatus {
project_path: row.get(0)?,
project_id: row.get(1)?,
applied_at: row.get(2)?,
last_sync_at: row.get(3)?,
last_commit: row.get(4)?,
status: row.get(5)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(items)
}
// ── 后台 git 轮询(T3)──────────────────────────────────────────────────────
/// App 启动时调用,启动后台轮询任务(每 5 秒检查所有 buffed 项目的 git HEAD)
pub fn start_blueprint_watchers(app: AppHandle) {
tauri::async_runtime::spawn(async move {
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
poll_all_buffs(&app);
}
});
}
fn poll_all_buffs(app: &AppHandle) {
let paths = match list_active_paths() {
Ok(p) => p,
Err(_) => return,
};
for path in paths {
let _ = check_project(app, &path);
}
}
fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
let root = Path::new(project_path);
// 获取当前 HEAD commit
let current_commit = match get_head_commit(root) {
Ok(c) => c,
Err(_) => return Ok(()), // 不是 git 仓库或无提交,跳过
};
// 查询 project_id 与上次已处理的 commit
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let (project_id, last_commit): (String, Option<String>) = conn
.query_row(
"SELECT project_id, last_commit FROM blueprint_buffs WHERE project_path = ?1",
rusqlite::params![project_path],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap_or_else(|_| (String::new(), None));
// 没有变化,跳过
if last_commit.as_deref() == Some(current_commit.as_str()) {
return Ok(());
}
// 飞轮 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),
);
}
// 批量写入用事务包裹(首次可达数十条,避免逐条 fsync)
// U3:规则 ID 集合每批次加载一次,剔除 trailer 里的未知 ID
let valid_rules = crate::commands::commit_metrics::load_project_rule_ids(root);
let tx = conn.unchecked_transaction();
for entry in &commits {
let _ = crate::commands::commit_metrics::record_commit_entry(
&conn,
&project_id,
entry,
valid_rules.as_ref(),
);
}
if let Ok(tx) = tx {
let _ = tx.commit();
}
}
// 文件路径匹配 → 兜底标记命中的任务卡 🔵(本批已显式推进 / HEAD 非前进时不推断)
let tasks_updated = infer_task_marks(root, last_commit.as_deref(), &current_commit)? > 0;
// 有新 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 = datetime('now')
WHERE project_path = ?2",
rusqlite::params![current_commit, project_path],
);
Ok(())
}
// ── 辅助:WSL 路径检测与转换 ─────────────────────────────────────────────────
/// 判断路径是否为 WSL UNC 路径(\\wsl.localhost\... 或 \\wsl$\...)
fn is_wsl_path(path: &str) -> bool {
path.starts_with("\\\\wsl.localhost\\") || path.starts_with("\\\\wsl$\\")
}
/// 将 WSL UNC 路径转为 Linux 绝对路径(去掉 \\wsl.localhost\Distro 前缀)
fn unc_to_linux(path: &str) -> Option<String> {
let prefix = if path.starts_with("\\\\wsl.localhost\\") {
"\\\\wsl.localhost\\"
} else if path.starts_with("\\\\wsl$\\") {
"\\\\wsl$\\"
} else {
return None;
};
let rest = &path[prefix.len()..];
let slash_idx = rest.find('\\')?;
Some("/".to_string() + &rest[slash_idx + 1..].replace('\\', "/"))
}
// ── 辅助:git 操作 ────────────────────────────────────────────────────────────
fn get_head_commit(root: &Path) -> Result<String, 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())?;
crate::silent_cmd("wsl")
.args(["git", "-C", &linux, "rev-parse", "HEAD"])
.output()
.map_err(|e| e.to_string())?
} else {
crate::silent_cmd("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.map_err(|e| e.to_string())?
};
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err("无法获取 HEAD".to_string())
}
}
/// head 传轮询时读到的 HEAD sha(而非字面量 "HEAD"),保证同一批次各项判定口径一致。
fn get_changed_files(
root: &Path,
last_commit: Option<&str>,
head: &str,
) -> Result<Vec<String>, 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}")
} else {
format!("git -C {linux} diff-tree --no-commit-id -r --name-only {head}")
};
crate::silent_cmd("wsl")
.args(["bash", "-c", &cmd])
.output()
.map_err(|e| e.to_string())?
} else {
let args: Vec<&str> = if let Some(prev) = last_commit {
vec!["diff", "--name-only", prev, head]
} else {
vec!["diff-tree", "--no-commit-id", "-r", "--name-only", head]
};
crate::silent_cmd("git")
.args(&args)
.current_dir(root)
.output()
.map_err(|e| e.to_string())?
};
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
} else {
Ok(vec![])
}
}
// 以下两个 git 辅助不再做 WSL 分支(2026-07-04「放弃 WSL 路径项目」裁决);
// WSL 项目上二者失败 → 按「无法确认」处理,不做推断。
/// prev 是否为 head 的祖先。checkout / reset / rebase 会让 HEAD 非前进地变化,
/// 此时 prev..head 的 diff 不代表新提交。git 出错(如 prev 已不存在)记日志并按「否」处理。
fn is_ancestor(root: &Path, prev: &str, head: &str) -> 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<String> {
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,避免一次性解析整个历史。
const FIRST_TIME_LIMIT: usize = 50;
/// 获取 last_commit..HEAD 之间的新 commit(含 Rules-Applied trailer)。
/// 首次接入(last_commit 为 None)只取最近 FIRST_TIME_LIMIT 条。
/// 取数统一走 commit_metrics::read_git_log(单一 format 来源,trailer 不再丢失)。
/// WSL 路径分支已随 2026-07-04「放弃 WSL 路径项目」裁决清理。
fn get_new_commits(
root: &Path,
last_commit: Option<&str>,
) -> Vec<crate::commands::commit_metrics::GitLogEntry> {
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),
};
read_git_log(root, range).unwrap_or_default()
}
// ── 辅助:任务状态更新 ────────────────────────────────────────────────────────
/// files 重叠推断只作兜底:head 由 last_commit 前进而来、且本批提交没有显式推进任何任务卡时,
/// 才把 files 命中的 📋 卡标 🔵。返回被标记的卡数。
///
/// 为什么是兜底:agent 按两阶段锁开工前已把在做的卡标 🔵,提交落地时仍是 📋 的卡多半没在做,
/// files 重叠(尤其共享热点文件)命中的恰是它们——2026-10-02 回放 master 历史 50 次命中仅 3 次明确正确。
/// 本批已有卡被推进、或已有蓝图内容被删改(跟进维护,B2)说明归属已显式,不再猜;
/// 整批既无推进、也只是纯追加(或未碰蓝图)才视为「agent 忘了加锁」。
fn infer_task_marks(root: &Path, last_commit: Option<&str>, head: &str) -> Result<usize, String> {
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) && !edits_existing_lines(&patch) => {
update_task_statuses(root, &changed)
}
_ => Ok(0),
}
}
/// 蓝图 patch 是否删改了已有行(hunk 内出现 `-` 行),即 agent 本批在跟进维护既有蓝图内容,
/// 例如给已 ✅ 的卡补记审查修复。纯追加(新立 📋 卡、新模块)不算——那正是「立卡即实现、忘了加锁」的兜底场景。
/// 为什么(B2,2026-10-03):PR #31 只补记 ✅ P7 正文、无标题变化,B1 判为无推进,
/// 共用 onboarding.rs / mcp_server.rs 使 P6/P8/A4 再次被误标 🔵。
fn edits_existing_lines(patch: &str) -> bool {
let mut in_hunk = false;
for line in patch.lines() {
if line.starts_with("diff --git ") {
in_hunk = false;
} else if line.starts_with("@@") {
in_hunk = true;
} else if in_hunk && line.starts_with('-') {
return true;
}
}
false
}
/// 蓝图 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<usize, String> {
let modules_dir = root.join(".blueprint").join("modules");
if !modules_dir.exists() {
return Ok(0);
}
let mut total = 0;
for entry in std::fs::read_dir(&modules_dir)
.map_err(|e| e.to_string())?
.flatten()
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let (new_content, count) = mark_tasks_in_progress(&content, changed_files);
if count > 0 {
std::fs::write(&path, new_content).map_err(|e| e.to_string())?;
total += count;
}
}
Ok(total)
}
/// 扫描 markdown 内容,将 files 字段命中 changed_files 的 📋 todo 任务标记为 🔵 in_progress
fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, usize) {
let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let mut updated = 0;
let mut i = 0;
while i < lines.len() {
if !lines[i].starts_with("### 📋 ") {
i += 1;
continue;
}
// 扫描该任务卡的属性行(到下一个 ## 或 ### 为止,最多 20 行)
let task_head = i;
let mut files_value: Option<String> = None;
let mut status_idx: Option<usize> = None;
let end = (i + 1 + 20).min(lines.len());
for j in (i + 1)..end {
if j > i + 1 && (lines[j].starts_with("### ") || lines[j].starts_with("## ")) {
break;
}
let t = lines[j].trim().trim_start_matches("- ");
if let Some(v) = t.strip_prefix("files: ") {
files_value = Some(v.to_string());
}
if t.starts_with("status: ") {
status_idx = Some(j);
}
}
if let Some(fv) = files_value {
let task_files = parse_task_files(&fv);
let hit = changed_files.iter().any(|cf| {
let ncf = normalize_path(cf);
task_files.iter().any(|tf| file_matches(tf, &ncf))
});
if hit {
// 替换标题前缀
lines[task_head] = lines[task_head].replacen("### 📋 ", "### 🔵 ", 1);
// 替换 status 行
if let Some(si) = status_idx {
lines[si] = lines[si].replace("status: todo", "status: in_progress");
}
updated += 1;
}
}
i += 1;
}
let mut result = lines.join("\n");
if content.ends_with('\n') {
result.push('\n');
}
(result, updated)
}
/// 解析任务卡 files 字段:逗号、顿号、全角逗号均作分隔;空项丢弃(尾逗号产生的空串会命中一切改动)。
fn parse_task_files(value: &str) -> Vec<String> {
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()
}
// ── 内部辅助 ──────────────────────────────────────────────────────────────────
fn list_active_paths() -> Result<Vec<String>, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT project_path FROM blueprint_buffs WHERE status = 'active'")
.map_err(|e| e.to_string())?;
let paths = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(paths)
}
fn query_buff(conn: &rusqlite::Connection, path: &str) -> Result<BuffStatus, String> {
conn.query_row(
"SELECT project_path, project_id, applied_at, last_sync_at, last_commit, status
FROM blueprint_buffs WHERE project_path = ?1",
rusqlite::params![path],
|row| {
Ok(BuffStatus {
project_path: row.get(0)?,
project_id: row.get(1)?,
applied_at: row.get(2)?,
last_sync_at: row.get(3)?,
last_commit: row.get(4)?,
status: row.get(5)?,
})
},
)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn card(emoji: &str, title: &str, files: &str) -> 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<String> {
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(""));
}
#[test]
fn edits_existing_lines_only_counts_removed_lines_inside_hunks() {
// 跟进已完成卡:改写既有行(19b9388 补记 ✅ P7 正文)
let followup = "diff --git a/.blueprint/modules/m.md b/.blueprint/modules/m.md\n\
--- a/.blueprint/modules/m.md\n+++ b/.blueprint/modules/m.md\n\
@@ -3 +3 @@\n- known_limits: 旧\n+ known_limits: 旧;已补齐\n";
assert!(edits_existing_lines(followup));
// 纯追加(新立 📋 卡):只有文件头的 `---`,hunk 内无 `-` 行
let append = "diff --git a/.blueprint/modules/m.md b/.blueprint/modules/m.md\n\
--- a/.blueprint/modules/m.md\n+++ b/.blueprint/modules/m.md\n\
@@ -9,0 +10,2 @@\n+### 📋 P9 — 新卡\n+- status: todo\n";
assert!(!edits_existing_lines(append));
// 新增模块文件:`--- /dev/null` 在 hunk 外
let new_file = "diff --git a/.blueprint/modules/n.md b/.blueprint/modules/n.md\n\
new file mode 100644\n--- /dev/null\n+++ b/.blueprint/modules/n.md\n\
@@ -0,0 +1 @@\n+id: n\n";
assert!(!edits_existing_lines(new_file));
assert!(!edits_existing_lines(""));
}
// ── 端到端:临时 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", "--", "."]);
// ⑧ 跟进已推进的 P1(改写其既有行、无标题变化)+ 共用文件改动 → 归属已显式,不推断(B2)
std::fs::write(dir.join("src/a.rs"), "4").unwrap();
std::fs::write(
&md_path,
md("🔵").replacen("- acceptance: x\n", "- acceptance: x;审查修复\n", 1),
)
.unwrap();
let c5 = commit_all(&dir, "fix: P1 审查修复");
assert_eq!(infer_task_marks(&dir, Some(&c3), &c5).unwrap(), 0);
assert!(read_md().contains("### 📋 P8"));
// ⑨ 纯追加新 📋 卡 + 共用文件改动(立卡即实现、忘了加锁)→ 兜底仍生效
std::fs::write(dir.join("src/a.rs"), "5").unwrap();
let appended = read_md() + &card("📋", "P9 — 新卡", "src/a.rs");
std::fs::write(&md_path, appended).unwrap();
let c6 = commit_all(&dir, "feat: P9");
assert_eq!(infer_task_marks(&dir, Some(&c5), &c6).unwrap(), 2);
assert!(read_md().contains("### 🔵 P8") && read_md().contains("### 🔵 P9"));
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);
}
}