Compare commits
No commits in common. "1778ca21d9d00b91c9c751d3199512322560b1a6" and "79853b71f2f1adae9e46cbc4c96217266e5a3eef" have entirely different histories.
1778ca21d9
...
79853b71f2
@ -257,8 +257,8 @@ modules:
|
|||||||
- id: blueprint-buff
|
- id: blueprint-buff
|
||||||
name: 蓝图 Buff
|
name: 蓝图 Buff
|
||||||
area: backend
|
area: backend
|
||||||
status: in_progress
|
status: done
|
||||||
progress: 86
|
progress: 100
|
||||||
position: [2250, 0]
|
position: [2250, 0]
|
||||||
|
|
||||||
- id: claude-autonomy-config
|
- id: claude-autonomy-config
|
||||||
|
|||||||
@ -12,7 +12,6 @@ id: blueprint-buff
|
|||||||
- git commit 只自动标 🔵(进行中),不自动标 ✅(完成);完成的语义需要验收,这是自动化的边界
|
- git commit 只自动标 🔵(进行中),不自动标 ✅(完成);完成的语义需要验收,这是自动化的边界
|
||||||
- Buff 同步后顺手调用 `get_blueprint()`,内部触发 `append_usage_snapshot()`,使飞轮数据与实际开发进度对齐
|
- Buff 同步后顺手调用 `get_blueprint()`,内部触发 `append_usage_snapshot()`,使飞轮数据与实际开发进度对齐
|
||||||
- 使用文件路径匹配而非 commit message 解析,与 AI 工具/提交信息风格完全解耦
|
- 使用文件路径匹配而非 commit message 解析,与 AI 工具/提交信息风格完全解耦
|
||||||
- **files 重叠只作兜底(2026-10-02,B1)**:本批提交已有卡被显式推进(新增 🔵/✅/🔴 标题行)则不推断,整批无推进才视为「agent 忘了加锁」。原因:两阶段锁下在做的卡落地时已非 📋,files 重叠只能碰到未开工卡,精度结构性偏低。曾评估「提交说明引用卡 ID」——正文自由文本 4 次命中 3 次误判(「阶段二 N3/N4/N5 待启动」),结构化 trailer 又与上条解耦决策冲突且兜不住忘加锁场景,故不采用;「可能相关」改记 DB 信号不改卡片(方案 C)留作残余噪声持续时的升级路径
|
|
||||||
|
|
||||||
## 任务卡
|
## 任务卡
|
||||||
|
|
||||||
@ -56,15 +55,3 @@ id: blueprint-buff
|
|||||||
- files: src/components/dashboard/BlueprintModal.tsx, src/components/dashboard/ProjectCard.tsx
|
- files: src/components/dashboard/BlueprintModal.tsx, src/components/dashboard/ProjectCard.tsx
|
||||||
- depends: blueprint-buff
|
- depends: blueprint-buff
|
||||||
- acceptance: 任意项目 ProjectCard 可打开该项目自己的蓝图视图,传入 projectId 支持 Buff 管理,炼境自身蓝图入口不受影响
|
- acceptance: 任意项目 ProjectCard 可打开该项目自己的蓝图视图,传入 projectId 支持 Buff 管理,炼境自身蓝图入口不受影响
|
||||||
|
|
||||||
### 🔵 B1 — files 重叠推断降为兜底(同批已有显式推进则不推断)
|
|
||||||
- status: in_progress
|
|
||||||
- complexity: M
|
|
||||||
- files: src-tauri/src/commands/buff.rs, src-tauri/src/commands/blueprint.rs
|
|
||||||
- acceptance: "agent_verify: [cargo test] ①批内蓝图 diff 含 +### 🔵/✅ → 不标且内容不变;②无显式推进 + 精确路径命中 → 标 🔵;③仅反向后缀不命中;④lib.rs 不命中 mylib.rs;⑤尾逗号空项不命中;⑥「、」分隔可拆;⑦last_commit 非 HEAD 祖先 → 跳过。[回放脚本] master 历史 50 → ≤5,53af8c3/239d9fd/79853b7 对 P6/P7/P8/A4 零命中。human_verify: 重装炼境后合并下一个 PR,主仓库工作区不再出现无关卡被标 🔵"
|
|
||||||
- 起因:2026-10-02 合并 PR #24/#25/#26 后 P6/P7/P8(agent-client-parity)与 A4(architecture-evolution)未开工却被标 🔵,仅因与 P1/P3/P9 共用 files;enterprise-system 2026-07-22 回传同类问题(第二例,governance-feedback-loop 挂触发条件满足)
|
|
||||||
- 回放(master 240 个 first-parent 提交):现有算法命中 50 次——明确误判 30、存疑 17、明确正确 3;热点为 mcp_tools.rs/onboarding.rs/commands.ts 等共享文件
|
|
||||||
- 根因:两阶段锁下真正在做的卡提交落地时已是 🔵/✅,files 重叠只能碰到仍为 📋 的卡——选择偏差使精度结构性偏低,非调匹配规则可解
|
|
||||||
- 实现(2026-10-02,分支 feat/blueprint-buff):infer_task_marks 收口三道闸——last_commit 非 head 祖先跳过(git 出错记日志)、本批 .blueprint/modules patch 新增 🔵/✅/🔴 标题行(非原样搬移)跳过、余下才走 files 匹配;同一批次全程用轮询读到的 HEAD sha。匹配:parse_task_files(, 、 , 分隔,丢空项)+ file_matches(精确或 / 边界后缀,去反向后缀)。蓝图同步提示词改为「核实仅共用文件的 🔵 可降回 📋 并写明原因」。边界分析补 🔴、head 口径、git 异常日志三处。
|
|
||||||
- verification: "2026-10-02:cargo test 256 passed / 0 failed;buff 9 条新测试(精确命中、非 📋 不动、反向后缀不命中、/ 边界、尾逗号空项、「、」分隔与 A4 实例、🔵/✅/🔴 推进判定、搬移不算推进、临时 git 仓库端到端:同批显式推进不推断 / 无推进兜底标 🔵 / checkout 非祖先跳过 / 首次接入兜底)。回放 master 240 个 first-parent 提交:命中 50 → 5,79 个批次因显式推进整批抑制,53af8c3/239d9fd/79853b7 对 P6/P7/P8/A4 零命中。"
|
|
||||||
- known_limits: "仍是启发式:不更新蓝图的 PR(如 Codex PR)碰共享文件仍可能误标;一次 pull 多个合并时,一个 PR 的显式推进会屏蔽另一个 PR 的兜底;只看已提交的蓝图改动,主仓库里只提交代码、🔵 锁留在工作区时仍会推断;files 项带反引号(4/311)或括号注释(21/311)永不命中(原有行为,未改);WSL 路径项目上新增 git 辅助不分支,失败即不推断。"
|
|
||||||
|
|||||||
@ -93,9 +93,8 @@ MANAGED 块警示(5b0b068)、Bug Fix/Feature 分类 + S 级轻量卡 + 汇
|
|||||||
crypto.randomUUID 非安全上下文 / DB 触发器审计注册表 / ALS+postgres-js 注入 / capacitor 三条 /
|
crypto.randomUUID 非安全上下文 / DB 触发器审计注册表 / ALS+postgres-js 注入 / capacitor 三条 /
|
||||||
ssh-action secrets envs / 部署脚本 git pull 自更新延迟
|
ssh-action secrets envs / 部署脚本 git pull 自更新延迟
|
||||||
- **/ship 规模闸实证 ×3**(超大 PR 例外有效 / L1 无人值守例外放行 / MCP 断连续接有效)→ 喂「/ship 体检」卡的阈值复核
|
- **/ship 规模闸实证 ×3**(超大 PR 例外有效 / L1 无人值守例外放行 / MCP 断连续接有效)→ 喂「/ship 体检」卡的阈值复核
|
||||||
- **挂触发 ×2**:SQL migration 文件漏提交检测(再发一例立卡);~~blueprint-gate files 重叠误标 🔵~~
|
- **挂触发 ×2**:SQL migration 文件漏提交检测(再发一例立卡);blueprint-gate files 重叠误标 🔵
|
||||||
→ **2026-10-02 第二例触发(炼境自身 P6/P7/P8/A4)已结案**:误标源头实为 buff 后台轮询(非 blueprint-gate),
|
(与 gate 信噪比增强同域,同一触发条件合并观察)
|
||||||
见 blueprint-buff B1(files 重叠降为兜底,同批已有显式推进则不推断)
|
|
||||||
- **review 体系设计输入 ×2**:对抗验证系统性过滤结构性建议 / 严苛 review 标准条件性收益——review 体系动工时消费
|
- **review 体系设计输入 ×2**:对抗验证系统性过滤结构性建议 / 严苛 review 标准条件性收益——review 体系动工时消费
|
||||||
- **坑库候选 ×1**:Windows 复制 SSH 私钥须 `cat | clip` 保 LF(CRLF 致 ssh-action 静默失败)
|
- **坑库候选 ×1**:Windows 复制 SSH 私钥须 `cat | clip` 保 LF(CRLF 致 ssh-action 静默失败)
|
||||||
|
|
||||||
|
|||||||
@ -1062,9 +1062,8 @@ fn build_sync_prompt(
|
|||||||
3. 将不再存在的功能标记为 abandoned 或删除\n\
|
3. 将不再存在的功能标记为 abandoned 或删除\n\
|
||||||
4. 更新模块的 status 和 progress 使其与任务卡状态一致\n\
|
4. 更新模块的 status 和 progress 使其与任务卡状态一致\n\
|
||||||
5. 更新 manifest.yaml 的 updated 日期为今天\n\n\
|
5. 更新 manifest.yaml 的 updated 日期为今天\n\n\
|
||||||
⚠️ 状态保护规则:任务卡中 🔵 in_progress 来自 agent 开工加锁,或炼境在未见显式加锁的提交中按 files 重叠推断标记。\
|
⚠️ 状态保护规则:任务卡中 🔵 in_progress 状态由炼境根据 git 提交自动标记,代表该任务的关联文件已有实际改动。\
|
||||||
同步时默认保留,可根据代码完成度升为 ✅ done;仅当核实该卡并无对应改动(只是与其他卡共用文件)时才降回 📋 todo,\
|
同步时请将其视为有效进度信号——可根据代码完成度升为 ✅ done,但无充分理由不要降回 📋 todo。"
|
||||||
并在提交说明中写明原因。"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let output_hint = if no_blueprint {
|
let output_hint = if no_blueprint {
|
||||||
|
|||||||
@ -176,8 +176,12 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件路径匹配 → 兜底标记命中的任务卡 🔵(本批已显式推进 / HEAD 非前进时不推断)
|
// 文件路径匹配 → 标记命中的任务卡 🔵
|
||||||
let tasks_updated = infer_task_marks(root, last_commit.as_deref(), ¤t_commit)? > 0;
|
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 可能已变);任务卡有变动则通知前端重绘
|
// 有新 commit 即刷新飞轮快照(rework_count 可能已变);任务卡有变动则通知前端重绘
|
||||||
let _ = crate::commands::blueprint::get_blueprint(project_path.to_string());
|
let _ = crate::commands::blueprint::get_blueprint(project_path.to_string());
|
||||||
@ -246,19 +250,14 @@ fn get_head_commit(root: &Path) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// head 传轮询时读到的 HEAD sha(而非字面量 "HEAD"),保证同一批次各项判定口径一致。
|
fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result<Vec<String>, String> {
|
||||||
fn get_changed_files(
|
|
||||||
root: &Path,
|
|
||||||
last_commit: Option<&str>,
|
|
||||||
head: &str,
|
|
||||||
) -> Result<Vec<String>, String> {
|
|
||||||
let path_str = root.to_string_lossy();
|
let path_str = root.to_string_lossy();
|
||||||
let out = if is_wsl_path(&path_str) {
|
let out = if is_wsl_path(&path_str) {
|
||||||
let linux = unc_to_linux(&path_str).ok_or_else(|| "无法转换 WSL 路径".to_string())?;
|
let linux = unc_to_linux(&path_str).ok_or_else(|| "无法转换 WSL 路径".to_string())?;
|
||||||
let cmd = if let Some(prev) = last_commit {
|
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 {
|
} 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")
|
crate::silent_cmd("wsl")
|
||||||
.args(["bash", "-c", &cmd])
|
.args(["bash", "-c", &cmd])
|
||||||
@ -266,9 +265,9 @@ fn get_changed_files(
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
} else {
|
} else {
|
||||||
let args: Vec<&str> = if let Some(prev) = last_commit {
|
let args: Vec<&str> = if let Some(prev) = last_commit {
|
||||||
vec!["diff", "--name-only", prev, head]
|
vec!["diff", "--name-only", prev, "HEAD"]
|
||||||
} else {
|
} 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")
|
crate::silent_cmd("git")
|
||||||
.args(&args)
|
.args(&args)
|
||||||
@ -288,59 +287,6 @@ fn get_changed_files(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 以下两个 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 解析(飞轮 L0)─────────────────────────────────────────
|
||||||
|
|
||||||
/// 首次接入时只解析最近这么多条 commit,避免一次性解析整个历史。
|
/// 首次接入时只解析最近这么多条 commit,避免一次性解析整个历史。
|
||||||
@ -364,53 +310,6 @@ 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<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) => 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<usize, String> {
|
fn update_task_statuses(root: &Path, changed_files: &[String]) -> Result<usize, String> {
|
||||||
let modules_dir = root.join(".blueprint").join("modules");
|
let modules_dir = root.join(".blueprint").join("modules");
|
||||||
if !modules_dir.exists() {
|
if !modules_dir.exists() {
|
||||||
@ -468,11 +367,13 @@ fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(fv) = files_value {
|
if let Some(fv) = files_value {
|
||||||
let task_files = parse_task_files(&fv);
|
let task_files: Vec<String> = fv.split(',').map(|s| normalize_path(s.trim())).collect();
|
||||||
|
|
||||||
let hit = changed_files.iter().any(|cf| {
|
let hit = changed_files.iter().any(|cf| {
|
||||||
let ncf = normalize_path(cf);
|
let ncf = normalize_path(cf);
|
||||||
task_files.iter().any(|tf| file_matches(tf, &ncf))
|
task_files.iter().any(|tf| {
|
||||||
|
tf == &ncf || ncf.ends_with(tf.as_str()) || tf.ends_with(ncf.as_str())
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if hit {
|
if hit {
|
||||||
@ -496,24 +397,6 @@ fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, u
|
|||||||
(result, updated)
|
(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 {
|
fn normalize_path(p: &str) -> String {
|
||||||
p.trim_start_matches("./").replace('\\', "/").to_string()
|
p.trim_start_matches("./").replace('\\', "/").to_string()
|
||||||
}
|
}
|
||||||
@ -552,185 +435,3 @@ fn query_buff(conn: &rusqlite::Connection, path: &str) -> Result<BuffStatus, Str
|
|||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())
|
.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(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 端到端:临时 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user