dev-manager-tauri/src-tauri/src/commands/onboarding.rs
lanrtop c9b951dd30
All checks were successful
Push & PR Check / check (push) Successful in 1m2s
Push & PR Check / check (pull_request) Successful in 1m5s
fix(onboarding/parity): 混合文件预警收窄空白归一、失败时不丢预警、测住改写判断(P10 审查修复)
P10 审查核实的三处缺陷:

- 块外比较原先逐行 trim_end 并丢弃全部空行,项目在块外删空行(Markdown 两段
  并成一段)或加行尾空格(硬换行)时判为无改动,升级后不预警却产生混合文件。
  改为 CRLF 统一为 LF、托管块原位换成占位符,只把文末占位符连同其前空白按
  trim_end 归一(正好抵消炼境追加托管块时的 trim_end + 空行);文中托管块两侧
  的空行与行尾空格照常比较——炼境只原位替换文中的块,不碰两侧。
- dispatch_templates_with_mixed_file_check 先 `then()?` 后标注:buff 先同步蓝图
  规则改写了 AGENTS.md/CLAUDE.md、再写 DB 失败时,预警随 Err 一起丢失。改为
  无论成败先标注,失败时把混合文件名并入错误信息;无混合文件则原样返回错误。
- 「块外有未提交改动但托管块已是最新、本次不改写」组合没有测试,删掉 annotate
  的改写判断测试照样全绿。主场景测试补情形三。

测试:新增 outside_managed_whitespace_edits_count_as_changes、
mixed_file_warning_survives_failure_after_write,主场景补情形三;纯函数测试的
期望值随归一化收窄更新("# 标题\n项目内容" → "# 标题\n\n项目内容",保留「之前
追加的块尚未提交、只多出块前空行」不算改动的断言)。变异自检:三处修复逐一回退,
对应测试各自失败,已还原。cargo test --locked:lib 272 passed / 5 ignored,0 failed;
pnpm typecheck 通过。契约文档 project-context.yaml 与蓝图 P10 卡同步。

Rules-Applied: R07,R12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:32:39 +09:00

2400 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 接入包模板分发(基线档)。
//!
//! 版本化分发(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 = "<!-- ONBOARDING_MANAGED_START";
const MANAGED_END: &str = "<!-- ONBOARDING_MANAGED_END -->";
// 标准模板(编译期内嵌)
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 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.7.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<String>,
}
impl TemplateResult {
fn new(name: &str, action: &str, detail: impl Into<String>) -> Self {
Self {
name: name.to_string(),
action: action.to_string(),
detail: detail.into(),
warning: None,
}
}
}
/// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。
fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult {
let target = root.join(relative);
if target.exists() {
return TemplateResult::new(relative, "skipped", "已存在,未覆盖");
}
if let Some(parent) = target.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return TemplateResult::new(relative, "failed", format!("建目录失败: {e}"));
}
}
match std::fs::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 抖动)。
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::new(relative, "skipped", "内容已是最新模板");
}
}
match std::fs::write(&target, content) {
Ok(_) => 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::<serde_json::Value>(&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) -> 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)
}
/// 从模板中提取 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::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 std::fs::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<TemplateResult> {
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,
),
// Claude Code 不读 .agents/skills,同一模板投影一份(单一来源,双投影)。
dispatch_fully_managed(
root,
".claude/skills/lian-jing/SKILL.md",
TMPL_LIANJING_SKILL,
),
dispatch_fully_managed(
root,
".lian-jing/capability-packs.json",
TMPL_CAPABILITY_PACKS,
),
dispatch_toolchain_resolver(root),
]
}
// ── P10:混合文件预警(升级托管块前检测块外的未提交改动)────────────────────
//
// 为什么:2026-10-01 enterprise 的 git-workflow.md 在有项目未提交改写时被升级了托管块,
// 同一文件混有炼境与项目两种来源的改动;按「禁止字符串手术」无法拆分提交,只能交人定案。
// 默认仍照常升级(只改托管块),但必须让调用方知道哪个文件因此成了混合文件。
/// 接入包会原位改写托管块的文件:AGENTS.md、git-workflow.md 由模板分发写 ONBOARDING_MANAGED,
/// AGENTS.md、CLAUDE.md 另由 buff 的蓝图规则同步写 BLUEPRINT_MANAGED。
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:只做提示,探测失败不打扰调用方。
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:./<path>` 按 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)
}
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<TemplateResult>) {
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<T>(
root: &Path,
then: impl FnOnce() -> Result<T, String>,
) -> Result<(Vec<TemplateResult>, 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:优先项目本地 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<std::path::PathBuf> = 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<TemplateResult>,
pub hook: HookActivation,
pub buff: crate::commands::buff::BuffStatus,
}
/// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。
/// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。
#[tauri::command]
pub fn apply_onboarding_pack(
project_path: String,
project_id: String,
) -> Result<OnboardingReport, String> {
// 空/无效路径必须在此拦截: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,
})
}
// ── 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<VerifyCheck>,
pub runtime: crate::commands::settings::AgentRuntimeStatus,
}
pub fn summarize_onboarding(
project_id: &str,
checks: Vec<VerifyCheck>,
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,
}
}
/// 接入检查的可行动状态。只有 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<String>, state: VerifyState, detail: impl Into<String>) -> Self {
let pass = state == VerifyState::Ready;
Self {
name: name.into(),
pass,
state,
detail: detail.into(),
}
}
fn from_pass(name: impl Into<String>, pass: bool, detail: impl Into<String>) -> 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::<serde_json::Value>(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::<serde_json::Value>(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<String> {
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<String> {
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<String> {
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<String> {
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<String> = 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<VerifyCheck> {
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<String> = 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)不列入。
pub fn silent_projects(conn: &rusqlite::Connection) -> Result<Vec<(String, i64)>, 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();
// 首次:基础接入包全部写入;非 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<!-- ONBOARDING_MANAGED_START version=\"1.0.0\" -->\r\n炼境内容\r\n<!-- ONBOARDING_MANAGED_END -->\r\n\r\n<!-- BLUEPRINT_MANAGED_START version=\"2.0.0\" -->\n蓝图\n<!-- BLUEPRINT_MANAGED_END -->\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,
"<!-- ONBOARDING_MANAGED_START version=\"1\" -->\nx\n<!-- ONBOARDING_MANAGED_END -->\n"
));
// 残缺块(无结束标记)不剔除,余下内容按块外处理
assert_eq!(
outside_managed_content("a\n<!-- ONBOARDING_MANAGED_START version=\"1\" -->\nb"),
"a\n<!-- ONBOARDING_MANAGED_START version=\"1\" -->\nb"
);
}
#[test]
fn outside_managed_whitespace_edits_count_as_changes() {
let block = |ver: &str| {
format!(
"<!-- ONBOARDING_MANAGED_START version=\"{ver}\" -->\n炼境内容\n<!-- ONBOARDING_MANAGED_END -->"
)
};
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<TemplateResult> {
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 report = summarize_onboarding("p1", checks, ready);
assert!(report.pass);
assert!(report.summary.contains("runtime ready"));
}
#[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.7.0",
"0c39acd7c0513a1063ea0c2de5da06ba2baa69b7eb85b167499859ab6ad0eb45",
),
(
"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<Item = &'a str>) -> 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<String> = 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/<canonical project_id>"),
"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 内容");
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 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);
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);
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 的回传过期后也应列为沉默");
}
}