feat(flywheel): 阶段三执行质量预测层(trailer 死链修复 + 风险画像) #2
@ -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
|
||||
|
||||
@ -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)"
|
||||
|
||||
## 决策记录
|
||||
|
||||
|
||||
@ -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<Strin
|
||||
/// 首次接入时只解析最近这么多条 commit,避免一次性解析整个历史。
|
||||
const FIRST_TIME_LIMIT: usize = 50;
|
||||
|
||||
/// 获取 last_commit..HEAD 之间的新 commit(sha, subject, committed_at ISO)。
|
||||
/// 获取 last_commit..HEAD 之间的新 commit(含 Rules-Applied trailer)。
|
||||
/// 首次接入(last_commit 为 None)只取最近 FIRST_TIME_LIMIT 条。
|
||||
fn get_new_commits(root: &Path, last_commit: Option<&str>) -> 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![],
|
||||
};
|
||||
/// 取数统一走 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) => format!("{prev}..HEAD"),
|
||||
None => format!("-n {FIRST_TIME_LIMIT}"),
|
||||
Some(prev) => GitLogRange::Since(prev),
|
||||
None => GitLogRange::Limit(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()
|
||||
};
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// ── 辅助:任务状态更新 ────────────────────────────────────────────────────────
|
||||
|
||||
@ -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<String> {
|
||||
let mut ids: Vec<String> = 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<String> {
|
||||
@ -87,24 +102,47 @@ pub fn parse_rules_applied(message: &str) -> Option<String> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim();
|
||||
let mut ids: Vec<String> = 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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<Vec<(String, String, String)>, 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<GitLogEntry> {
|
||||
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<Vec<GitLogEntry>, 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<Vec<(String, Str
|
||||
}
|
||||
Ok(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))
|
||||
})
|
||||
.filter_map(parse_git_log_line)
|
||||
.collect())
|
||||
}
|
||||
|
||||
@ -290,7 +368,7 @@ pub fn ingest_full_git_history(
|
||||
if !root.exists() {
|
||||
return Err(format!("目录不存在: {}", root.display()));
|
||||
}
|
||||
let commits = read_git_log(root, FULL_HISTORY_LIMIT)?;
|
||||
let commits = read_git_log(root, GitLogRange::Limit(FULL_HISTORY_LIMIT))?;
|
||||
let total = commits.len() as u32;
|
||||
|
||||
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
||||
@ -306,19 +384,11 @@ pub fn ingest_full_git_history(
|
||||
|
||||
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
|
||||
let mut imported = 0u32;
|
||||
for (sha, subject, date) in &commits {
|
||||
let p = parse_conventional(subject);
|
||||
let n = conn.execute(
|
||||
"INSERT OR IGNORE INTO commit_metrics
|
||||
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![
|
||||
sha, effective_id, p.commit_type, p.scope,
|
||||
p.is_rework as i32, p.is_ci_auto as i32,
|
||||
if date.is_empty() { None } else { Some(date.as_str()) },
|
||||
],
|
||||
).map_err(|e| e.to_string())?;
|
||||
imported += n as u32;
|
||||
for entry in &commits {
|
||||
// 新 sha 插入;已存在的行 UPSERT 回填 rules_applied(trailer 死链修复后的存量补数路径)
|
||||
if record_commit_entry(&conn, &effective_id, entry)? {
|
||||
imported += 1;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(IngestResult { imported, skipped: total - imported })
|
||||
@ -629,6 +699,105 @@ mod tests {
|
||||
assert_eq!(total, 1, "同 sha 重复写入应被忽略");
|
||||
}
|
||||
|
||||
// ── P3-A:trailer 链路修复 + UPSERT 回填 ─────────────────────────────────────
|
||||
|
||||
fn entry(sha: &str, subject: &str, trailer: &str) -> 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<String> {
|
||||
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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user