//! 接入包模板分发(基线档)。 //! //! 版本化分发(T1,2026-07-03):AGENTS.md / git-workflow.md 的炼境管理内容 //! 包在 ONBOARDING_MANAGED 标记块中——文件不存在写全新;已有标记原位升级块; //! 无标记则末尾追加块。块外用户内容永不触碰。 //! lefthook.yml 因用户常深度定制(monorepo filter 等),保持"存在即跳过"。 //! 模板蓝本取自 enterprise-system,已通用化。 use serde::{Deserialize, Serialize}; use std::path::Path; const MANAGED_START: &str = ""; // 标准模板(编译期内嵌) const TMPL_AGENTS: &str = include_str!("../../resources/onboarding/AGENTS.md.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_ENVOY: &str = include_str!("../../resources/onboarding/.claude/commands/envoy.md"); const TMPL_BLUEPRINT_GATE: &str = include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs"); const TMPL_LIANJING_SKILL: &str = include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md"); const TMPL_CAPABILITY_PACKS: &str = include_str!("../../resources/onboarding/.lian-jing/capability-packs.json"); const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; const BASE_ONBOARDING_PACK_VERSION: &str = "1.1.0"; /// 单个模板的分发结果。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TemplateResult { /// 相对项目根的目标路径 pub name: String, /// "written" | "skipped" | "failed" pub action: String, pub detail: String, } /// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。 fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult { let target = root.join(relative); if target.exists() { return TemplateResult { name: relative.to_string(), action: "skipped".into(), detail: "已存在,未覆盖".into(), }; } if let Some(parent) = target.parent() { if let Err(e) = std::fs::create_dir_all(parent) { return TemplateResult { name: relative.to_string(), action: "failed".into(), detail: format!("建目录失败: {e}"), }; } } match std::fs::write(&target, content) { Ok(_) => TemplateResult { name: relative.to_string(), action: "written".into(), detail: "已写入".into(), }, Err(e) => TemplateResult { name: relative.to_string(), action: "failed".into(), detail: format!("写入失败: {e}"), }, } } /// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。 /// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。 /// 比较前归一化行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。 fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> TemplateResult { let target = root.join(relative); if !target.exists() { return dispatch_one(root, relative, content); } let normalize = |s: &str| s.replace("\r\n", "\n"); if let Ok(existing) = std::fs::read_to_string(&target) { if normalize(&existing) == normalize(content) { return TemplateResult { name: relative.to_string(), action: "skipped".into(), detail: "内容已是最新模板".into(), }; } } match std::fs::write(&target, content) { Ok(_) => TemplateResult { name: relative.to_string(), action: "updated".into(), detail: "全托管文件已覆盖为最新模板".into(), }, Err(e) => TemplateResult { name: relative.to_string(), action: "failed".into(), detail: format!("覆盖失败: {e}"), }, } } /// 从模板中提取 ONBOARDING_MANAGED 块(含首尾标记行)。 fn extract_managed_block(template: &str) -> Option<&str> { let start = template.find(MANAGED_START)?; let end = template.find(MANAGED_END)? + MANAGED_END.len(); template.get(start..end) } /// 从标记行提取版本号(`version="x.y.z"`)。 fn block_version(text: &str) -> Option<&str> { let start = text.find(MANAGED_START)?; let rest = &text[start..]; let v = rest.find("version=\"")? + "version=\"".len(); let tail = &rest[v..]; tail.split('"').next() } /// 版本化分发(三态):不存在→写全新;有标记→原位升级块;无标记→末尾追加块。 fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateResult { let target = root.join(relative); let tpl_block = match extract_managed_block(template) { Some(b) => b, None => return dispatch_one(root, relative, template), // 模板无标记,退化为旧行为 }; let tpl_ver = block_version(tpl_block).unwrap_or("?"); if !target.exists() { return dispatch_one(root, relative, template); } let content = match std::fs::read_to_string(&target) { Ok(c) => c, Err(e) => { return TemplateResult { name: relative.to_string(), action: "failed".into(), detail: format!("读取失败: {e}"), } } }; let (new_content, action, detail) = if let (Some(s), Some(e)) = (content.find(MANAGED_START), content.find(MANAGED_END)) { let old_ver = block_version(&content).unwrap_or("?").to_string(); if old_ver == tpl_ver { return TemplateResult { name: relative.to_string(), action: "skipped".into(), detail: format!("管理块已是 v{tpl_ver}"), }; } let mut nc = String::new(); nc.push_str(&content[..s]); nc.push_str(tpl_block); nc.push_str(&content[e + MANAGED_END.len()..]); ( nc, "updated", format!("管理块 v{old_ver} → v{tpl_ver},块外内容未动"), ) } else { ( format!("{}\n\n{}\n", content.trim_end(), tpl_block), "appended", format!("追加管理块 v{tpl_ver}(存量文件无标记,原内容保留在上方)"), ) }; match std::fs::write(&target, new_content) { Ok(_) => TemplateResult { name: relative.to_string(), action: action.into(), detail, }, Err(e) => TemplateResult { name: relative.to_string(), action: "failed".into(), detail: format!("写入失败: {e}"), }, } } /// 向项目分发全部基线模板,返回每个的处理结果。 /// project_path 为项目根(WSL 项目为 \\wsl.localhost\... UNC 路径,可直接写)。 pub fn dispatch_templates(project_path: &str) -> Vec { let root = Path::new(project_path.trim_end_matches(['/', '\\'])); vec![ dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS), dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK), dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW), dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY), dispatch_fully_managed( root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE, ), dispatch_fully_managed( root, ".agents/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL, ), dispatch_fully_managed( root, ".lian-jing/capability-packs.json", TMPL_CAPABILITY_PACKS, ), ] } // ── V1:lefthook 激活(分发 ≠ 生效,文件写入后必须 lefthook install 挂上 hook)── /// lefthook 激活结果。status: "activated" | "missing_binary" | "failed" #[derive(Debug, Serialize, Deserialize, Clone)] pub struct HookActivation { pub status: String, pub detail: String, } /// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。 /// Windows 的 PATH 兜底须额外尝试 "lefthook.cmd"——pnpm 全局安装的是 .cmd shim, /// 而 CreateProcess 对裸名 "lefthook" 只解析 .exe(2026-07-04 批量接入实测踩坑)。 fn run_lefthook_install(root: &Path) -> HookActivation { let mut candidates: Vec = Vec::new(); #[cfg(target_os = "windows")] { candidates.push(root.join("node_modules/.bin/lefthook.exe")); candidates.push(root.join("node_modules/.bin/lefthook.cmd")); } #[cfg(not(target_os = "windows"))] candidates.push(root.join("node_modules/.bin/lefthook")); let local = candidates.into_iter().find(|p| p.exists()); let mut attempts: Vec<(String, &str)> = Vec::new(); match &local { Some(p) => attempts.push((p.to_string_lossy().to_string(), "项目本地")), None => { attempts.push(("lefthook".to_string(), "全局 PATH")); #[cfg(target_os = "windows")] attempts.push(("lefthook.cmd".to_string(), "全局 PATH(.cmd shim)")); } } for (program, source) in &attempts { match crate::silent_cmd(program) .arg("install") .current_dir(root) .output() { Ok(out) if out.status.success() => { return HookActivation { status: "activated".into(), detail: format!("lefthook install 完成({source})"), } } Ok(out) => { return HookActivation { status: "failed".into(), detail: format!( "lefthook install 退出码 {:?}:{}", out.status.code(), String::from_utf8_lossy(&out.stderr).trim() ), } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) => { return HookActivation { status: "failed".into(), detail: format!("执行失败: {e}"), } } } } HookActivation { status: "missing_binary".into(), detail: "未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包".into(), } } /// 接入包应用报告:模板分发结果 + hook 激活 + buff 状态。 #[derive(Debug, Serialize, Deserialize, Clone)] pub struct OnboardingReport { pub templates: Vec, pub hook: HookActivation, pub buff: crate::commands::buff::BuffStatus, } /// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。 #[tauri::command] pub fn apply_onboarding_pack( project_path: String, project_id: String, ) -> Result { // 空/无效路径必须在此拦截:Path::new("") 是相对路径,模板会写进炼境进程的 // cwd(2026-07-15 批量实调实锤:空路径登记把接入包写进了 src-tauri/) let root = Path::new(project_path.trim_end_matches(['/', '\\'])); if project_path.trim().is_empty() || !root.is_dir() { return Err(format!("项目路径为空或不存在,拒绝分发: {project_path:?}")); } let templates = dispatch_templates(&project_path); let hook = run_lefthook_install(Path::new(project_path.trim_end_matches(['/', '\\']))); let buff = crate::commands::buff::apply_blueprint_buff(project_path, project_id)?; Ok(OnboardingReport { templates, hook, buff, }) } // ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)────── /// 单项验收结果。 #[derive(Debug, Serialize, Deserialize, Clone)] pub struct VerifyCheck { pub name: String, pub pass: bool, pub state: VerifyState, pub detail: String, } /// 接入检查的可行动状态。只有 ready 才表示检查项通过;其余状态必须显式说明原因。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VerifyState { Ready, Missing, Legacy, VersionMismatch, Unavailable, } impl VerifyCheck { fn with_state(name: impl Into, state: VerifyState, detail: impl Into) -> Self { let pass = state == VerifyState::Ready; Self { name: name.into(), pass, state, detail: detail.into(), } } fn from_pass(name: impl Into, pass: bool, detail: impl Into) -> Self { Self::with_state( name, if pass { VerifyState::Ready } else { VerifyState::Missing }, detail, ) } } fn check_project_context( canonical: Option<&str>, legacy: Option<&str>, expected_project_id: &str, ) -> VerifyCheck { let parse_object = |content: &str| serde_json::from_str::(content).ok(); if canonical.is_some() && canonical.and_then(parse_object).is_none() { return VerifyCheck::with_state( "项目身份上下文", VerifyState::Unavailable, ".lian-jing/context.json 不是有效 JSON——请修复后重试 inject_mcp", ); } match canonical.and_then(parse_object) { Some(context) if !context.is_object() => VerifyCheck::with_state( "项目身份上下文", VerifyState::Unavailable, ".lian-jing/context.json 顶层不是 JSON 对象——请修复后重试 inject_mcp", ), Some(context) => { let version = context .get("schema_version") .and_then(|value| value.as_u64()); let project_id = context.get("project_id").and_then(|value| value.as_str()); if version != Some(PROJECT_CONTEXT_SCHEMA_VERSION) { VerifyCheck::with_state( "项目身份上下文", VerifyState::VersionMismatch, format!( "context schema_version={:?},当前为 {}——调用 inject_mcp 可升级", version, PROJECT_CONTEXT_SCHEMA_VERSION ), ) } else if project_id == Some(expected_project_id) { VerifyCheck::with_state( "项目身份上下文", VerifyState::Ready, "`.lian-jing/context.json` 与已登记 project_id 一致", ) } else { VerifyCheck::with_state( "项目身份上下文", VerifyState::Unavailable, "`.lian-jing/context.json` 的 project_id 与炼境登记不一致——停止使用并重新 inject_mcp", ) } } None if legacy .and_then(parse_object) .and_then(|value| { value .get("project_id") .and_then(|value| value.as_str()) .map(str::to_owned) }) .as_deref() == Some(expected_project_id) => { VerifyCheck::with_state( "项目身份上下文", VerifyState::Legacy, "仅发现 `.claude/liangjing.json`——调用 inject_mcp 迁移到 `.lian-jing/context.json`", ) } None if legacy.is_some() => VerifyCheck::with_state( "项目身份上下文", VerifyState::Unavailable, "`.claude/liangjing.json` 无有效 project_id——请重新 inject_mcp", ), None => VerifyCheck::with_state( "项目身份上下文", VerifyState::Missing, "缺少 `.lian-jing/context.json`——调用 inject_mcp 创建项目身份上下文", ), } } fn check_capability_packs(content: Option<&str>) -> VerifyCheck { let Some(content) = content else { return VerifyCheck::with_state( "基础能力包描述符", VerifyState::Missing, "缺少 `.lian-jing/capability-packs.json`——重跑接入包可写入", ); }; let Ok(descriptor) = serde_json::from_str::(content) else { return VerifyCheck::with_state( "基础能力包描述符", VerifyState::Unavailable, "能力包描述符不是有效 JSON——请修复后重跑接入包", ); }; let schema_version = descriptor .get("schema_version") .and_then(|value| value.as_u64()); let version = descriptor .get("packs") .and_then(|value| value.as_array()) .and_then(|packs| { packs.iter().find(|pack| { pack.get("pack_id").and_then(|value| value.as_str()) == Some("base-onboarding") }) }) .and_then(|pack| pack.get("version")) .and_then(|value| value.as_str()); if schema_version == Some(CAPABILITY_PACK_SCHEMA_VERSION) && version == Some(BASE_ONBOARDING_PACK_VERSION) { VerifyCheck::with_state( "基础能力包描述符", VerifyState::Ready, format!("base-onboarding v{BASE_ONBOARDING_PACK_VERSION}(最新)"), ) } else { VerifyCheck::with_state( "基础能力包描述符", VerifyState::VersionMismatch, format!( "能力包 schema={schema_version:?}、base-onboarding={version:?};当前应为 schema={}、v{}——重跑接入包可升级", CAPABILITY_PACK_SCHEMA_VERSION, BASE_ONBOARDING_PACK_VERSION ), ) } } /// 纯函数:pre-commit hook 内容是否由 lefthook 接管。 fn hook_is_lefthook(content: &str) -> bool { content.contains("lefthook") } /// 纯函数:从 .git/config 文本提取 origin remote 的 url。 fn parse_origin_url(git_config: &str) -> Option { let mut in_origin = false; for line in git_config.lines() { let t = line.trim(); if t.starts_with('[') { in_origin = t .replace(' ', "") .eq_ignore_ascii_case("[remote\"origin\"]"); continue; } if in_origin { if let Some(rest) = t.strip_prefix("url") { if let Some(v) = rest.trim_start().strip_prefix('=') { return Some(v.trim().to_string()); } } } } None } /// 纯函数:提取 URL 的 host(支持 https:// 与 git@host: 两种形式,忽略端口与用户名)。 fn url_host(url: &str) -> Option { let u = url.trim(); if let Some(rest) = u.split("://").nth(1) { let authority = rest.split('/').next()?; let host = authority.rsplit('@').next()?.split(':').next()?; return Some(host.to_lowercase()); } // ssh 形式 git@host:owner/repo.git let after_at = u.rsplit('@').next()?; let host = after_at.split([':', '/']).next()?; if host.contains('.') { Some(host.to_lowercase()) } else { None } } fn check_codex_mcp(content: Option<&str>) -> (bool, String) { match content { Some(content) if content.contains("[mcp_servers.lian-jing]") && content.contains("url = \"http://127.0.0.1:") && content.contains("/mcp\"") => { (true, "lian-jing 指向本地 MCP 服务".into()) } Some(_) => ( false, "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into(), ), None => ( false, ".codex/config.toml 缺失——调用 inject_mcp 后生成".into(), ), } } fn check_skill(content: Option<&str>) -> (bool, String) { match content { Some(content) if content.contains("name: lian-jing") => (true, "炼境项目技能已分发".into()), _ => ( false, "缺少 .agents/skills/lian-jing/SKILL.md——重跑接入包".into(), ), } } /// 纯函数:管理块版本是否与最新模板一致。 fn check_managed_version(content: Option<&str>, expected: &str) -> (bool, String) { match content.and_then(block_version) { Some(v) if v == expected => (true, format!("管理块 v{v}(最新)")), Some(v) => ( false, format!("管理块 v{v},最新模板 v{expected},重跑接入包可升级"), ), None => (false, "文件缺失或无管理块标记".into()), } } /// 纯函数:origin host 是否命中已登记的 Gitea 实例。 fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool, String) { match origin_host { None => (false, "未配置 origin remote(或无 .git)".into()), Some(h) if instance_hosts.is_empty() => { (false, format!("origin={h},但炼境未登记任何 Gitea 实例")) } Some(h) if instance_hosts.iter().any(|i| i == h) => { (true, format!("origin → {h}(已登记 Gitea 实例)")) } Some(h) => ( false, format!( "origin={h},不在已登记 Gitea 实例({})中", instance_hosts.join(", ") ), ), } } /// 从 lefthook.yml 内容提取 commands 键集合(` commands:` 下缩进 4 空格的键行)。 /// 纯函数;注释与更深缩进(run: | 多行体等)忽略。用于 U1 检查项自省—— /// 期望键集合取自内嵌模板,禁止硬编码具体检查项名(模板演进校验器零维护)。 pub fn lefthook_command_keys(yml: &str) -> Vec { let mut keys = Vec::new(); let mut in_commands = false; for line in yml.lines() { let trimmed = line.trim_start(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } let indent = line.len() - trimmed.len(); if indent == 2 && trimmed.trim_end() == "commands:" { in_commands = true; continue; } if indent < 4 { in_commands = false; continue; } if in_commands && indent == 4 { if let Some(key) = trimmed.trim_end().strip_suffix(':') { keys.push(key.to_string()); } } } keys } /// U1 检查:项目 lefthook.yml 是否缺少模板要求的检查段(lefthook 不自动覆盖,只检测落后)。 fn check_lefthook_keys(project_yml: Option<&str>, template_yml: &str) -> (bool, String) { let Some(yml) = project_yml else { return (false, "lefthook.yml 缺失——重跑接入包写入".to_string()); }; let have: Vec = lefthook_command_keys(yml); let missing: Vec = lefthook_command_keys(template_yml) .into_iter() .filter(|k| !have.contains(k)) .collect(); if missing.is_empty() { (true, "模板要求的检查段齐全".to_string()) } else { ( false, format!( "缺少检查段: {}——lefthook.yml 不自动覆盖,请手动对照模板补齐", missing.join(", ") ), ) } } /// 接入生效验收:检查链覆盖项目身份、能力包和既有接入基线。分发成功 ≠ 功能生效。 /// conn 由调用方注入(R06)。 pub fn verify_onboarding( conn: &rusqlite::Connection, project_id: &str, root: &Path, ) -> Vec { let read = |rel: &str| std::fs::read_to_string(root.join(rel)).ok(); let mut checks = Vec::new(); // 1/2 模板管理块版本到位(期望版本从内嵌模板自省,不硬编码) for (rel, tmpl, label) in [ ("AGENTS.md", TMPL_AGENTS, "AGENTS.md 管理块版本"), ( "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW, "git-workflow 管理块版本(含交付状态调用说明)", ), ] { let expected = extract_managed_block(tmpl) .and_then(block_version) .unwrap_or("?"); let (pass, detail) = check_managed_version(read(rel).as_deref(), expected); checks.push(VerifyCheck::from_pass(label, pass, detail)); } checks.push(check_project_context( read(".lian-jing/context.json").as_deref(), read(".claude/liangjing.json").as_deref(), project_id, )); checks.push(check_capability_packs( read(".lian-jing/capability-packs.json").as_deref(), )); let (pass, detail) = check_skill(read(".agents/skills/lian-jing/SKILL.md").as_deref()); checks.push(VerifyCheck::from_pass("Codex 炼境技能已分发", pass, detail)); let (pass, detail) = check_codex_mcp(read(".codex/config.toml").as_deref()); checks.push(VerifyCheck::from_pass("Codex MCP 已配置", pass, detail)); // 3 lefthook hook 真挂在 .git/hooks let (pass, detail) = match read(".git/hooks/pre-commit") { Some(c) if hook_is_lefthook(&c) => (true, "pre-commit 由 lefthook 接管".to_string()), Some(_) => ( false, "pre-commit 存在但非 lefthook(被其他工具占用?)".to_string(), ), None => ( false, "无 pre-commit hook——lefthook install 未执行".to_string(), ), }; checks.push(VerifyCheck::from_pass("lefthook hook 挂载", pass, detail)); // 4 lefthook 检查段齐全(期望键从内嵌模板自省,U1) let (pass, detail) = check_lefthook_keys(read("lefthook.yml").as_deref(), TMPL_LEFTHOOK); checks.push(VerifyCheck::from_pass("lefthook 检查段齐全", pass, detail)); // 5 origin 指向已登记 Gitea 实例 let origin_host = read(".git/config") .as_deref() .and_then(parse_origin_url) .as_deref() .and_then(url_host); let mut instance_hosts: Vec = Vec::new(); if let Ok(mut stmt) = conn.prepare("SELECT url FROM gitea_instances") { if let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0)) { for u in rows.flatten() { if let Some(h) = url_host(&u) { instance_hosts.push(h); } } } } let (pass, detail) = check_origin(origin_host.as_deref(), &instance_hosts); checks.push(VerifyCheck::from_pass("origin 指向 Gitea", pass, detail)); // 6 CI workflow 存在 let yml_count = std::fs::read_dir(root.join(".gitea/workflows")) .map(|rd| { rd.flatten() .filter(|e| { e.path() .extension() .map(|x| x == "yml" || x == "yaml") .unwrap_or(false) }) .count() }) .unwrap_or(0); checks.push(VerifyCheck::from_pass( "CI workflow 存在", yml_count > 0, if yml_count > 0 { format!(".gitea/workflows/ 有 {yml_count} 个 workflow") } else { "无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into() }, )); // 7 飞轮数据在流入(近 30 天 commit_metrics) let recent: i64 = conn .query_row( "SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now','-30 days')", rusqlite::params![project_id], |r| r.get(0), ) .unwrap_or(0); checks.push(VerifyCheck::from_pass( "飞轮 commit 数据流入", recent > 0, if recent > 0 { format!("近 30 天 {recent} 条 commit 已采集") } else { "近 30 天无数据——确认 buff 已挂且有 commit 活动(或调 poll_now)".into() }, )); checks } // ── B4:使者回传覆盖率(「沉默项目」信号)────────────────────────────────────── /// 「沉默项目」:近 30 天有 commit 采集但零使者回传的已登记项目(名称, commit 数)。 /// /// 回流触发不自动化(决策 2026-07-15):不在子项目装 hook 催回传, /// 而是让遗忘可被看见——人看到信号后自行决定是否去该项目跑 /envoy。 /// commit_metrics.project_id 可能是 blueprint_buffs 的 UUID,经 buff 路径 /// 关联回 project_workspaces;未登记项目(无法跑 /envoy)不列入。 pub fn silent_projects(conn: &rusqlite::Connection) -> Result, String> { let mut stmt = conn .prepare( "SELECT COALESCE(pp.name, pw.id) AS name, COUNT(*) AS commits FROM commit_metrics cm LEFT JOIN blueprint_buffs bb ON bb.project_id = cm.project_id LEFT JOIN project_workspaces pw ON pw.id = cm.project_id OR pw.win_path = bb.project_path LEFT JOIN project_profiles pp ON pw.profile_id = pp.id WHERE cm.committed_at >= datetime('now','-30 days') AND pw.id IS NOT NULL GROUP BY pw.id HAVING (SELECT COUNT(*) FROM project_notes pn WHERE pn.project_id = pw.id AND pn.content LIKE '【炼境使者回传】%' AND pn.created_at >= datetime('now','localtime','-30 days')) = 0 ORDER BY commits DESC", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); Ok(rows) } #[cfg(test)] mod tests { use super::*; #[test] fn writes_then_skips_never_overwrites() { let base = std::env::temp_dir().join(format!("lianjing_onboarding_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); // 首次:全部写入 let r1 = dispatch_templates(pp); assert!(r1.iter().all(|t| t.action == "written"), "首次应全部写入"); assert!(base.join("AGENTS.md").exists()); assert!(base.join("lefthook.yml").exists()); assert!(base.join("docs/ai-context/git-workflow.md").exists()); // 改一个文件(无管理块标记),验证用户内容不被覆盖: // T1 后的契约——AGENTS/git-workflow 追加管理块(原内容保留),lefthook 仍跳过 std::fs::write(base.join("AGENTS.md"), "用户自己的内容").unwrap(); let r2 = dispatch_templates(pp); let agents_r = r2.iter().find(|t| t.name == "AGENTS.md").unwrap(); assert_eq!(agents_r.action, "appended", "无标记存量文件应追加管理块"); let kept = std::fs::read_to_string(base.join("AGENTS.md")).unwrap(); assert!(kept.starts_with("用户自己的内容"), "用户内容必须保留在原位"); let lefthook_r = r2.iter().find(|t| t.name == "lefthook.yml").unwrap(); assert_eq!(lefthook_r.action, "skipped", "lefthook 保持存在即跳过"); let _ = std::fs::remove_dir_all(&base); } // ── T1:版本化三态分发 ──────────────────────────────────────────────── #[test] fn versioned_dispatch_three_states() { let base = std::env::temp_dir().join(format!("lianjing_onb_ver_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); // 态一:不存在 → 写全新(含管理块标记) let r1 = dispatch_templates(pp); assert!(r1.iter().all(|t| t.action == "written")); let agents = std::fs::read_to_string(base.join("AGENTS.md")).unwrap(); assert!(agents.contains(MANAGED_START) && agents.contains(MANAGED_END)); // 态二(同版本):有标记且版本一致 → skipped,文件不动 let r2 = dispatch_templates(pp); let agents_r = r2.iter().find(|t| t.name == "AGENTS.md").unwrap(); assert_eq!(agents_r.action, "skipped"); // 态二(旧版本):把版本改旧 + 块外加用户内容 → updated,块升级、用户内容保留 // 版本号从模板动态取,避免模板升版时测试跟着改(曾因硬编码 1.2.0 断裂) let tpl_ver = extract_managed_block(TMPL_AGENTS) .and_then(block_version) .expect("模板必须带版本标记"); let old = agents .replace(&format!("version=\"{tpl_ver}\""), "version=\"1.0.0\"") .replace( "## 项目概况", "## 项目概况 我的项目说明", ) + " ## 用户自加的章节 内容 "; std::fs::write(base.join("AGENTS.md"), &old).unwrap(); let r3 = dispatch_templates(pp); let agents_r = r3.iter().find(|t| t.name == "AGENTS.md").unwrap(); assert_eq!(agents_r.action, "updated"); let upgraded = std::fs::read_to_string(base.join("AGENTS.md")).unwrap(); assert!( upgraded.contains(&format!("version=\"{tpl_ver}\"")), "块应升级到模板版本" ); assert!( upgraded.contains("我的项目说明"), "块外用户内容(前)须保留" ); assert!( upgraded.contains("用户自加的章节"), "块外用户内容(后)须保留" ); assert_eq!(upgraded.matches(MANAGED_START).count(), 1, "只有一个管理块"); // 态三:存量文件无标记 → appended,原内容保留在上方 std::fs::write( base.join("AGENTS.md"), "# 手写的 AGENTS 自定义内容", ) .unwrap(); let r4 = dispatch_templates(pp); let agents_r = r4.iter().find(|t| t.name == "AGENTS.md").unwrap(); assert_eq!(agents_r.action, "appended"); let appended = std::fs::read_to_string(base.join("AGENTS.md")).unwrap(); assert!(appended.starts_with("# 手写的 AGENTS")); assert!(appended.contains(MANAGED_START)); // lefthook 始终保持"存在即跳过" let lefthook_r = r4.iter().find(|t| t.name == "lefthook.yml").unwrap(); assert_eq!(lefthook_r.action, "skipped"); let _ = std::fs::remove_dir_all(&base); } // ── V1:接入生效验收纯函数 ──────────────────────────────────────────── #[test] fn hook_detection_covers_mounted_and_foreign() { assert!( hook_is_lefthook("#!/bin/sh\ncall lefthook run pre-commit"), "lefthook 接管应判 true" ); assert!( !hook_is_lefthook("#!/bin/sh\nnpx husky run"), "非 lefthook hook 应判 false" ); } #[test] fn origin_parsing_and_host_extraction() { let cfg = "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n[remote \"mirror\"]\n\turl = https://gitee.com/x/y.git\n"; assert_eq!( parse_origin_url(cfg).as_deref(), Some("https://git.example.com/o/r.git"), "应取 origin 而非其他 remote" ); assert_eq!( parse_origin_url("[core]\n\tbare = false"), None, "无 origin 应返回 None" ); assert_eq!( url_host("https://git.example.com/o/r.git").as_deref(), Some("git.example.com") ); assert_eq!( url_host("https://user@git.example.com:3000/o/r.git").as_deref(), Some("git.example.com"), "应忽略用户名与端口" ); assert_eq!( url_host("git@git.example.com:o/r.git").as_deref(), Some("git.example.com"), "ssh 形式" ); } #[test] fn origin_check_match_and_mismatch() { let hosts = vec!["git.example.com".to_string()]; assert!( check_origin(Some("git.example.com"), &hosts).0, "命中实例应 pass" ); assert!(!check_origin(Some("github.com"), &hosts).0, "未命中应 fail"); assert!(!check_origin(None, &hosts).0, "无 origin 应 fail"); assert!( !check_origin(Some("git.example.com"), &[]).0, "无登记实例应 fail" ); } #[test] fn project_context_and_pack_checks_report_actionable_states() { let ready = check_project_context( Some(r#"{"schema_version":1,"project_id":"p1"}"#), None, "p1", ); assert_eq!(ready.state, VerifyState::Ready); assert!(ready.pass); let legacy = check_project_context(None, Some(r#"{"project_id":"p1"}"#), "p1"); assert_eq!(legacy.state, VerifyState::Legacy); assert!(!legacy.pass); let mismatch = check_project_context( Some(r#"{"schema_version":0,"project_id":"p1"}"#), None, "p1", ); assert_eq!(mismatch.state, VerifyState::VersionMismatch); let missing = check_project_context(None, None, "p1"); assert_eq!(missing.state, VerifyState::Missing); let unavailable = check_project_context(Some("not-json"), None, "p1"); assert_eq!(unavailable.state, VerifyState::Unavailable); let pack_ready = check_capability_packs(Some(TMPL_CAPABILITY_PACKS)); assert_eq!(pack_ready.state, VerifyState::Ready); let pack_old = check_capability_packs(Some(r#"{"schema_version":0,"packs":[]}"#)); assert_eq!(pack_old.state, VerifyState::VersionMismatch); let outdated_pack = check_capability_packs(Some( r#"{"schema_version":1,"packs":[{"pack_id":"base-onboarding","version":"1.0.0"}]}"#, )); assert_eq!(outdated_pack.state, VerifyState::VersionMismatch); assert!(outdated_pack.detail.contains("重跑接入包可升级")); } #[test] fn verify_onboarding_full_chain() { let conn = crate::db::conn_with_schema(); let base = std::env::temp_dir().join(format!("lianjing_verify_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); // 空目录:所有检查均不通过(真实反映"什么都没接") let checks = verify_onboarding(&conn, "p1", &base); assert_eq!(checks.len(), 11, "检查链应为十一项"); assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过"); // 铺齐全部生效条件后:所有检查全通过 dispatch_templates(base.to_str().unwrap()); std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); std::fs::write( base.join(".lian-jing/context.json"), r#"{"schema_version":1,"project_id":"p1"}"#, ) .unwrap(); std::fs::create_dir_all(base.join(".codex")).unwrap(); std::fs::write( base.join(".codex/config.toml"), "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n", ) .unwrap(); std::fs::create_dir_all(base.join(".git/hooks")).unwrap(); std::fs::write( base.join(".git/hooks/pre-commit"), "call lefthook run pre-commit", ) .unwrap(); std::fs::write( base.join(".git/config"), "[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n", ) .unwrap(); std::fs::create_dir_all(base.join(".gitea/workflows")).unwrap(); std::fs::write(base.join(".gitea/workflows/pr-check.yml"), "name: x").unwrap(); conn.execute( "INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1','t','https://git.example.com','tok')", [], ) .unwrap(); conn.execute( "INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at) VALUES ('abc','p1','feat',0,0,datetime('now'),datetime('now'))", [], ) .unwrap(); let checks = verify_onboarding(&conn, "p1", &base); for c in &checks { assert!(c.pass, "「{}」应通过,实际: {}", c.name, c.detail); } let _ = std::fs::remove_dir_all(&base); } #[test] fn managed_version_check_states() { let expected = extract_managed_block(TMPL_AGENTS) .and_then(block_version) .unwrap(); assert!( check_managed_version(Some(TMPL_AGENTS), expected).0, "同版本应 pass" ); let old = TMPL_AGENTS.replacen(&format!("version=\"{expected}\""), "version=\"0.0.1\"", 1); let (pass, detail) = check_managed_version(Some(&old), expected); assert!( !pass && detail.contains("0.0.1"), "旧版本应 fail 且报出版本号" ); assert!(!check_managed_version(None, expected).0, "文件缺失应 fail"); assert!( !check_managed_version(Some("手写内容,无标记"), expected).0, "无标记应 fail" ); } /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。 #[test] fn apply_rejects_empty_or_missing_path() { let err = apply_onboarding_pack(String::new(), "p".into()).unwrap_err(); assert!(err.contains("拒绝分发"), "空路径应拒绝: {err}"); let err = apply_onboarding_pack(" ".into(), "p".into()).unwrap_err(); assert!(err.contains("拒绝分发"), "空白路径应拒绝: {err}"); let err = apply_onboarding_pack("E:/不存在的目录/xyz-404".into(), "p".into()).unwrap_err(); assert!(err.contains("拒绝分发"), "不存在路径应拒绝: {err}"); } // ── U1:全托管文件三态 + lefthook 检查段自省 ───────────────────────────── #[test] fn fully_managed_dispatch_three_states() { let base = std::env::temp_dir().join(format!("lianjing_fm_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); // 不存在 → written let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); assert_eq!(r.action, "written"); // 一致 → skipped(CRLF 检出也视为一致) let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); assert_eq!(r.action, "skipped"); std::fs::write(base.join("envoy.md"), "v2 内容".replace('\n', "\r\n")).unwrap(); let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); assert_eq!(r.action, "skipped", "仅行尾差异不应触发覆盖"); // 不一致(旧版模板/被改动)→ 覆盖 updated std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap(); let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容"); assert_eq!(r.action, "updated"); assert_eq!( std::fs::read_to_string(base.join("envoy.md")).unwrap(), "v2 内容" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn dispatch_templates_upgrades_fully_managed_files() { let base = std::env::temp_dir().join(format!("lianjing_fm_e2e_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); let pp = base.to_str().unwrap(); dispatch_templates(pp); // 模拟旧版本 envoy:内容与当前模板不同 → 重跑接入包应覆盖为最新 std::fs::write(base.join(".claude/commands/envoy.md"), "旧版使者指令").unwrap(); let r2 = dispatch_templates(pp); let envoy = r2 .iter() .find(|t| t.name == ".claude/commands/envoy.md") .unwrap(); assert_eq!(envoy.action, "updated", "全托管文件应被升级,而非跳过"); let gate = r2 .iter() .find(|t| t.name == ".claude/hooks/blueprint-gate.cjs") .unwrap(); assert_eq!(gate.action, "skipped", "未动过的全托管文件重跑应 skipped"); let _ = std::fs::remove_dir_all(&base); } #[test] fn lefthook_keys_extraction_and_check() { // 从内嵌模板自省:至少含 typecheck 与 commit-msg 侧检查段 let tpl_keys = lefthook_command_keys(TMPL_LEFTHOOK); assert!( tpl_keys.contains(&"typecheck".to_string()), "模板应含 typecheck: {tpl_keys:?}" ); assert!(tpl_keys.len() >= 3, "模板检查段应不止一个: {tpl_keys:?}"); // 注释掉的示例段不提取 assert!( !tpl_keys.contains(&"api-typecheck".to_string()), "注释行不算键" ); // 项目文件与模板一致 → pass let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK); assert!(pass); // 缺段 → fail 且列出缺失键 let partial = "pre-commit:\n commands:\n typecheck:\n run: pnpm typecheck\n"; let (pass, detail) = check_lefthook_keys(Some(partial), TMPL_LEFTHOOK); assert!(!pass); assert!(detail.contains("缺少检查段"), "应列出缺失键: {detail}"); assert!( !detail.contains("typecheck,") && detail.contains("blueprint-gate"), "已有键不在缺失列表: {detail}" ); // 文件缺失 → fail assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0); } // ── B4:使者回传覆盖率(沉默项目信号)──────────────────────────────────── #[test] fn silent_projects_flags_active_without_returns() { let conn = crate::db::conn_with_schema(); // 两个已登记项目:pw-a 有 commit 无回传(沉默);pw-b 有 commit 有回传 conn.execute_batch( "INSERT INTO project_profiles (id, name) VALUES ('prof-a','项目A'),('prof-b','项目B'); INSERT INTO project_workspaces (id, profile_id, win_path) VALUES ('pw-a','prof-a','E:/a'),('pw-b','prof-b','E:/b');", ) .unwrap(); // pw-a 的 commit 走 blueprint_buffs UUID 关联(生产真实形状) conn.execute( "INSERT INTO blueprint_buffs (project_id, project_path) VALUES ('uuid-a','E:/a')", [], ) .unwrap(); conn.execute( "INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at) VALUES ('c1','uuid-a','feat',0,0,datetime('now'),datetime('now')), ('c2','uuid-a','fix',1,0,datetime('now'),datetime('now')), ('c3','pw-b','feat',0,0,datetime('now'),datetime('now'))", [], ) .unwrap(); // pw-b 近 30 天有使者回传;pw-a 只有普通笔记(不算回传) conn.execute( "INSERT INTO project_notes (project_id, content) VALUES ('pw-b','【炼境使者回传】来源: 项目B ...'), ('pw-a','普通笔记,不是回传')", [], ) .unwrap(); // 未登记项目的 commit 不列入(无法跑 /envoy) conn.execute( "INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at) VALUES ('c9','orphan','feat',0,0,datetime('now'),datetime('now'))", [], ) .unwrap(); let silent = silent_projects(&conn).unwrap(); assert_eq!(silent.len(), 1, "只有 pw-a 沉默:{silent:?}"); assert_eq!(silent[0].0, "项目A", "经 buff UUID 关联解析出名称"); assert_eq!(silent[0].1, 2, "commit 数按项目聚合"); // 老回传(>30 天)不算覆盖:把 pw-b 的回传改旧后应变沉默 conn.execute( "UPDATE project_notes SET created_at = datetime('now','localtime','-40 days') WHERE project_id = 'pw-b'", [], ) .unwrap(); let silent = silent_projects(&conn).unwrap(); assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默"); } }