Compare commits

...

3 Commits

Author SHA1 Message Date
lanrtop
aaf3343133 feat(flywheel/rules-validation): Rules-Applied 规则 ID ingest 层真实性校验(U3)
All checks were successful
Push & PR Check / check (push) Successful in 1m17s
校验单点放统计消费端(R12 边界单点):extract_rule_ids 从项目
CONVENTIONS.md 提取合法 ID 集合,filter_valid_rules 在入库前剔除
幻觉/typo ID(无 CONVENTIONS.md 时 fail-open 不校验)。接入全部
4 条写入链路(buff 增量/webhook/gitea 轮询/全量导入);
sanitize_rules_applied 提供回溯清洗,重跑全量导入即可清历史污染。

Rules-Applied: R01,R06,R07
2026-07-15 14:05:13 +09:00
lanrtop
f04eb6e766 feat(onboarding): 接入包 v1.6.0——蓝图机器门控双层实现
A: feat: 提交须含 Feature-Confirmed: true trailer
  - lefthook.yml.tmpl commit-msg 新增 feature-confirmed 命令
  - AGENTS.md.tmpl 需求对齐协议补 trailer 说明

B: pre-commit 检查未关闭 🔵 任务卡(warn only)
  - 新增 .claude/hooks/blueprint-gate.cjs(CommonJS,兼容 ESM 项目)
  - lefthook.yml.tmpl pre-commit 新增 blueprint-gate 命令
  - onboarding.rs dispatch 新增 blueprint-gate.cjs

Feature-Confirmed: true
2026-07-15 13:38:32 +09:00
lanrtop
be2f69578a docs(conventions): v1.11.0——同步使者回传的四项治理修复
- Session 结束汇总加限定语,不得作为跳过确认卡的通道
- acceptance 格式补 agent_verify/human_verify 分层说明
- 阶段2末尾引用 AGENTS.md 需求对齐协议(Bug Fix/Feature 分类)
- Rust/Tauri 专属章节顶部加 scope 前置提示,避免污染 web 项目
- lefthook.yml.tmpl:API typecheck 注释由警告升级为 REQUIRED 措辞
2026-07-15 12:16:31 +09:00
15 changed files with 331 additions and 30 deletions

View File

@ -1,6 +1,6 @@
--- ---
version: 1.10.0 version: 1.11.0
updated: 2026-07-03 updated: 2026-07-15
source: dev-manager-tauri source: dev-manager-tauri
--- ---
@ -136,6 +136,11 @@ acceptance 是 agent 自验的唯一判断依据,必须是**可执行断言**
最小结构:`[载体] 调用/运行什么(参数) → 断言什么`。 最小结构:`[载体] 调用/运行什么(参数) → 断言什么`。
**含 UI 操作路径时必须分层**
- `agent_verify:` — 机器可断言函数返回值、HTTP 状态码、数据库记录stop hook / CI 只验此段
- `human_verify:` — 需人眼判断视觉效果、拖拽手势、动画30 秒浏览器终验
纯命令行 / API 任务可省略 `human_verify:`UI 拖拽 / 视觉类必须拆,否则 stop hook 无法收敛。
含 Rust 业务逻辑的 M/L 卡acceptance 还须包含(详见 Rust 代码可测试性规范): 含 Rust 业务逻辑的 M/L 卡acceptance 还须包含(详见 Rust 代码可测试性规范):
- `cargo test 通过` + 依赖注入写法确认 - `cargo test 通过` + 依赖注入写法确认
@ -304,6 +309,8 @@ Claude 在每次对话中应遵循以下流程:
| "开始做 XX" | 模块 status → in_progress | | "开始做 XX" | 模块 status → in_progress |
| "为什么选这个方案" | 记录到模块的 `## 决策记录` | | "为什么选这个方案" | 记录到模块的 `## 决策记录` |
> 开始写代码前,先判断改动类型:**Bug Fix**(意外偏差,直接执行)还是 **Feature**(新增能力/主动改变行为,必须先输出确认卡等触发词)。完整触发规则见 AGENTS.md「需求对齐协议」章节。
### 阶段 3实现功能 — 两阶段执行锁 ### 阶段 3实现功能 — 两阶段执行锁
任务卡执行遵循**两阶段提交**开始写第一行代码前加锁信息1全部完成后解锁信息2。只有信息1没有信息2说明任务被中断。 任务卡执行遵循**两阶段提交**开始写第一行代码前加锁信息1全部完成后解锁信息2。只有信息1没有信息2说明任务被中断。
@ -373,7 +380,7 @@ Claude 应关注这些信号来决定是否更新蓝图:
> B) 现在拉清单,走 /architect 重新规划 > B) 现在拉清单,走 /architect 重新规划
> C) 继续执行,我在 session 结束前汇总成任务卡 > C) 继续执行,我在 session 结束前汇总成任务卡
**Session 结束汇总**:任何有代码改动但无预建任务卡的会话,结束前 Claude 应输出 **Session 结束汇总**(仅适用于漂移检测触发后用户主动选择 C 选项的场景,**不得作为跳过确认卡的通道**
> 📋 本次会话实际改动:[文件列表 + 简述] > 📋 本次会话实际改动:[文件列表 + 简述]
> 是否整理为蓝图任务卡? > 是否整理为蓝图任务卡?
@ -638,6 +645,8 @@ cp -r .claude/skills/splitter ~/.claude/skills/
## Rust 代码可测试性规范 ## Rust 代码可测试性规范
> ⚠️ **本节仅适用于 Tauri / Rust 项目**,非 Tauri 项目跳过本节全部内容。
> 本规范仅适用于**新增** Rust 函数,存量代码不强制回填。 > 本规范仅适用于**新增** Rust 函数,存量代码不强制回填。
### 强制规则 ### 强制规则

View File

@ -372,8 +372,9 @@ modules:
area: backend area: backend
status: in_progress status: in_progress
progress: 99 progress: 99
note: "体系分发主线T1/T2/B1/B2/B3/V1T2 2026-07-04git-workflow 复杂度感知分支策略B3 2026-07-04manifest agent_button_carrier + splitter 三级载体继承);剩 B4 健康诊断遵守度(后置" note: "体系分发主线T1/T2/B1/B2/B3/V1/U3 ✅U3 2026-07-15Rules-Applied ingest 层校验+回溯清洗);剩 📋 U1全托管升级通道+批量重分发)/ B4遵守度+回传覆盖率,吸收被否决的 U2"
position: [3500, 0] position: [3500, 0]
updated: 2026-07-15
- id: gitea-integration - id: gitea-integration
name: Gitea 集成 name: Gitea 集成

View File

@ -30,6 +30,8 @@
- 「完整分发」是陷阱,正确形态是分级菜单:新项目种全套,旧项目增量接入(复用 mcp_inject 的「命名空间隔离 + 合并不覆盖」语义,不动用户已有配置) - 「完整分发」是陷阱,正确形态是分级菜单:新项目种全套,旧项目增量接入(复用 mcp_inject 的「命名空间隔离 + 合并不覆盖」语义,不动用户已有配置)
- 接入动作:现有 `apply_blueprint_buff` 升格为 `apply_onboarding_pack`,按档编排注入 - 接入动作:现有 `apply_blueprint_buff` 升格为 `apply_onboarding_pack`,按档编排注入
- 语义层进料口隐患:决策/验收/踩坑仍需主动写(与饿死复盘笔记同源),须配「从 PR 标题/commit body/review 评论半自动提取」,不再单纯指望人自觉 - 语义层进料口隐患:决策/验收/踩坑仍需主动写(与饿死复盘笔记同源),须配「从 PR 标题/commit body/review 评论半自动提取」,不再单纯指望人自觉
- **分发文件三档升级语义2026-07-15 择升环评估)**:① 版本化块文件AGENTS/git-workflow→ MANAGED 块原位升级;② 炼境全托管文件envoy.md/blueprint-gate.cjs用户不定制→ 内容不一致即覆盖;③ 用户可定制文件lefthook.yml→ 绝不覆盖,但 verify_onboarding 须能检测落后。此前 ②③ 混用「存在即跳过」导致回流修正推不到存量项目
- **回流触发不自动化2026-07-15 多角度辩证)**:否决子项目侧 hook 自动触发 /envoy——自动化触发拆掉人肉过滤层诱导低价值回传污染通道伤「择」换「环」转速。回流频率问题用炼境侧可观测性解「沉默项目」诊断信号见 B4 卡),人留在过滤环里。校验类需求优先放统计消费端(炼境 ingest而非分发 hookR12 边界单点 + 零分发成本 + 可回溯)
## 复用的现有能力(~60% 地基已具备) ## 复用的现有能力(~60% 地基已具备)
@ -132,6 +134,29 @@
- 版本化闭环要求2026-07-03 用户澄清,体系分发的灵魂): 分发不是"发了就不管"——每类资产带版本分发 → 子项目按该版本实践产生经验(成功/失败)→ 经验自吸反哺炼境trailer/failure_cases/复盘/返工率)→ 按版本分组对比效果 → 修订出新版 → 再分发。CONVENTIONS 是模范生version+队列对比+梦核+同步1.8→1.9 已跑通一整圈B 系列每类资产按此标准建闭环。**前置依赖提升**「接入包模板演进分发MANAGED 块化」concept 卡——模板无版本机制是当前最大缺口,没有它体系 v2 到不了存量项目 - 版本化闭环要求2026-07-03 用户澄清,体系分发的灵魂): 分发不是"发了就不管"——每类资产带版本分发 → 子项目按该版本实践产生经验(成功/失败)→ 经验自吸反哺炼境trailer/failure_cases/复盘/返工率)→ 按版本分组对比效果 → 修订出新版 → 再分发。CONVENTIONS 是模范生version+队列对比+梦核+同步1.8→1.9 已跑通一整圈B 系列每类资产按此标准建闭环。**前置依赖提升**「接入包模板演进分发MANAGED 块化」concept 卡——模板无版本机制是当前最大缺口,没有它体系 v2 到不了存量项目
- notes: 2026-07-03 概念探讨收束后的分发需求(体系详见 memory concept_dual_projection 与 mcp-server 模块 concept 卡)。用户全 agent 开发形态下,"给开发者立规范"="给 agent 注入规范",可完全机器化。四条现成管道分发四类资产:① B1(S) CONVENTIONS 1.10.0 加 R12 边界单点实现 + R13 验收须真实走通验证路径 + 载体选择原则;② B2(S) 接入包 AGENTS.md 模板加 agent 按钮章节(三要素/载体谱系/侦察驻军);③ B3(M) init/sync 提示词升级——建蓝图时声明项目按钮载体、acceptance 生成为可执行验证路径格式;④ B4(S,后置) 健康诊断加体系遵守度检查。移植体验=零新增动作,接入流程不变。 - notes: 2026-07-03 概念探讨收束后的分发需求(体系详见 memory concept_dual_projection 与 mcp-server 模块 concept 卡)。用户全 agent 开发形态下,"给开发者立规范"="给 agent 注入规范",可完全机器化。四条现成管道分发四类资产:① B1(S) CONVENTIONS 1.10.0 加 R12 边界单点实现 + R13 验收须真实走通验证路径 + 载体选择原则;② B2(S) 接入包 AGENTS.md 模板加 agent 按钮章节(三要素/载体谱系/侦察驻军);③ B3(M) init/sync 提示词升级——建蓝图时声明项目按钮载体、acceptance 生成为可执行验证路径格式;④ B4(S,后置) 健康诊断加体系遵守度检查。移植体验=零新增动作,接入流程不变。
### 📋 U1. 全托管文件升级通道 + 批量重分发(择升环断点修复 ①)
- status: planned
- complexity: M
- depends: onboarding-pack卡 T1
- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/resources/onboarding/.claude/commands/envoy.md, src-tauri/resources/onboarding/.claude/hooks/blueprint-gate.cjs
- acceptance: [Tauri command] 对存量项目重跑 apply_onboarding_pack → envoy.md / blueprint-gate.cjs 与内嵌模板不一致时覆盖action=updated一致时 skipped文件头含「由炼境托管重跑接入包会覆盖」声明[cargo test] dispatch_fully_managed 三态不存在→written / 一致→skipped / 不一致→updated[MCP tool] verify_onboarding 的 lefthook 检查项从内嵌 TMPL_LEFTHOOK 自省提取 commands 键集合,项目 lefthook.yml 缺键 → fail + 列出缺失键(禁止硬编码具体检查项名,模板演进校验器零维护);[MCP tool] apply_onboarding_pack 不传 project_id → 对所有已登记项目批量执行并返回逐项目摘要(复用 ingest_git_history 先例),验收通过后删除 batch_apply.py / scan_onboarded.py
- notes: dispatch_one「存在即跳过」使回流修正只能推到 AGENTS/git-workflow 两份版本化文档——envoy/gate 的 bug 修复永远到不了存量 12 项目,批量重分发靠临时 python 脚本硬扛batch_apply.py 为实锤。三档升级语义见决策记录。已知盲点辩证承认首次覆盖时子项目文件尚无托管声明存在一次性盲覆盖窗口——envoy/gate 分发仅一周、被定制概率极低,且 updated 会在报告显式出现,风险接受
### ✅ U3. Rules-Applied 真实性校验ingest 层单点实现,择升环断点修复 ②)
- status: done
- done_note: 2026-07-15 完成。纯函数 extract_rule_idsR+数字 token 边界识别,防 PR13/R2D2 误提取)+ filter_valid_rulesvalid=None 不校验 fail-open校验接入全部 4 条写入链路buff 增量 / webhook push / gitea 轮询 / 全量导入规则集每项目每批次加载一次sanitize_rules_applied 显式回溯清洗通道UPSERT「不覆盖」约束原样保留两者并行不悖IngestResult 新增 sanitized 字段Rust/TS/MCP 输出/FlywheelPanel 四层同步record_commit 收窄为 #[cfg(test)],生产链路只剩 validated 版防绕过。cargo test 98 全过(新增 5 测token 边界/过滤/入库剔除/message 链路/清洗幂等+仅清洗指定项目pnpm typecheck 干净
- complexity: S
- files: src-tauri/src/commands/commit_metrics.rs
- acceptance: [cargo test] trailer 解析校验纯函数——规则 ID 不存在于该项目 .blueprint/CONVENTIONS.md如 R99→ 从 rules_applied 剔除并可观测(日志或 invalid 计数),真实 ID 保留;项目无 CONVENTIONS.md → 不校验全保留;[MCP tool] ingest_git_history 重跑 → 「规则命中 × 返工」交叉统计不再含未知 ID存量污染可回溯清洗
- notes: 辩证翻转载体:原方案(子项目 commit-msg hook违反 R12 边界单点——统计消费端在炼境,校验就该在炼境 ingest 层。收益零分发成本覆盖全部项目、重跑即回溯清洗、cargo test 可测(炼境自身 agent 按钮载体。commit-msg hook 即时反馈版降为可选增强(若做依赖 U1 通道,不在本卡)。上限诚实:只挡 typo/幻觉 ID「真 ID 假触发」留给宁缺毋滥纪律
### 📋 B4. 健康诊断遵守度 + 回传覆盖率(吸收被否决的 U2择升环断点修复 ③)
- status: planned
- complexity: S
- files: src-tauri/src/mcp_tools.rs, src-tauri/src/commands/onboarding.rs
- acceptance: [MCP tool] get_diagnostics或 verify_onboarding 检查链)新增「沉默项目」信号:项目近 30 天 commit_metrics 有数据但使者回传list_envoy_returns 同源数据)为 0 → 列出项目名 + commit 数,提示去该项目跑 /envoy近 30 天有回传或无 commit 活动 → 不报
- notes: U2子项目侧 Stop hook 自动触发 /envoy经多角度辩证否决2026-07-15自动化触发会拆掉人肉过滤层诱导 agent 用 under_validation 噪声交差灌满回传通道——伤「择」换「环」转速,本末倒置。正解是炼境侧可观测性:让遗忘可被看见(信号驱动),人留在过滤环里决定是否回传。同时销掉原 B4 后置欠账(体系遵守度检查)
## 复盘笔记 ## 复盘笔记
【复盘】V1 接入生效验收2026-07-04 【复盘】V1 接入生效验收2026-07-04

View File

@ -0,0 +1,31 @@
#!/usr/bin/env node
// 蓝图门控pre-commit 检查未关闭的 🔵 任务卡warn onlyexit 0不阻断提交
const fs = require('fs');
const path = require('path');
const modulesDir = path.join(process.cwd(), '.blueprint', 'modules');
if (!fs.existsSync(modulesDir)) {
process.exit(0);
}
const unclosed = [];
for (const file of fs.readdirSync(modulesDir).filter(f => f.endsWith('.md'))) {
const content = fs.readFileSync(path.join(modulesDir, file), 'utf8');
for (const line of content.split('\n')) {
if (line.startsWith('### 🔵')) {
unclosed.push({ file, title: line.replace(/^###\s*🔵\s*/, '').trim() });
}
}
}
if (unclosed.length > 0) {
process.stderr.write('\n⚠ 蓝图门控:发现未关闭任务卡\n');
for (const { file, title } of unclosed) {
process.stderr.write(` 🔵 ${title}${file}\n`);
}
process.stderr.write('\n 已完成 → 改为 ✅;仍进行中 → 属预期,可忽略本提示。\n\n');
}
process.exit(0);

View File

@ -18,7 +18,7 @@
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 --> <!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
<!-- ONBOARDING_MANAGED_START version="1.5.0" --> <!-- ONBOARDING_MANAGED_START version="1.6.0" -->
## 核心开发约束 ## 核心开发约束
### 包管理 ### 包管理
@ -55,6 +55,8 @@
**边界模糊时**:保守判断,按 Feature 处理,走完整确认卡。 **边界模糊时**:保守判断,按 Feature 处理,走完整确认卡。
**提交时**`feat:` 提交须在 commit message 末尾加 `Feature-Confirmed: true` trailerlefthook commit-msg 强制检查)。这是确认卡已输出的机器层存证。
> ⚠️ Session 结束汇总补录 ≠ 确认卡替代——补录仅适用于漂移检测触发后用户主动选择补录的场景,不得作为「先做再补」的通道。 > ⚠️ Session 结束汇总补录 ≠ 确认卡替代——补录仅适用于漂移检测触发后用户主动选择补录的场景,不得作为「先做再补」的通道。
## Git 工作流 ## Git 工作流

View File

@ -9,15 +9,22 @@ pre-commit:
typecheck: typecheck:
glob: "*.{ts,tsx}" glob: "*.{ts,tsx}"
run: pnpm typecheck run: pnpm typecheck
# ⚠️ 如果项目有 API / 后端层,必须在此补对应的 typecheck——否则后端类型错误不会被拦截。 # 🚨 【必填项】项目有 API / 后端层时,必须取消注释并按实际路径配置——
# 取消注释并按实际填写(留多余示例行删掉即可): # 否则后端类型错误不会被 pre-commit 拦截(前端 typecheck 只覆盖 src/ 层)。
# 按实际填写一行,其余示例行可删掉:
# api-typecheck: # api-typecheck:
# glob: "api/**/*.{ts,tsx}" # glob: "api/**/*.{ts,tsx}"
# run: pnpm --filter api typecheck # monorepo workspace # run: pnpm --filter api typecheck # monorepo workspace
# # run: pnpm exec tsc -p api/tsconfig.json --noEmit # 独立 tsconfig # # run: pnpm exec tsc -p api/tsconfig.json --noEmit # 独立 tsconfig
# # run: pnpm -r typecheck # 递归所有包 # # run: pnpm -r typecheck # 递归所有包
blueprint-gate:
run: node .claude/hooks/blueprint-gate.cjs || true
commit-msg: commit-msg:
commands: commands:
conventional: conventional:
run: "head -1 .git/COMMIT_EDITMSG | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1" run: "head -1 .git/COMMIT_EDITMSG | grep -qE '^(feat|fix|docs|chore|refactor|ci|perf|style|test|build|revert)(\\(.+\\))?!?: .+' || exit 1"
feature-confirmed:
run: |
head -1 .git/COMMIT_EDITMSG | grep -qE '^feat' || exit 0
grep -q 'Feature-Confirmed: true' .git/COMMIT_EDITMSG || { printf '\n⚠ feat: 提交须含 Feature-Confirmed: true trailer\n 在 commit message 末尾加一行Feature-Confirmed: true\n (确认卡已与用户对齐,方可提交)\n\n' >&2; exit 1; }

View File

@ -159,9 +159,16 @@ fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> {
); );
} }
// 批量写入用事务包裹(首次可达数十条,避免逐条 fsync // 批量写入用事务包裹(首次可达数十条,避免逐条 fsync
// U3规则 ID 集合每批次加载一次,剔除 trailer 里的未知 ID
let valid_rules = crate::commands::commit_metrics::load_project_rule_ids(root);
let tx = conn.unchecked_transaction(); let tx = conn.unchecked_transaction();
for entry in &commits { for entry in &commits {
let _ = crate::commands::commit_metrics::record_commit_entry(&conn, &project_id, entry); let _ = crate::commands::commit_metrics::record_commit_entry(
&conn,
&project_id,
entry,
valid_rules.as_ref(),
);
} }
if let Ok(tx) = tx { if let Ok(tx) = tx {
let _ = tx.commit(); let _ = tx.commit();

View File

@ -5,6 +5,7 @@
use rusqlite::Connection; use rusqlite::Connection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet;
/// 一条 commit 的解析结果。 /// 一条 commit 的解析结果。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -107,6 +108,104 @@ pub fn parse_rules_applied(message: &str) -> Option<String> {
None None
} }
// ── U3Rules-Applied 真实性校验ingest 层单点,防幻觉/typo ID 污染交叉统计)──
/// 从 CONVENTIONS.md 内容提取全部规则 ID大写 R+数字,前后不粘字母数字)。纯函数。
pub fn extract_rule_ids(text: &str) -> HashSet<String> {
let b = text.as_bytes();
let mut ids = HashSet::new();
let mut i = 0;
while i < b.len() {
if b[i] == b'R' && (i == 0 || !b[i - 1].is_ascii_alphanumeric()) {
let mut j = i + 1;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
if j > i + 1 && (j == b.len() || !b[j].is_ascii_alphanumeric()) {
ids.insert(text[i..j].to_string());
}
i = j;
} else {
i += 1;
}
}
ids
}
/// 过滤 rules_applied 串:剔除不在 valid 集合中的 ID全部无效返回 None。
/// valid 为 None项目无 CONVENTIONS.md时不校验、原样保留。纯函数。
pub fn filter_valid_rules(rules: &str, valid: Option<&HashSet<String>>) -> Option<String> {
let Some(set) = valid else {
return Some(rules.to_string());
};
let kept: Vec<&str> = rules
.split(',')
.map(str::trim)
.filter(|t| !t.is_empty() && set.contains(*t))
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join(","))
}
}
/// 读取项目 .blueprint/CONVENTIONS.md 提取规则 ID 集合;文件不存在返回 None不校验
/// 每项目每批次调用一次,勿放进逐 commit 循环。
pub fn load_project_rule_ids(root: &std::path::Path) -> Option<HashSet<String>> {
let content = std::fs::read_to_string(root.join(".blueprint").join("CONVENTIONS.md")).ok()?;
Some(extract_rule_ids(&content))
}
/// 远程采集链路webhook/轮询)无本地 root按 project_id 查 blueprint_buffs 路径再加载。
pub fn load_rule_ids_by_project_id(conn: &Connection, project_id: &str) -> Option<HashSet<String>> {
let path: String = conn
.query_row(
"SELECT project_path FROM blueprint_buffs WHERE project_id = ?1",
rusqlite::params![project_id],
|r| r.get(0),
)
.ok()?;
load_project_rule_ids(std::path::Path::new(&path))
}
/// 回溯清洗存量行:剔除 rules_applied 中不在 valid 集合的 ID全无效置 NULL
/// 与 UPSERT 的「不覆盖已有值」约束并行不悖——本函数是显式清洗通道,仅在全量导入时调用。
/// 返回被清洗的行数,重跑幂等(已干净的行不再计数)。
pub fn sanitize_rules_applied(
conn: &Connection,
project_id: &str,
valid: &HashSet<String>,
) -> Result<u32, String> {
let rows: Vec<(String, String)> = {
let mut stmt = conn
.prepare(
"SELECT sha, rules_applied FROM commit_metrics
WHERE project_id = ?1 AND rules_applied IS NOT NULL",
)
.map_err(|e| e.to_string())?;
let mapped = stmt
.query_map(rusqlite::params![project_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})
.map_err(|e| e.to_string())?;
mapped.filter_map(|r| r.ok()).collect()
};
let mut cleaned = 0u32;
for (sha, rules) in rows {
let filtered = filter_valid_rules(&rules, Some(valid));
if filtered.as_deref() != Some(rules.as_str()) {
conn.execute(
"UPDATE commit_metrics SET rules_applied = ?1 WHERE sha = ?2",
rusqlite::params![filtered, sha],
)
.map_err(|e| e.to_string())?;
cleaned += 1;
}
}
Ok(cleaned)
}
/// UPSERT 写入一条 commit 记录:新 sha 插入;已存在且 rules_applied 为空时仅回填 /// UPSERT 写入一条 commit 记录:新 sha 插入;已存在且 rules_applied 为空时仅回填
/// rules_applied绝不覆盖已有值——回填幂等的关键约束。返回是否产生了写入。 /// rules_applied绝不覆盖已有值——回填幂等的关键约束。返回是否产生了写入。
fn upsert_metrics( fn upsert_metrics(
@ -140,9 +239,27 @@ fn upsert_metrics(
Ok(n > 0) Ok(n > 0)
} }
/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer /// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer
/// 生产链路的 git log 只给 subjecttrailer 走 `record_commit_entry`;本函数保留给 /// 并按 valid_rules 剔除未知规则 IDU3。远程采集生产链路webhook/轮询)用本函数,
/// 持有完整 message 的调用方与测试。 /// valid_rules 每项目加载一次(`load_rule_ids_by_project_id`)。
pub fn record_commit_validated(
conn: &Connection,
project_id: &str,
sha: &str,
message: &str,
committed_at: Option<&str>,
valid_rules: Option<&HashSet<String>>,
) -> Result<(), String> {
let p = parse_conventional(message);
let rules_applied =
parse_rules_applied(message).and_then(|r| filter_valid_rules(&r, valid_rules));
upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?;
Ok(())
}
/// 不校验规则 ID 的写入(等价 valid_rules=None。仅供测试铺数据
/// 生产链路一律走 `record_commit_validated` / `record_commit_entry`U3 校验强制)。
#[cfg(test)]
pub fn record_commit( pub fn record_commit(
conn: &Connection, conn: &Connection,
project_id: &str, project_id: &str,
@ -150,21 +267,21 @@ pub fn record_commit(
message: &str, message: &str,
committed_at: Option<&str>, committed_at: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
let p = parse_conventional(message); record_commit_validated(conn, project_id, sha, message, committed_at, None)
let rules_applied = parse_rules_applied(message);
upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?;
Ok(())
} }
/// 写入一条来自 `read_git_log` 的记录subject 与 trailer 分列trailer 由 git 原生解析)。 /// 写入一条来自 `read_git_log` 的记录subject 与 trailer 分列trailer 由 git 原生解析)。
/// 同时把触碰文件写入 commit_filesINSERT OR IGNORE 幂等,供文件级风险画像)。 /// 同时把触碰文件写入 commit_filesINSERT OR IGNORE 幂等,供文件级风险画像)。
/// valid_rules 每项目加载一次传入U3 校验None 表示项目无 CONVENTIONS.md不校验。
pub fn record_commit_entry( pub fn record_commit_entry(
conn: &Connection, conn: &Connection,
project_id: &str, project_id: &str,
entry: &GitLogEntry, entry: &GitLogEntry,
valid_rules: Option<&HashSet<String>>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let p = parse_conventional(&entry.subject); let p = parse_conventional(&entry.subject);
let rules = normalize_rule_tokens(&entry.rules_trailer); let rules = normalize_rule_tokens(&entry.rules_trailer)
.and_then(|r| filter_valid_rules(&r, valid_rules));
let date = if entry.committed_at.is_empty() { let date = if entry.committed_at.is_empty() {
None None
} else { } else {
@ -300,6 +417,8 @@ pub struct IngestResult {
pub imported: u32, pub imported: u32,
/// 已存在且无需回填(跳过) /// 已存在且无需回填(跳过)
pub skipped: u32, pub skipped: u32,
/// 存量行中被清洗掉未知规则 ID 的行数U3 回溯清洗;项目无 CONVENTIONS.md 时恒为 0
pub sanitized: u32,
} }
/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值 /// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值
@ -406,16 +525,23 @@ pub fn ingest_full_git_history(
) )
.unwrap_or(project_id); .unwrap_or(project_id);
// U3每项目加载一次规则 ID 集合,新写入过滤 + 存量回溯清洗
let valid_rules = load_project_rule_ids(root);
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
let mut imported = 0u32; let mut imported = 0u32;
for entry in &commits { for entry in &commits {
// 新 sha 插入;已存在的行 UPSERT 回填 rules_appliedtrailer 死链修复后的存量补数路径) // 新 sha 插入;已存在的行 UPSERT 回填 rules_appliedtrailer 死链修复后的存量补数路径)
if record_commit_entry(&conn, &effective_id, entry)? { if record_commit_entry(&conn, &effective_id, entry, valid_rules.as_ref())? {
imported += 1; imported += 1;
} }
} }
let sanitized = match &valid_rules {
Some(set) => sanitize_rules_applied(&conn, &effective_id, set)?,
None => 0,
};
tx.commit().map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string())?;
Ok(IngestResult { imported, skipped: total - imported }) Ok(IngestResult { imported, skipped: total - imported, sanitized })
} }
// ── 跨项目 Git 健康度聚合 ───────────────────────────────────────────────────── // ── 跨项目 Git 健康度聚合 ─────────────────────────────────────────────────────
@ -784,22 +910,22 @@ mod tests {
assert_eq!(rules_of(&conn, "old"), None); assert_eq!(rules_of(&conn, "old"), None);
// 回填:同 sha 带 trailer 重写 → 补上 // 回填:同 sha 带 trailer 重写 → 补上
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06")).unwrap(); let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06"), None).unwrap();
assert!(changed, "回填应计为写入"); assert!(changed, "回填应计为写入");
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06")); assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
// 不覆盖:已有值的行,不同 trailer 重写 → 保持原值 // 不覆盖:已有值的行,不同 trailer 重写 → 保持原值
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99")).unwrap(); let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99"), None).unwrap();
assert!(!changed, "已有值不应被覆盖"); assert!(!changed, "已有值不应被覆盖");
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06")); assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
// 无 trailer 重写已有 NULL 行 → 不产生无意义 UPDATE // 无 trailer 重写已有 NULL 行 → 不产生无意义 UPDATE
record_commit(&conn, "proj", "old2", "chore: y", None).unwrap(); record_commit(&conn, "proj", "old2", "chore: y", None).unwrap();
let changed = record_commit_entry(&conn, "proj", &entry("old2", "chore: y", "")).unwrap(); let changed = record_commit_entry(&conn, "proj", &entry("old2", "chore: y", ""), None).unwrap();
assert!(!changed); assert!(!changed);
// 新行带 trailer 直接写入 // 新行带 trailer 直接写入
assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05")).unwrap()); assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05"), None).unwrap());
assert_eq!(rules_of(&conn, "new").as_deref(), Some("R05")); assert_eq!(rules_of(&conn, "new").as_deref(), Some("R05"));
} }
@ -844,8 +970,8 @@ mod tests {
let conn = crate::db::conn_with_schema(); let conn = crate::db::conn_with_schema();
let mut e = entry("s1", "feat(a): x", ""); let mut e = entry("s1", "feat(a): x", "");
e.files = vec!["src/a.rs".into(), "src/b.rs".into()]; e.files = vec!["src/a.rs".into(), "src/b.rs".into()];
record_commit_entry(&conn, "proj", &e).unwrap(); record_commit_entry(&conn, "proj", &e, None).unwrap();
record_commit_entry(&conn, "proj", &e).unwrap(); // 重跑 record_commit_entry(&conn, "proj", &e, None).unwrap(); // 重跑
let count: i64 = conn let count: i64 = conn
.query_row("SELECT COUNT(*) FROM commit_files WHERE sha = 's1'", [], |r| r.get(0)) .query_row("SELECT COUNT(*) FROM commit_files WHERE sha = 's1'", [], |r| r.get(0))
@ -990,5 +1116,80 @@ Rules-Applied: R06", None).unwrap();
record_commit(&conn, "pz", "s9", "feat: plain", None).unwrap(); record_commit(&conn, "pz", "s9", "feat: plain", None).unwrap();
assert!(rule_hit_stats(&conn).unwrap().is_empty()); assert!(rule_hit_stats(&conn).unwrap().is_empty());
} }
// ── U3Rules-Applied 真实性校验 ────────────────────────────────────────
fn valid_set(ids: &[&str]) -> HashSet<String> {
ids.iter().map(|s| s.to_string()).collect()
}
#[test]
fn extract_rule_ids_respects_token_boundaries() {
let text = "### R01 规则一\n- R12xxx见 |R06| 表格)\nPR13 前粘字母不算R2D2 后粘字母不算r03 小写不算";
let ids = extract_rule_ids(text);
assert!(ids.contains("R01"));
assert!(ids.contains("R12"));
assert!(ids.contains("R06"));
assert!(!ids.contains("R13"), "PR13 内的 R13 不是独立 ID");
assert!(!ids.contains("R2"), "R2D2 内的 R2 不是独立 ID");
assert!(!ids.contains("R03") && !ids.contains("r03"), "小写 r 不提取");
assert!(extract_rule_ids("没有任何规则").is_empty());
}
#[test]
fn filter_valid_rules_drops_unknown_ids() {
let set = valid_set(&["R01", "R06"]);
assert_eq!(filter_valid_rules("R01,R99", Some(&set)).as_deref(), Some("R01"));
assert_eq!(filter_valid_rules("R99", Some(&set)), None, "全无效返回 None");
assert_eq!(
filter_valid_rules("R01,R99", None).as_deref(),
Some("R01,R99"),
"无 CONVENTIONS.md 时不校验"
);
}
#[test]
fn record_commit_entry_filters_unknown_ids_at_ingest() {
let conn = crate::db::conn_with_schema();
let set = valid_set(&["R01"]);
record_commit_entry(&conn, "proj", &entry("v1", "feat(a): x", "R01,R99"), Some(&set)).unwrap();
assert_eq!(rules_of(&conn, "v1").as_deref(), Some("R01"), "未知 ID 应在入库前剔除");
record_commit_entry(&conn, "proj", &entry("v2", "feat(a): y", "R99"), Some(&set)).unwrap();
assert_eq!(rules_of(&conn, "v2"), None, "全无效则该列为 NULL");
}
#[test]
fn record_commit_validated_filters_trailer_in_message() {
let conn = crate::db::conn_with_schema();
let set = valid_set(&["R05"]);
record_commit_validated(&conn, "proj", "w1", "feat(x): a\n\nRules-Applied: R05,R99", None, Some(&set)).unwrap();
assert_eq!(rules_of(&conn, "w1").as_deref(), Some("R05"));
}
#[test]
fn sanitize_cleans_stored_pollution_idempotently() {
let conn = crate::db::conn_with_schema();
// 存量污染:校验上线前写入的行
record_commit_entry(&conn, "proj", &entry("p1", "feat: a", "R01,R99"), None).unwrap();
record_commit_entry(&conn, "proj", &entry("p2", "fix: b", "R99"), None).unwrap();
record_commit_entry(&conn, "proj", &entry("p3", "feat: c", "R01"), None).unwrap();
record_commit_entry(&conn, "other", &entry("q1", "feat: d", "R99"), None).unwrap();
let set = valid_set(&["R01"]);
let cleaned = sanitize_rules_applied(&conn, "proj", &set).unwrap();
assert_eq!(cleaned, 2, "p1 剔除 R99、p2 置 NULLp3 干净不动");
assert_eq!(rules_of(&conn, "p1").as_deref(), Some("R01"));
assert_eq!(rules_of(&conn, "p2"), None);
assert_eq!(rules_of(&conn, "p3").as_deref(), Some("R01"));
assert_eq!(rules_of(&conn, "q1").as_deref(), Some("R99"), "只清洗指定项目");
// 清洗后交叉统计不再含未知 IDacceptance 断言)
let stats = rule_hit_stats(&conn).unwrap();
assert!(stats.iter().filter(|s| s.rule_id == "R99").all(|s| s.project_count == 1),
"proj 的 R99 已清,仅剩 other 项目的");
// 幂等:重跑零清洗
assert_eq!(sanitize_rules_applied(&conn, "proj", &set).unwrap(), 0);
}
} }

View File

@ -517,13 +517,16 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> {
Err(_) => continue, Err(_) => continue,
}; };
polled += 1; polled += 1;
// U3按 project_id 加载规则 ID 集合(每仓库一次),剔除 trailer 里的未知 ID
let valid_rules =
crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id);
for (sha, message, date) in parse_gitea_commits(&json) { for (sha, message, date) in parse_gitea_commits(&json) {
let before: i64 = conn.query_row( let before: i64 = conn.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE sha = ?1", params![sha], |r| r.get(0), "SELECT COUNT(*) FROM commit_metrics WHERE sha = ?1", params![sha], |r| r.get(0),
).unwrap_or(0); ).unwrap_or(0);
if before == 0 if before == 0
&& crate::commands::commit_metrics::record_commit( && crate::commands::commit_metrics::record_commit_validated(
&conn, project_id, &sha, &message, date.as_deref(), &conn, project_id, &sha, &message, date.as_deref(), valid_rules.as_ref(),
).is_ok() ).is_ok()
{ {
ingested += 1; ingested += 1;

View File

@ -17,6 +17,7 @@ const TMPL_AGENTS: &str = include_str!("../../resources/onboarding/AGENTS.md.tmp
const TMPL_LEFTHOOK: &str = include_str!("../../resources/onboarding/lefthook.yml.tmpl"); const TMPL_LEFTHOOK: &str = include_str!("../../resources/onboarding/lefthook.yml.tmpl");
const TMPL_GIT_WORKFLOW: &str = include_str!("../../resources/onboarding/git-workflow.md.tmpl"); const TMPL_GIT_WORKFLOW: &str = include_str!("../../resources/onboarding/git-workflow.md.tmpl");
const TMPL_ENVOY: &str = include_str!("../../resources/onboarding/.claude/commands/envoy.md"); const TMPL_ENVOY: &str = include_str!("../../resources/onboarding/.claude/commands/envoy.md");
const TMPL_BLUEPRINT_GATE: &str = include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs");
/// 单个模板的分发结果。 /// 单个模板的分发结果。
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
@ -147,6 +148,7 @@ pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK), dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK),
dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW), dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW),
dispatch_one(root, ".claude/commands/envoy.md", TMPL_ENVOY), dispatch_one(root, ".claude/commands/envoy.md", TMPL_ENVOY),
dispatch_one(root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE),
] ]
} }

View File

@ -263,7 +263,7 @@ mod tests {
rules_trailer: String::new(), rules_trailer: String::new(),
files: files.iter().map(|s| s.to_string()).collect(), files: files.iter().map(|s| s.to_string()).collect(),
}; };
record_commit_entry(conn, pid, &entry).unwrap(); record_commit_entry(conn, pid, &entry, None).unwrap();
} }
#[test] #[test]

View File

@ -566,7 +566,10 @@ pub async fn tools_call(params: Option<&Value>) -> Value {
) { ) {
Ok(r) => { Ok(r) => {
ok += 1; ok += 1;
lines.push(format!("- {}: 写入 {} 条,跳过 {}", id, r.imported, r.skipped)); lines.push(format!(
"- {}: 写入 {} 条,跳过 {} 条,清洗 {} 条无效规则ID",
id, r.imported, r.skipped, r.sanitized
));
} }
Err(e) => { Err(e) => {
failed += 1; failed += 1;

View File

@ -133,14 +133,18 @@ async fn handle_push(project_id: &str, body: &Bytes) -> Response {
.into_response() .into_response()
} }
}; };
// U3按 project_id 找到本地路径加载规则 ID 集合,剔除 trailer 里的未知 ID
let valid_rules =
crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id);
let mut received = 0; let mut received = 0;
for c in &commits { for c in &commits {
if crate::commands::commit_metrics::record_commit( if crate::commands::commit_metrics::record_commit_validated(
&conn, &conn,
project_id, project_id,
&c.sha, &c.sha,
&c.message, &c.message,
c.timestamp.as_deref(), c.timestamp.as_deref(),
valid_rules.as_ref(),
) )
.is_ok() .is_ok()
{ {

View File

@ -112,17 +112,21 @@ export function FlywheelPanel() {
setIngestSummary(null); setIngestSummary(null);
let totalImported = 0; let totalImported = 0;
let totalSkipped = 0; let totalSkipped = 0;
let totalSanitized = 0;
for (const { id, path } of projectsWithPath) { for (const { id, path } of projectsWithPath) {
try { try {
const r = await ingestFullGitHistory(id, path); const r = await ingestFullGitHistory(id, path);
totalImported += r.imported; totalImported += r.imported;
totalSkipped += r.skipped; totalSkipped += r.skipped;
totalSanitized += r.sanitized;
} catch { } catch {
// 单个项目失败不中断其他项目 // 单个项目失败不中断其他项目
} }
} }
await refetchHealth(); await refetchHealth();
setIngestSummary(`新导入 ${totalImported} 条,跳过 ${totalSkipped}`); setIngestSummary(
`新导入 ${totalImported} 条,跳过 ${totalSkipped}${totalSanitized > 0 ? `,清洗 ${totalSanitized} 条无效规则ID` : ""}`,
);
setIngesting(false); setIngesting(false);
}; };

View File

@ -1580,6 +1580,8 @@ export const getCommitStats = (projectId: string) =>
export interface IngestResult { export interface IngestResult {
imported: number; imported: number;
skipped: number; skipped: number;
/** 存量行中被清洗掉未知规则 ID 的行数U3项目无 CONVENTIONS.md 时为 0 */
sanitized: number;
} }
export const ingestFullGitHistory = (projectId: string, projectPath: string) => export const ingestFullGitHistory = (projectId: string, projectPath: string) =>