//! 接入包模板分发(基线档)。 //! //! 版本化分发(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"); pub(crate) 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 TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl"); const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; const BASE_ONBOARDING_PACK_VERSION: &str = "1.9.0"; const TOOLCHAIN_PACK_VERSION: &str = "1.0.0"; /// 单个模板的分发结果。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct TemplateResult { /// 相对项目根的目标路径 pub name: String, /// "written" | "updated" | "appended" | "skipped" | "failed" pub action: String, pub detail: String, /// 混合文件预警(P10):写入前该文件在 git 中已有托管块之外的未提交改动, /// 本次又改写了其中的托管块。只提示不阻断,块外内容仍不触碰。 #[serde(default, skip_serializing_if = "Option::is_none")] pub warning: Option, } impl TemplateResult { fn new(name: &str, action: &str, detail: impl Into) -> Self { Self { name: name.to_string(), action: action.to_string(), detail: detail.into(), warning: None, } } } /// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。 /// 只判存在、不读内容:已存在的非 UTF-8 文件(UTF-16、GBK 等项目自有文件)照旧跳过。 fn dispatch_one( root: &Path, relative: &str, content: &str, sink: &dyn crate::WriteSink, ) -> TemplateResult { let target = root.join(relative); if sink.exists(&target) { return TemplateResult::new(relative, "skipped", "已存在,未覆盖"); } match sink.write(&target, content) { Ok(_) => TemplateResult::new(relative, "written", "已写入"), Err(e) => TemplateResult::new(relative, "failed", format!("写入失败: {e}")), } } /// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。 /// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。 /// 比较忽略行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。 /// 已存在但读不出(非 UTF-8、无权限)记 failed,不盲目覆盖(P8c 起;原为直接覆盖)。 fn dispatch_fully_managed( root: &Path, relative: &str, content: &str, sink: &dyn crate::WriteSink, ) -> TemplateResult { let target = root.join(relative); match sink.read(&target) { Ok(Some(_)) => {} Ok(None) => return dispatch_one(root, relative, content, sink), Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")), } match sink.write(&target, content) { Ok(false) => TemplateResult::new(relative, "skipped", "内容已是最新模板"), Ok(true) => TemplateResult::new(relative, "updated", "全托管文件已覆盖为最新模板"), Err(e) => TemplateResult::new(relative, "failed", format!("覆盖失败: {e}")), } } fn node_pnpm_contract_present(root: &Path) -> bool { if !root.join(".nvmrc").is_file() { return false; } std::fs::read_to_string(root.join("package.json")) .ok() .and_then(|content| serde_json::from_str::(&content).ok()) .and_then(|package| { package .get("packageManager") .and_then(|value| value.as_str()) .map(|value| value.starts_with("pnpm@")) }) .unwrap_or(false) } fn dispatch_toolchain_resolver(root: &Path, sink: &dyn crate::WriteSink) -> TemplateResult { if !node_pnpm_contract_present(root) { return TemplateResult::new( "mise.toml", "skipped", "非 .nvmrc + pnpm packageManager 项目,不适用 Node/pnpm 工具链包", ); } dispatch_one(root, "mise.toml", TMPL_MISE, sink) } /// 从模板中提取 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, sink: &dyn crate::WriteSink, ) -> TemplateResult { let target = root.join(relative); let tpl_block = match extract_managed_block(template) { Some(b) => b, None => return dispatch_one(root, relative, template, sink), // 模板无标记,退化为旧行为 }; let tpl_ver = block_version(tpl_block).unwrap_or("?"); let content = match sink.read(&target) { Ok(Some(c)) => c, Ok(None) => return dispatch_one(root, relative, template, sink), Err(e) => return TemplateResult::new(relative, "failed", 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::new(relative, "skipped", 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 sink.write(&target, &new_content) { Ok(_) => TemplateResult::new(relative, action, detail), Err(e) => TemplateResult::new(relative, "failed", 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(['/', '\\'])); dispatch_templates_with(root, &crate::DiskSink) } /// dispatch_templates 的写入汇注入版:plan 用记录汇得到同一份结果而不落盘(P8c)。 pub(crate) fn dispatch_templates_with( root: &Path, sink: &dyn crate::WriteSink, ) -> Vec { vec![ dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS, sink), dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK, sink), dispatch_versioned( root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW, sink, ), dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY, sink), dispatch_fully_managed( root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE, sink, ), dispatch_fully_managed( root, ".agents/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL, sink, ), // Claude Code 不读 .agents/skills,同一模板投影一份(单一来源,双投影)。 dispatch_fully_managed( root, ".claude/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL, sink, ), dispatch_fully_managed( root, ".lian-jing/capability-packs.json", TMPL_CAPABILITY_PACKS, sink, ), dispatch_toolchain_resolver(root, sink), ] } // ── P10:混合文件预警(升级托管块前检测块外的未提交改动)──────────────────── // // 为什么:2026-10-01 enterprise 的 git-workflow.md 在有项目未提交改写时被升级了托管块, // 同一文件混有炼境与项目两种来源的改动;按「禁止字符串手术」无法拆分提交,只能交人定案。 // 默认仍照常升级(只改托管块),但必须让调用方知道哪个文件因此成了混合文件。 /// 接入包会原位改写托管块的文件:AGENTS.md、git-workflow.md 由模板分发写 ONBOARDING_MANAGED, /// AGENTS.md、CLAUDE.md 另由 buff 的蓝图规则同步写 BLUEPRINT_MANAGED。 pub(crate) const MANAGED_BLOCK_FILES: [&str; 3] = ["AGENTS.md", "docs/ai-context/git-workflow.md", "CLAUDE.md"]; /// 比较块外内容时整体剔除的托管块(开始标记前缀,结束标记)。 const STRIPPED_MANAGED_BLOCKS: [(&str, &str); 2] = [ (MANAGED_START, MANAGED_END), ( crate::commands::blueprint::BLUEPRINT_MANAGED_START, crate::commands::blueprint::BLUEPRINT_MANAGED_END, ), ]; /// 托管块剔除后在原位留下的占位符(正常文本不含 NUL)。 const BLOCK_PLACEHOLDER: char = '\0'; /// 去掉全部托管块(含标记)后的块外内容。只抵消炼境自己写入带来的差异,其余空白差异 /// (块外空行增删、行尾空格——Markdown 里分别是段落合并与硬换行)一律算项目改动: /// - CRLF 统一为 LF(子项目常开 core.autocrlf); /// - 托管块原位换成占位符:文中的块两侧空行原样参与比较(炼境只原位替换文中的块,不碰两侧); /// - 结尾的占位符连同其间、其前的空白整体剔除:炼境追加托管块写的是 /// `原文.trim_end() + "\n\n" + 块 + "\n"`,追加前原文末尾的空白也被 trim_end 吃掉, /// 所以只有文末这段按 trim_end 归一。 fn outside_managed_content(text: &str) -> String { let mut rest = text.replace("\r\n", "\n"); let placeholder = BLOCK_PLACEHOLDER.to_string(); for (start, end) in STRIPPED_MANAGED_BLOCKS { while let Some(s) = rest.find(start) { let Some(len) = rest[s..].find(end) else { break; // 残缺块(无结束标记):余下内容按块外处理 }; rest.replace_range(s..s + len + end.len(), &placeholder); } } rest.trim_end_matches(|c: char| c.is_whitespace() || c == BLOCK_PLACEHOLDER) .to_string() } /// HEAD 版本(None = 不在 HEAD 中:未跟踪、仅暂存或仓库尚无提交)与工作区版本的块外内容是否不同。 fn outside_managed_differs(head: Option<&str>, worktree: &str) -> bool { outside_managed_content(head.unwrap_or("")) != outside_managed_content(worktree) } /// 目标文件在 git 中是否有托管块之外的未提交改动(未跟踪文件带块外内容也算)。 /// 非 git 仓库、git 不可用或命令失败一律返回 false:只做提示,探测失败不打扰调用方。 pub(crate) fn has_uncommitted_outside_managed(root: &Path, relative: &str, worktree: &str) -> bool { // --no-optional-locks:后台探测不抢 index.lock,避免与 agent 同时进行的提交冲突 let status = crate::silent_cmd("git") .args([ "--no-optional-locks", "status", "--porcelain", "--untracked-files=all", "--", relative, ]) .current_dir(root) .output(); match status { Ok(out) if out.status.success() => { if out.stdout.iter().all(u8::is_ascii_whitespace) { return false; // 与 HEAD 一致(或被 .gitignore 忽略),没有未提交改动 } } _ => return false, } // `HEAD:./` 按 cwd 解析,项目根是仓库子目录时同样正确 let head = crate::silent_cmd("git") .args(["show", &format!("HEAD:./{relative}")]) .current_dir(root) .output() .ok() .filter(|out| out.status.success()) .map(|out| String::from_utf8_lossy(&out.stdout).into_owned()); outside_managed_differs(head.as_deref(), worktree) } pub(crate) fn mixed_file_warning(relative: &str) -> String { format!( "{relative}:块外有未提交改动,升级后将成为混合文件——本次只改写了其中的炼境托管块,块外内容未动;\ 提交前先确认块外改动的归属(不得用字符串手术拆分暂存),无法整体提交时交人定案。" ) } /// 写入前快照:块外已有未提交改动的托管块文件,及其写入前内容。 struct MixedFileGuard { dirty: Vec<(&'static str, String)>, } impl MixedFileGuard { fn snapshot(root: &Path) -> Self { let dirty = MANAGED_BLOCK_FILES .into_iter() .filter_map(|relative| { let before = std::fs::read_to_string(root.join(relative)).ok()?; has_uncommitted_outside_managed(root, relative, &before) .then_some((relative, before)) }) .collect(); Self { dirty } } /// 写入后:快照里本次确被改写的文件标注 warning;不在模板结果里的 /// (CLAUDE.md 只由蓝图规则同步改写)补一条结果,免得预警无处可见。 fn annotate(self, root: &Path, templates: &mut Vec) { for (relative, before) in self.dirty { let Ok(after) = std::fs::read_to_string(root.join(relative)) else { continue; }; if crate::same_text_ignoring_eol(&before, &after) { continue; // 本次没改写它,不会制造混合文件 } let warning = Some(mixed_file_warning(relative)); match templates.iter_mut().find(|t| t.name == relative) { Some(result) => result.warning = warning, None => { let action = if before.contains(crate::commands::blueprint::BLUEPRINT_MANAGED_START) { "updated" } else { "appended" }; let mut result = TemplateResult::new( relative, action, "蓝图规则托管块(BLUEPRINT_MANAGED)已同步,块外内容未动", ); result.warning = warning; templates.push(result); } } } } } /// 模板分发与其后的写入步骤(apply 时为 lefthook 激活与 buff 蓝图规则同步)前后夹混合文件检测: /// 写入前快照块外未提交改动,写入后只对确被改写的文件给出预警。 /// 后续步骤报错时文件可能已被改写(如 buff 先同步了蓝图规则、再写 DB 失败),预警是混合文件 /// 已产生的唯一信号,不能随 Err 丢失:先标注,再把混合文件名并入错误信息。 fn dispatch_templates_with_mixed_file_check( root: &Path, then: impl FnOnce() -> Result, ) -> Result<(Vec, T), String> { let guard = MixedFileGuard::snapshot(root); let mut templates = dispatch_templates(&root.to_string_lossy()); let rest = then(); guard.annotate(root, &mut templates); match rest { Ok(rest) => Ok((templates, rest)), Err(e) => { let mixed: Vec<&str> = templates .iter() .filter(|t| t.warning.is_some()) .map(|t| t.name.as_str()) .collect(); if mixed.is_empty() { Err(e) } else { Err(format!( "{e};注意:以下文件块外有未提交改动,本次已改写其中的炼境托管块,成为混合文件:{}", mixed.join("、") )) } } } } // ── 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(本体在 onboard::apply::lefthook_install,与一键接入同一实现):优先项目本地 /// node_modules/.bin,回退全局 PATH(Windows 另试 lefthook.cmd);带 60 秒超时。 fn run_lefthook_install(root: &Path) -> HookActivation { use crate::onboard::apply::LefthookInstall; let (status, detail) = match crate::onboard::apply::lefthook_install( root, &crate::onboard::probe_l1::SystemRunner, ) { LefthookInstall::Activated(detail) => ("activated", detail), LefthookInstall::MissingBinary(detail) => ("missing_binary", detail), LefthookInstall::Failed(detail) => ("failed", detail), }; HookActivation { status: status.into(), detail, } } /// 接入包应用报告:模板分发结果 + 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 观测/飞轮采集)。 /// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。 // buff 的独立产品组会注入 MCP(可能起 claude CLI,单次至多 20 秒),lefthook install 也是子进程: // 在线程池执行,不占界面线程(P8d) #[tauri::command(async)] 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, (hook, buff)) = dispatch_templates_with_mixed_file_check(root, || { let hook = run_lefthook_install(root); let buff = crate::commands::buff::apply_blueprint_buff(project_path.clone(), project_id)?; Ok((hook, buff)) })?; Ok(OnboardingReport { templates, hook, buff, }) } /// 三端对等 L1 零模型检查(agent-client-parity P4a)的 UI 投影,与 MCP probe_client_parity 同一本体。 /// 会起零模型的客户端自省进程并回调本机 MCP 服务(约 5–10 秒),放到阻塞线程池执行,不占界面线程。 #[tauri::command] pub async fn probe_client_parity( project_id: String, tier: Option, ) -> Result { tokio::task::spawn_blocking(move || { crate::mcp_inject::probe_client_parity(&project_id, tier.as_deref()) }) .await .map_err(|error| format!("L1 检查线程异常: {error}"))? } // ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)────── /// 单项验收结果。 #[derive(Debug, Serialize, Deserialize, Clone)] pub struct VerifyCheck { pub name: String, pub pass: bool, pub state: VerifyState, pub detail: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct OnboardingVerification { pub project_id: String, pub pass: bool, pub summary: String, pub checks: Vec, pub runtime: crate::commands::settings::AgentRuntimeStatus, /// 三端客户端对本项目的只读配置状态(P8b 附加字段):不计入 checks、不影响 pass。 #[serde(default)] pub clients: Vec, } pub fn summarize_onboarding( project_id: &str, checks: Vec, runtime: crate::commands::settings::AgentRuntimeStatus, ) -> OnboardingVerification { let passed = checks.iter().filter(|check| check.pass).count(); let pass = passed == checks.len() && runtime.ready; OnboardingVerification { project_id: project_id.to_string(), pass, summary: format!( "静态接入 {passed}/{} 项通过;Agent runtime {}", checks.len(), if runtime.ready { "ready" } else { "未就绪" } ), checks, runtime, clients: Vec::new(), } } /// 接入检查的可行动状态。只有 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 project_id != Some(expected_project_id) { VerifyCheck::with_state( "项目身份上下文", VerifyState::Unavailable, "`.lian-jing/context.json` 的 project_id 与炼境登记不一致——停止使用并重新 inject_mcp", ) } else if version != Some(PROJECT_CONTEXT_SCHEMA_VERSION) { VerifyCheck::with_state( "项目身份上下文", VerifyState::VersionMismatch, format!( "context schema_version={:?},当前为 {}——调用 inject_mcp 可升级", version, PROJECT_CONTEXT_SCHEMA_VERSION ), ) } else { VerifyCheck::with_state( "项目身份上下文", VerifyState::Ready, "`.lian-jing/context.json` 与已登记 project_id 一致", ) } } 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 pack_version = |pack_id: &str| { 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(pack_id) }) }) .and_then(|pack| pack.get("version")) .and_then(|value| value.as_str()) }; let base_version = pack_version("base-onboarding"); let toolchain_version = pack_version("toolchain-node-pnpm"); if schema_version == Some(CAPABILITY_PACK_SCHEMA_VERSION) && base_version == Some(BASE_ONBOARDING_PACK_VERSION) && toolchain_version == Some(TOOLCHAIN_PACK_VERSION) { VerifyCheck::with_state( "基础能力包描述符", VerifyState::Ready, format!( "base-onboarding v{BASE_ONBOARDING_PACK_VERSION}、toolchain-node-pnpm v{TOOLCHAIN_PACK_VERSION}(最新)" ), ) } else { VerifyCheck::with_state( "基础能力包描述符", VerifyState::VersionMismatch, format!( "能力包 schema={schema_version:?}、base-onboarding={base_version:?}、toolchain-node-pnpm={toolchain_version:?};当前应为 schema={}、base v{}、toolchain v{}——重跑接入包可升级", CAPABILITY_PACK_SCHEMA_VERSION, BASE_ONBOARDING_PACK_VERSION, TOOLCHAIN_PACK_VERSION ), ) } } fn check_delivery_profile(content: &str) -> VerifyCheck { match crate::commands::delivery_status::parse_production_profile(content) { Ok(Some(profile)) => VerifyCheck::with_state( "production delivery profile", VerifyState::Ready, format!( ".lian-jing/capabilities.yaml 已声明 {} 个只读组件探针;项目文件由项目自行维护,接入包不会覆盖", profile.components.len() ), ), Ok(None) => VerifyCheck::with_state( "production delivery profile", VerifyState::Ready, ".lian-jing/capabilities.yaml 格式有效;未配置 production delivery(可选)", ), Err(error) => VerifyCheck::with_state( "production delivery profile", VerifyState::Unavailable, format!(".lian-jing/capabilities.yaml 无效:{error};请在项目内修复,接入包不会覆盖"), ), } } fn check_toolchain_resolver(root: &Path) -> VerifyCheck { match crate::commands::toolchain::parse_toolchain_contract(root) { Err(error) => { VerifyCheck::with_state("Node/pnpm 工具链 resolver", VerifyState::Unavailable, error) } Ok(contract) if !contract.conflicts.is_empty() => VerifyCheck::with_state( "Node/pnpm 工具链 resolver", VerifyState::VersionMismatch, format!("版本声明冲突: {}", contract.conflicts.join(", ")), ), Ok(contract) if contract.resolver.configured && (contract.resolver.node_idiomatic || contract.node.source.as_deref() == Some("mise.toml#tools.node")) && (contract.resolver.pnpm_idiomatic || contract.pnpm.source.as_deref() == Some("mise.toml#tools.pnpm")) => { VerifyCheck::with_state( "Node/pnpm 工具链 resolver", VerifyState::Ready, "mise.toml 已消费项目的 Node/pnpm 精确版本声明", ) } Ok(_) => VerifyCheck::with_state( "Node/pnpm 工具链 resolver", VerifyState::Missing, "缺少可消费 .nvmrc 与 packageManager 的 mise.toml;重跑接入包可补充,已有文件不会覆盖", ), } } /// 纯函数: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 } } /// 读取 Codex MCP 静态配置。运行态可用性由 settings::get_agent_runtime_status 独立探测。 fn codex_mcp_server_url(content: &str) -> Option<&str> { let mut in_lian_jing = false; for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') { in_lian_jing = trimmed == "[mcp_servers.lian-jing]"; continue; } if in_lian_jing { let Some(raw_url) = trimmed.strip_prefix("url =") else { continue; }; return Some(raw_url.trim().trim_matches('"')); } } None } /// helper 的旧写法需要重注入升级:最早把本机 exe 绝对路径写进 helper(换构建即失效、换机器即错); /// 之后改为 %LOCALAPPDATA% 下的入口,但 Codex Desktop(MSIX)看不见该目录。新注入只引用 /// %USERPROFILE%\.lian-jing 下的稳定入口。只判定显式的旧写法,未配置 helper 不在此处判定。 fn codex_helper_upgrade_hint(content: &str) -> Option<&'static str> { let mut in_lian_jing = false; for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') { in_lian_jing = trimmed == "[mcp_servers.lian-jing]"; continue; } if in_lian_jing && trimmed.starts_with("http_headers_helper") { if trimmed.contains("%LOCALAPPDATA%") { return Some("helper 仍引用 %LOCALAPPDATA% 下的旧入口(炼境若在 MSIX 应用的进程树里启动,例如由 Claude 桌面版里的 agent 启动,该目录会被重定向到那个应用的私有目录,Codex Desktop 等其他客户端取认证失败)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); } if trimmed.contains("--lianjing-mcp-header") { return Some("helper 仍写着本机 exe 绝对路径(换构建即失效,且会随仓库提交)——调用 inject_mcp 升级为 %USERPROFILE%\\.lian-jing 稳定入口"); } return None; } } None } pub(crate) fn check_codex_mcp( content: Option<&str>, expected_project_id: &str, expected_port: u16, ) -> VerifyCheck { let Some(content) = content else { return VerifyCheck::with_state( "Codex MCP 已配置", VerifyState::Missing, ".codex/config.toml 缺失——调用 inject_mcp 后生成", ); }; let expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id); let configured_url = codex_mcp_server_url(content); let url_matches = configured_url == Some(expected_url.as_str()); if let Some(hint) = codex_helper_upgrade_hint(content).filter(|_| url_matches) { VerifyCheck::with_state("Codex MCP 已配置", VerifyState::Legacy, hint) } else if url_matches { VerifyCheck::with_state( "Codex MCP 已配置", VerifyState::Ready, format!("lian-jing 已绑定 project_id={expected_project_id} 的 scoped MCP URL"), ) } else if configured_url .map(|url| url.starts_with("http://127.0.0.1:") && url.ends_with("/mcp")) .unwrap_or(false) { VerifyCheck::with_state( "Codex MCP 已配置", VerifyState::Legacy, "仍使用旧 /mcp 全局 URL——调用 inject_mcp 升级;手工同名 server 不会被覆盖", ) } else { VerifyCheck::with_state( "Codex MCP 已配置", VerifyState::Missing, "未找到当前项目的炼境 scoped MCP 配置——调用 inject_mcp 修复", ) } } fn check_skill(content: Option<&str>) -> (bool, String) { match content { Some(content) if content.contains("name: lian-jing") && content.contains(".lian-jing/context.json") => { (true, "炼境项目技能已分发,并使用 canonical context".into()) } _ => ( false, "缺少炼境项目技能,或其未使用 .lian-jing/context.json——重跑接入包升级".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(", ") ), ), } } /// 从指定 hook 的指定段提取缩进四空格的键集合。 /// 纯函数;注释与更深缩进(run: | 多行体等)忽略。 fn lefthook_section_keys(yml: &str, hook: &str, section: &str) -> Vec { let mut keys = Vec::new(); let mut in_hook = false; let mut in_section = 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 == 0 && trimmed.ends_with(':') { in_hook = trimmed.trim_end().trim_end_matches(':') == hook; in_section = false; continue; } if !in_hook { continue; } if indent == 2 && trimmed.trim_end() == format!("{section}:") { in_section = true; continue; } if indent == 2 { in_section = false; continue; } if indent < 2 { in_section = false; continue; } if in_section && indent == 4 { if let Some(key) = trimmed.trim_end().strip_suffix(':') { keys.push(key.to_string()); } } } keys } /// 从 lefthook.yml 内容提取 commands 键集合。 pub fn lefthook_command_keys(yml: &str) -> Vec { let mut keys = lefthook_section_keys(yml, "pre-commit", "commands"); keys.extend(lefthook_section_keys(yml, "commit-msg", "commands")); keys } /// U1 检查:项目 lefthook.yml 是否缺少模板要求的检查能力。 /// /// lefthook.yml 是项目可定制文件,不能把模板中的键名当成唯一实现。保留模板 /// 自省作为基线,但识别两类等价能力:分层 typecheck 命令,以及 commit-msg /// 目录中的 Conventional script。其余模板检查仍要求同名命令,避免将任意注释或 /// 无关脚本误报为 ready。 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 commands = lefthook_command_keys(yml); let commit_msg_scripts = lefthook_section_keys(yml, "commit-msg", "scripts"); let missing: Vec = lefthook_command_keys(template_yml) .into_iter() .filter(|required| match required.as_str() { "typecheck" => !commands.iter().any(|key| key.contains("typecheck")), "conventional" => !commands .iter() .chain(commit_msg_scripts.iter()) .any(|key| key.contains("conventional")), _ => !commands.contains(required), }) .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(), )); // delivery profile 是可选的项目所有声明;缺席不降低基础 onboarding 的通过率。 let delivery_path = root.join(".lian-jing/capabilities.yaml"); if delivery_path.exists() { checks.push(match std::fs::read_to_string(&delivery_path) { Ok(content) => check_delivery_profile(&content), Err(error) => VerifyCheck::with_state( "production delivery profile", VerifyState::Unavailable, format!("无法读取项目声明:{error}"), ), }); } if node_pnpm_contract_present(root) || root.join("mise.toml").exists() { checks.push(check_toolchain_resolver(root)); } 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_skill(read(".claude/skills/lian-jing/SKILL.md").as_deref()); checks.push(VerifyCheck::from_pass( "Claude 炼境技能已分发", pass, detail, )); checks.push(check_codex_mcp( read(".codex/config.toml").as_deref(), project_id, crate::mcp_server::read_port_from_conn(conn), )); // 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——先调 plan_cicd_workflow 预览,用户确认摘要后再调 apply_cicd_workflow 写入".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)不列入。 /// 每条 commit 只归属一个 workspace(直接 ID 优先,其次 buff 路径):同路径重复登记时 /// 若按「ID 或路径」任一匹配计数,一行会吃到多条登记的并集(2026-10-03 真机 147 / 86)。 pub fn silent_projects(conn: &rusqlite::Connection) -> Result, String> { let mut stmt = conn .prepare( "WITH recent AS ( SELECT COALESCE( (SELECT pw.id FROM project_workspaces pw WHERE pw.id = cm.project_id), (SELECT MIN(pw.id) FROM blueprint_buffs bb JOIN project_workspaces pw ON pw.win_path = bb.project_path WHERE bb.project_id = cm.project_id) ) AS ws_id FROM commit_metrics cm WHERE cm.committed_at >= datetime('now','-30 days') ) SELECT COALESCE(pp.name, pw.id) AS name, COUNT(*) AS commits FROM recent r JOIN project_workspaces pw ON pw.id = r.ws_id LEFT JOIN project_profiles pp ON pw.profile_id = pp.id 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(); // 首次:基础接入包全部写入;非 Node/pnpm 项目跳过工具链 resolver let r1 = dispatch_templates(pp); assert!( r1.iter() .filter(|t| t.name != "mise.toml") .all(|t| t.action == "written"), "首次基础模板应全部写入" ); assert_eq!( r1.iter().find(|t| t.name == "mise.toml").unwrap().action, "skipped" ); 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() .filter(|t| t.name != "mise.toml") .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); } // ── P10:混合文件预警 ──────────────────────────────────────────────── #[test] fn outside_managed_content_strips_blocks_and_only_normalizes_eol_and_appended_tail() { let with_blocks = "# 标题\r\n\r\n项目内容\r\n\r\n\r\n炼境内容\r\n\r\n\r\n\n蓝图\n\n"; assert_eq!(outside_managed_content(with_blocks), "# 标题\n\n项目内容"); // 之前追加的块尚未提交、只多出块前空行(原文末尾空白被追加时的 trim_end 吃掉):不算改动 assert!(!outside_managed_differs( Some("# 标题\n\n项目内容\n"), with_blocks )); assert!(!outside_managed_differs( Some("# 标题\n\n项目内容 \n\n\n"), with_blocks )); assert!(outside_managed_differs( Some("# 标题\n项目内容"), &with_blocks.replace("项目内容", "改过的项目内容") )); // 不在 HEAD 中:有块外内容即算改动,只有托管块则不算 assert!(outside_managed_differs(None, with_blocks)); assert!(!outside_managed_differs( None, "\nx\n\n" )); // 残缺块(无结束标记)不剔除,余下内容按块外处理 assert_eq!( outside_managed_content("a\n\nb"), "a\n\nb" ); } #[test] fn outside_managed_whitespace_edits_count_as_changes() { let block = |ver: &str| { format!( "\n炼境内容\n" ) }; let head = format!("# 项目文档\n\nA\n\nB\n\n{}\n", block("1.0.0")); // 托管块版本不同、块外一致(含 CRLF 检出):不算改动 assert!(!outside_managed_differs( Some(&head), &format!("# 项目文档\n\nA\n\nB\n\n{}\n", block("2.0.0")).replace('\n', "\r\n") )); // 删掉 A、B 间的空行(Markdown 两段并成一段):算改动 assert!(outside_managed_differs( Some(&head), &format!("# 项目文档\n\nA\nB\n\n{}\n", block("1.0.0")) )); // 给 A 加两个行尾空格(Markdown 硬换行):算改动 assert!(outside_managed_differs( Some(&head), &format!("# 项目文档\n\nA \n\nB\n\n{}\n", block("1.0.0")) )); // 文中托管块两侧的空行由项目改动:算改动(炼境只原位替换文中的块,不碰两侧) let middle = format!("A\n\n{}\n\nB\n", block("1.0.0")); assert!(!outside_managed_differs( Some(&middle), &middle.replace("1.0.0", "2.0.0") )); assert!(outside_managed_differs( Some(&middle), &format!("A\n\n{}\nB\n", block("1.0.0")) )); } fn git_in(dir: &Path, args: &[&str]) -> bool { 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 可执行") .status .success() } /// 托管块版本改旧(分发时必然升级),块外带项目自己的内容。 fn outdated_versioned(template: &str, project_line: &str) -> String { let block = extract_managed_block(template).expect("模板必须带管理块"); let tpl_ver = block_version(block).expect("模板必须带版本标记"); let old_block = block.replace(&format!("version=\"{tpl_ver}\""), "version=\"0.0.1\""); format!("# 项目文档\n\n{project_line}\n\n{old_block}\n") } /// 走与 apply_onboarding_pack 相同的检测夹层;后续写入只做 buff 里的蓝图规则同步 /// (buff 其余部分要连炼境 DB,lefthook 激活不碰托管块文件,均不在此调用)。 fn apply_like(root: &Path) -> Vec { dispatch_templates_with_mixed_file_check(root, || { crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().into_owned()) .map(|_| ()) }) .expect("分发不应失败") .0 } fn result_of<'a>(results: &'a [TemplateResult], name: &str) -> &'a TemplateResult { results .iter() .find(|t| t.name == name) .unwrap_or_else(|| panic!("缺少 {name} 的分发结果")) } #[test] fn mixed_file_warning_only_for_uncommitted_changes_outside_managed_blocks() { let base = std::env::temp_dir().join(format!("lianjing_onb_mixed_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join("docs/ai-context")).unwrap(); let workflow_path = base.join("docs/ai-context/git-workflow.md"); let workflow = outdated_versioned(TMPL_GIT_WORKFLOW, "项目分支约定"); std::fs::write( base.join("AGENTS.md"), outdated_versioned(TMPL_AGENTS, "项目说明"), ) .unwrap(); std::fs::write(&workflow_path, &workflow).unwrap(); assert!(git_in(&base, &["init", "-q"])); assert!(git_in(&base, &["add", "-A"])); assert!(git_in( &base, &["commit", "-q", "--no-verify", "-m", "init"] )); // 情形一:git-workflow.md 块外有未提交改写、CLAUDE.md 未跟踪且含项目内容 → 两者预警; // AGENTS.md 与 HEAD 一致 → 照常升级、无预警 std::fs::write( &workflow_path, workflow.replace("项目分支约定", "项目分支约定(未提交改写)"), ) .unwrap(); std::fs::write(base.join("CLAUDE.md"), "# 项目自己的 CLAUDE.md\n").unwrap(); let r = apply_like(&base); let wf = result_of(&r, "docs/ai-context/git-workflow.md"); assert_eq!(wf.action, "updated", "默认仍升级托管块"); let warning = wf.warning.as_deref().expect("块外有未提交改动应预警"); assert!( warning.contains("docs/ai-context/git-workflow.md") && warning.contains("块外有未提交改动,升级后将成为混合文件"), "预警须含文件名与提示: {warning}" ); let upgraded = std::fs::read_to_string(&workflow_path).unwrap(); let tpl_ver = extract_managed_block(TMPL_GIT_WORKFLOW) .and_then(block_version) .unwrap(); assert!( upgraded.contains(&format!("version=\"{tpl_ver}\"")), "托管块应升级" ); assert!( upgraded.contains("项目分支约定(未提交改写)") && upgraded.starts_with("# 项目文档"), "块外内容不得改动" ); let claude = result_of(&r, "CLAUDE.md"); assert_eq!(claude.action, "appended"); assert!( claude .warning .as_deref() .is_some_and(|w| w.contains("CLAUDE.md")), "未跟踪文件含块外内容也算:{claude:?}" ); let claude_md = std::fs::read_to_string(base.join("CLAUDE.md")).unwrap(); assert!(claude_md.starts_with("# 项目自己的 CLAUDE.md")); let agents = result_of(&r, "AGENTS.md"); assert_eq!(agents.action, "updated"); assert_eq!(agents.warning, None, "块外无未提交改动的文件不应预警"); // 情形二:全部提交后只改托管块(版本退回)并整体转成 CRLF → 照常升级、无预警 assert!(git_in(&base, &["add", "-A"])); assert!(git_in( &base, &["commit", "-q", "--no-verify", "-m", "onboard"] )); let agents_path = base.join("AGENTS.md"); let committed = std::fs::read_to_string(&agents_path).unwrap(); let tpl_ver = extract_managed_block(TMPL_AGENTS) .and_then(block_version) .unwrap(); let block_only = committed .replace(&format!("version=\"{tpl_ver}\""), "version=\"0.0.1\"") .replace('\n', "\r\n"); std::fs::write(&agents_path, block_only).unwrap(); let r = apply_like(&base); assert_eq!(result_of(&r, "AGENTS.md").action, "updated"); assert!( r.iter().all(|t| t.warning.is_none()), "只改托管块(含行尾差异)不应预警: {r:?}" ); assert!( !r.iter().any(|t| t.name == "CLAUDE.md"), "无预警时不补 CLAUDE.md 结果行" ); // 情形三:块外有未提交改动、但托管块已是最新(本次不改写)→ 不预警、不补 CLAUDE.md 行 assert!(git_in(&base, &["add", "-A"])); assert!(git_in( &base, &["commit", "-q", "--no-verify", "-m", "upgrade"] )); let agents_now = std::fs::read_to_string(&agents_path).unwrap(); assert!(agents_now.contains("项目说明")); std::fs::write( &agents_path, agents_now.replacen("项目说明", "项目说明(未提交改写)", 1), ) .unwrap(); let mut workflow_now = std::fs::read_to_string(&workflow_path).unwrap(); workflow_now.push_str("\n块外追加的项目约定\n"); std::fs::write(&workflow_path, workflow_now).unwrap(); let r = apply_like(&base); assert_eq!(result_of(&r, "AGENTS.md").action, "skipped"); assert_eq!( result_of(&r, "docs/ai-context/git-workflow.md").action, "skipped" ); assert!( r.iter().all(|t| t.warning.is_none()), "本次没改写的文件即使块外有未提交改动也不应预警: {r:?}" ); assert!( !r.iter().any(|t| t.name == "CLAUDE.md"), "CLAUDE.md 未被改写时不补结果行" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn mixed_file_warning_survives_failure_after_write() { let base = std::env::temp_dir().join(format!( "lianjing_onb_mixed_err_test_{}", std::process::id() )); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join("docs/ai-context")).unwrap(); let workflow_path = base.join("docs/ai-context/git-workflow.md"); let workflow = outdated_versioned(TMPL_GIT_WORKFLOW, "项目分支约定"); std::fs::write( base.join("AGENTS.md"), outdated_versioned(TMPL_AGENTS, "项目说明"), ) .unwrap(); std::fs::write(&workflow_path, &workflow).unwrap(); assert!(git_in(&base, &["init", "-q"])); assert!(git_in(&base, &["add", "-A"])); assert!(git_in( &base, &["commit", "-q", "--no-verify", "-m", "init"] )); let failing_after_write = || { dispatch_templates_with_mixed_file_check(&base, || { crate::commands::blueprint::sync_blueprint_rules( base.to_string_lossy().into_owned(), )?; Err::<(), String>("database is locked".into()) }) .map(|_| ()) }; // 无块外未提交改动:错误原样返回 assert_eq!( failing_after_write().unwrap_err(), "database is locked", "无混合文件时不改写错误信息" ); // 还原到旧版托管块,git-workflow.md 块外改写后再失败:错误里须带混合文件名 assert!(git_in( &base, &[ "checkout", "--", "AGENTS.md", "docs/ai-context/git-workflow.md" ] )); std::fs::write( &workflow_path, workflow.replace("项目分支约定", "项目分支约定(未提交改写)"), ) .unwrap(); let err = failing_after_write().unwrap_err(); assert!( err.starts_with("database is locked") && err.contains("混合文件") && err.contains("docs/ai-context/git-workflow.md"), "后续步骤失败时预警须并入错误信息: {err}" ); assert!( !err.contains("AGENTS.md"), "块外无改动的文件不应列入: {err}" ); assert!( std::fs::read_to_string(&workflow_path) .unwrap() .contains("项目分支约定(未提交改写)"), "块外内容不得改动" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn mixed_file_warning_is_silent_outside_git() { let base = std::env::temp_dir().join(format!("lianjing_onb_nogit_test_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); if git_in(&base, &["rev-parse", "--git-dir"]) { // 临时目录位于某个 git 仓库内,构造不出「非 git 仓库」前置条件 eprintln!("跳过:{} 位于 git 仓库内", base.display()); let _ = std::fs::remove_dir_all(&base); return; } std::fs::write( base.join("AGENTS.md"), outdated_versioned(TMPL_AGENTS, "项目说明"), ) .unwrap(); std::fs::write(base.join("CLAUDE.md"), "# 项目自己的 CLAUDE.md\n").unwrap(); let r = apply_like(&base); assert_eq!(result_of(&r, "AGENTS.md").action, "updated"); assert!( r.iter().all(|t| t.warning.is_none()), "非 git 仓库不应预警: {r:?}" ); 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 identity_mismatch = check_project_context( Some(r#"{"schema_version":0,"project_id":"another-project"}"#), None, "p1", ); assert_eq!(identity_mismatch.state, VerifyState::Unavailable); assert!(identity_mismatch.detail.contains("停止使用")); 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, "模板 capability-packs.json 的版本须等于 BASE_ONBOARDING_PACK_VERSION / TOOLCHAIN_PACK_VERSION:{}", pack_ready.detail ); 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("重跑接入包可升级")); } fn runtime_layer( state: crate::commands::settings::AgentRuntimeState, detail: &str, ) -> crate::commands::settings::AgentRuntimeLayer { crate::commands::settings::AgentRuntimeLayer { state, detail: detail.to_string(), required: None, } } #[test] fn onboarding_summary_requires_runtime_readiness() { use crate::commands::settings::{AgentRuntimeLayer, AgentRuntimeState, AgentRuntimeStatus}; let checks = vec![VerifyCheck::from_pass( "Codex MCP 已配置", true, "静态配置存在", )]; let unavailable = AgentRuntimeStatus { project_id: "p1".to_string(), ready: false, configured: runtime_layer(AgentRuntimeState::Ready, "静态配置存在"), reachable: runtime_layer(AgentRuntimeState::Unavailable, "connection refused"), handshake: runtime_layer(AgentRuntimeState::Unavailable, "未执行 initialize"), tools_ready: runtime_layer(AgentRuntimeState::Unavailable, "未执行 tools/list"), reload_required: AgentRuntimeLayer { state: AgentRuntimeState::Ready, detail: "无重载证据".to_string(), required: Some(false), }, }; let report = summarize_onboarding("p1", checks.clone(), unavailable); assert!(!report.pass, "配置存在但端口拒绝时不得整体假报 ready"); assert!(report.summary.contains("runtime 未就绪")); assert_eq!( report.runtime.reachable.state, AgentRuntimeState::Unavailable ); let ready_layer = || runtime_layer(AgentRuntimeState::Ready, "ready"); let ready = AgentRuntimeStatus { project_id: "p1".to_string(), ready: true, configured: ready_layer(), reachable: ready_layer(), handshake: ready_layer(), tools_ready: ready_layer(), reload_required: AgentRuntimeLayer { state: AgentRuntimeState::Ready, detail: "无需重载".to_string(), required: Some(false), }, }; let mut report = summarize_onboarding("p1", checks, ready); assert!(report.pass); assert!(report.summary.contains("runtime ready")); assert!(report.clients.is_empty()); // P8b:clients 是附加字段——即使三端未就绪,也不改 checks 计数、VerifyState 与 pass use crate::onboard::clients::{ClientId, ClientOutcome, ClientReport, NotApplicableReason}; let (count, states): (usize, Vec) = ( report.checks.len(), report .checks .iter() .map(|check| check.state.clone()) .collect(), ); report.clients = vec![ ClientReport::new( ClientId::Claude, ClientOutcome::Failed { reason: "x".into() }, ), ClientReport::new(ClientId::Codex, ClientOutcome::Missing), ClientReport::new( ClientId::Dsh, ClientOutcome::NotApplicable { reason: NotApplicableReason::ControlPlane, }, ), ]; let json = serde_json::to_value(&report).unwrap(); assert_eq!(json["pass"], true); assert_eq!(json["checks"].as_array().unwrap().len(), count); assert_eq!( report .checks .iter() .map(|check| check.state.clone()) .collect::>(), states ); assert_eq!(json["clients"][2]["state"], "not_applicable"); } #[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(), 12, "检查链应为十二项"); 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/projects/p1\"\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 project_owned_delivery_profile_is_checked_but_never_overwritten() { let base = std::env::temp_dir().join(format!( "lianjing_delivery_onboarding_test_{}", std::process::id() )); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); let profile_path = base.join(".lian-jing/capabilities.yaml"); let profile = "version: 1\ndelivery:\n targets:\n production:\n provider: gitea-actions\n components:\n api:\n deploy_context: deploy-api\n probe:\n kind: http-json\n url: https://api3.yljt.info/health/version\n revision_field: revision\n healthy_field: ok\n"; std::fs::write(&profile_path, profile).unwrap(); dispatch_templates(base.to_str().unwrap()); assert_eq!(std::fs::read_to_string(&profile_path).unwrap(), profile); let check = check_delivery_profile(profile); assert_eq!(check.state, VerifyState::Ready); let invalid = check_delivery_profile(&profile.replace( "url: https://api3.yljt.info/health/version", "url: file:///etc/passwd", )); assert_eq!(invalid.state, VerifyState::Unavailable); 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" ); } // ── 托管模板指纹:改内容必须升版本(onboarding-sync.md 升版规则的机器校验)── /// 版本号 → 摘要登记表:(指纹对象, 登记版本, sha256)。 /// - `resources/onboarding/**`:全部接入包资源,对应 BASE_ONBOARDING_PACK_VERSION—— /// verify 只凭 capability-packs.json 的版本判落后,全托管文件(envoy/SKILL)的内容不参与比对 /// - `<模板>#ONBOARDING_MANAGED`:版本化块,对应块首 version——dispatch_versioned 唯一的升级信号 /// /// 改了模板:按测试报错升对应版本,再把报错给出的新摘要填回这里。 const TEMPLATE_FINGERPRINTS: [(&str, &str, &str); 3] = [ ( "resources/onboarding/**", "1.9.0", "9e719b041f90f084d412e7d5e715bbf72950302ab3f0138ea52841ffe2ccec8e", ), ( "AGENTS.md.tmpl#ONBOARDING_MANAGED", "1.10.0", "59b29e99c1b2184732dbf828eb3f2c8d3bb5afdfdbd30fd5d967daced82313e5", ), ( "git-workflow.md.tmpl#ONBOARDING_MANAGED", "1.7.0", "9d52231fd725b786ad4d2bf874e21c155150b4df5a115e3b8facf23b6c23c255", ), ]; /// sha256 hex。先把 CRLF 归一化为 LF:Windows autocrlf 工作区是 CRLF,CI 检出是 LF。 /// 段间加 \0 分隔,内容在相邻段之间挪动也会改变摘要。 fn lf_sha256<'a>(parts: impl IntoIterator) -> String { use sha2::Digest; let mut hasher = sha2::Sha256::new(); for part in parts { hasher.update(part.replace("\r\n", "\n").as_bytes()); hasher.update([0u8]); } hasher .finalize() .iter() .map(|byte| format!("{byte:02x}")) .collect() } /// 递归收集 (相对路径, 内容)——新增模板文件无需登记,自动纳入指纹。 fn collect_resource_files(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) { for entry in std::fs::read_dir(dir).unwrap() { let path = entry.unwrap().path(); if path.is_dir() { collect_resource_files(root, &path, out); continue; } let relative = path .strip_prefix(root) .unwrap() .to_string_lossy() .replace('\\', "/"); let content = std::fs::read_to_string(&path) .unwrap_or_else(|e| panic!("读取模板 {relative} 失败: {e}")); out.push((relative, content)); } } #[test] fn managed_template_fingerprints_track_versions() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/onboarding"); let mut files = Vec::new(); collect_resource_files(&root, &root, &mut files); files.sort(); let pack_digest = lf_sha256( files .iter() .flat_map(|(path, content)| [path.as_str(), content.as_str()]), ); let block = |template: &'static str| { let block = extract_managed_block(template).expect("模板须含 ONBOARDING_MANAGED 块"); ( block_version(block).expect("管理块首须有 version"), lf_sha256([block]), ) }; let (agents_version, agents_digest) = block(TMPL_AGENTS); let (workflow_version, workflow_digest) = block(TMPL_GIT_WORKFLOW); let actual = [ ( BASE_ONBOARDING_PACK_VERSION, pack_digest, "改了托管模板请升 BASE_ONBOARDING_PACK_VERSION 与 capability-packs.json 版本,并更新指纹常量", ), ( agents_version, agents_digest, "改了 AGENTS.md.tmpl 管理块请升块首 version(不升则存量项目重跑接入包全部 skipped),并更新指纹常量", ), ( workflow_version, workflow_digest, "改了 git-workflow.md.tmpl 管理块请升块首 version(不升则存量项目重跑接入包全部 skipped),并更新指纹常量", ), ]; let stale: Vec = TEMPLATE_FINGERPRINTS .iter() .zip(&actual) .filter(|((_, version, digest), (now_version, now_digest, _))| { *version != *now_version || *digest != *now_digest }) .map(|((name, version, _), (now_version, now_digest, hint))| { let (advice, next_version) = if version == now_version { (*hint, "<新版本>") } else { ("版本已升,更新指纹常量即可", *now_version) }; format!( "- {name}:登记 v{version},当前 v{now_version}——{advice}。\n TEMPLATE_FINGERPRINTS 改为 (\"{name}\", \"{next_version}\", \"{now_digest}\")" ) }) .collect(); let hashed: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); assert!( stale.is_empty(), "托管模板内容与登记版本不符:\n{}\n(resources/onboarding/** 参与摘要的文件:{})", stale.join("\n"), hashed.join(", ") ); } #[test] fn skill_is_projected_to_codex_and_claude_from_one_source() { let base = std::env::temp_dir().join(format!("lianjing_skill_projection_{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); dispatch_templates(base.to_str().unwrap()); let codex = std::fs::read_to_string(base.join(".agents/skills/lian-jing/SKILL.md")).unwrap(); let claude = std::fs::read_to_string(base.join(".claude/skills/lian-jing/SKILL.md")).unwrap(); assert_eq!(codex, claude, "两端 skill 必须来自同一模板"); assert!(check_skill(Some(&claude)).0); let packs: serde_json::Value = serde_json::from_str(TMPL_CAPABILITY_PACKS).unwrap(); let artifacts = packs["packs"][0]["project_artifacts"].to_string(); assert!( artifacts.contains(".claude/skills/lian-jing/SKILL.md"), "能力包清单须登记 Claude 投影" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn skill_check_requires_canonical_context() { let legacy = "---\nname: lian-jing\n---\n读取 .claude/liangjing.json"; assert!( !check_skill(Some(legacy)).0, "旧 Claude 身份来源不能被误报为已接入" ); assert!( check_skill(Some(TMPL_LIANJING_SKILL)).0, "当前接入包 skill 必须使用 canonical context" ); for instruction in ["configured", "reachable", "reload"] { assert!( TMPL_LIANJING_SKILL.contains(instruction), "Skill 必须包含 {instruction} 降级层" ); } assert!( TMPL_LIANJING_SKILL.contains("/mcp/projects/"), "Skill 必须声明 scoped MCP URL" ); assert!( TMPL_LIANJING_SKILL.contains("不要") && TMPL_LIANJING_SKILL.contains("复制凭证"), "Skill 必须阻止通过复制凭证绕过 readiness" ); } #[test] fn codex_mcp_check_distinguishes_scoped_legacy_and_wrong_project() { let scoped = check_codex_mcp( Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp/projects/p%201\"\n"), "p 1", 27190, ); assert_eq!(scoped.state, VerifyState::Ready); let legacy = check_codex_mcp( Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n"), "p1", 27190, ); assert_eq!(legacy.state, VerifyState::Legacy); assert!(legacy.detail.contains("inject_mcp")); assert!(legacy.detail.contains("不会被覆盖")); let wrong_project = check_codex_mcp( Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp/projects/p2\"\n"), "p1", 27190, ); assert_eq!(wrong_project.state, VerifyState::Missing); let prefix_collision = check_codex_mcp( Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp/projects/p10\"\n"), "p1", 27190, ); assert_eq!(prefix_collision.state, VerifyState::Missing); let wrong_port = check_codex_mcp( Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:28000/mcp/projects/p1\"\n"), "p1", 27190, ); assert_eq!(wrong_port.state, VerifyState::Missing); } #[test] fn codex_mcp_check_flags_machine_path_helper_for_upgrade() { let url = "url = \"http://127.0.0.1:27190/mcp/projects/p1\"\n"; let machine = format!( "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"E:\\\\x\\\\target\\\\debug\\\\app.exe\\\" --lianjing-mcp-header d1\"\n" ); let legacy = check_codex_mcp(Some(&machine), "p1", 27190); assert_eq!(legacy.state, VerifyState::Legacy); assert!(legacy.detail.contains("本机 exe 绝对路径")); // 2026-10-01:%LOCALAPPDATA% 入口可能被重定向进某个 MSIX 应用的私有目录,由 Ready 改判 Legacy let app_data = format!( "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%LOCALAPPDATA%\\\\lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" ); let app_data_check = check_codex_mcp(Some(&app_data), "p1", 27190); assert_eq!(app_data_check.state, VerifyState::Legacy); assert!(app_data_check.detail.contains("MSIX")); let stable = format!( "[mcp_servers.lian-jing]\n{url}http_headers_helper = \"\\\"%USERPROFILE%\\\\.lian-jing\\\\bin\\\\lianjing-mcp-header.cmd\\\" d1\"\n" ); assert_eq!( check_codex_mcp(Some(&stable), "p1", 27190).state, VerifyState::Ready ); } /// 回归:空路径登记曾把接入包写进炼境进程 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 内容", &crate::DiskSink); assert_eq!(r.action, "written"); // 一致 → skipped(CRLF 检出也视为一致) let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink); 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 内容", &crate::DiskSink); assert_eq!(r.action, "skipped", "仅行尾差异不应触发覆盖"); // 不一致(旧版模板/被改动)→ 覆盖 updated std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap(); let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容", &crate::DiskSink); 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 toolchain_resolver_is_conditional_and_never_overwrites_custom_config() { let base = std::env::temp_dir().join(format!( "lianjing_toolchain_pack_test_{}", std::process::id() )); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); std::fs::write(base.join(".nvmrc"), "24.19.0\n").unwrap(); std::fs::write( base.join("package.json"), r#"{"packageManager":"pnpm@9.15.0"}"#, ) .unwrap(); let first = dispatch_toolchain_resolver(&base, &crate::DiskSink); assert_eq!(first.action, "written"); assert_eq!(check_toolchain_resolver(&base).state, VerifyState::Ready); std::fs::write( base.join("mise.toml"), "[tasks.custom]\nrun = 'project-owned'\n", ) .unwrap(); let second = dispatch_toolchain_resolver(&base, &crate::DiskSink); assert_eq!(second.action, "skipped"); assert_eq!( std::fs::read_to_string(base.join("mise.toml")).unwrap(), "[tasks.custom]\nrun = 'project-owned'\n" ); assert_ne!( check_toolchain_resolver(&base).state, VerifyState::Ready, "自定义配置未消费 Node/pnpm 声明时不能伪报 ready" ); 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, detail) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK); assert!(pass, "模板应满足自身检查: {detail}"); // 缺段 → 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); // 项目可将 typecheck 拆到 web/API,并把 Conventional 校验声明为 commit-msg script。 let custom = r#" pre-commit: commands: typecheck-web: run: pnpm --filter web typecheck typecheck-api: run: pnpm --filter api typecheck blueprint-gate: run: node scripts/blueprint-gate.cjs || true commit-msg: scripts: "check-conventional.sh": runner: bash commands: feature-confirmed: run: test -n "$1" "#; assert!( check_lefthook_keys(Some(custom), 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 的回传过期后也应列为沉默"); } #[test] fn silent_projects_counts_each_commit_once_under_duplicate_registration() { let conn = crate::db::conn_with_schema(); // 2026-10-03 真机形状:同一路径两条登记,buff 挂规范 ID,commit 按 sha 先到先得分挂两个 ID conn.execute_batch( "INSERT INTO project_profiles (id, name) VALUES ('prof-legacy','炼境'),('prof-uuid','炼境'); INSERT INTO project_workspaces (id, profile_id, win_path) VALUES ('pw-legacy','prof-legacy','E:/dup'),('pw-uuid','prof-uuid','E:/dup'); INSERT INTO blueprint_buffs (project_id, project_path) VALUES ('pw-legacy','E:/dup'); INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at) VALUES ('d1','pw-legacy','feat',0,0,datetime('now'),datetime('now')), ('d2','pw-legacy','fix',1,0,datetime('now'),datetime('now')), ('d3','pw-legacy','feat',0,0,datetime('now'),datetime('now')), ('d4','pw-uuid','feat',0,0,datetime('now'),datetime('now')), ('d5','pw-uuid','docs',0,0,datetime('now'),datetime('now'));", ) .unwrap(); let silent = silent_projects(&conn).unwrap(); let mut counts: Vec = silent.iter().map(|(_, n)| *n).collect(); counts.sort(); assert_eq!( counts, vec![2, 3], "各计各的,不出现一行等于两边并集:{silent:?}" ); assert_eq!(counts.iter().sum::(), 5, "总数等于去重后的 commit 数"); } }