All checks were successful
Push & PR Check / check (push) Successful in 1m1s
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>
798 lines
30 KiB
Rust
798 lines
30 KiB
Rust
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(), ¤t_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);
|
||
}
|
||
}
|