- MCP 新增 plan_onboarding(readOnly)与 onboard_project(幂等写操作):schema 各自单独成函数, dispatch 一行委托到 mcp_inject 生产入口(中间只做 JSON 类型解析);全局与项目路由都可见, 项目路由强制本项目、跨项目返回 scope_violation;onboard_project required 含 expected_digest 与 confirmed;共用可选选项 bypass_permissions / skip_targets;进 STRUCTURED_TOOLS 并给一行摘要 - Tauri 新增同名 async 命令(async fn + spawn_blocking),commands.ts 同步 OnboardOptions、 OnboardingPlan、OnboardReport 类型与 planOnboarding / onboardProject 封装 - 统筹决定(P8d 审查 ⑩):L1 Codex 列仅因 codex_project_untrusted 未就绪的格不计入 done, completion 单列「Codex 信任」(pending_user),next_actions 保留 codex_trust;仓库子目录与 worktree 补 next_action repo_layout 说明为何到不了 done;非 git 维持不能 done - 契约同步:project-context.yaml、agent-button-system.md、AGENTS.md 架构速查;P8d 卡记统筹决定落地 - cargo test 413 passed / 0 failed / 6 ignored;pnpm typecheck、pnpm test 通过;三轮 10 处变异全部被杀 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2517 lines
102 KiB
Rust
2517 lines
102 KiB
Rust
//! 接入包模板分发(基线档)。
|
||
//!
|
||
//! 版本化分发(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");
|
||
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<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,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。
|
||
/// 只判存在、不读内容:已存在的非 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::<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, 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<TemplateResult> {
|
||
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<TemplateResult> {
|
||
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:./<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)
|
||
}
|
||
|
||
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<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(本体在 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<TemplateResult>,
|
||
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<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,
|
||
})
|
||
}
|
||
|
||
/// 三端对等 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<String>,
|
||
) -> Result<crate::onboard::probe_l1::L1Report, String> {
|
||
tokio::task::spawn_blocking(move || {
|
||
crate::mcp_inject::probe_client_parity(&project_id, tier.as_deref())
|
||
})
|
||
.await
|
||
.map_err(|error| format!("L1 检查线程异常: {error}"))?
|
||
}
|
||
|
||
// ── 一键接入(agent-client-parity P8e):UI 投影,与 MCP plan_onboarding / onboard_project 同一本体 ──────
|
||
// 用 async fn + spawn_blocking(同 probe_client_parity),不用 #[tauri::command(async)] 包同步函数:后者让函数体占住一个
|
||
// tokio worker,而 MCP 服务端跑在同一运行时、一键接入可能 30 秒以上(P8d 审查 ⑨)。options 缺省即默认选项;
|
||
// 计划与应用须传同一组选项,否则 digest 不符被拒。
|
||
|
||
/// 一键接入写入前计划(只读)。
|
||
#[tauri::command]
|
||
pub async fn plan_onboarding(
|
||
project_id: String,
|
||
options: Option<crate::onboard::apply::OnboardOptions>,
|
||
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
|
||
tokio::task::spawn_blocking(move || {
|
||
crate::mcp_inject::plan_onboarding(&project_id, &options.unwrap_or_default())
|
||
})
|
||
.await
|
||
.map_err(|error| format!("一键接入计划线程异常: {error}"))?
|
||
}
|
||
|
||
/// 一键接入:带回人确认过的计划 digest 与 confirmed=true 才写;结果以报告的 status 区分(不以 Err 表示 refused 等)。
|
||
#[tauri::command]
|
||
pub async fn onboard_project(
|
||
project_id: String,
|
||
expected_digest: String,
|
||
confirmed: bool,
|
||
options: Option<crate::onboard::apply::OnboardOptions>,
|
||
) -> Result<crate::onboard::report::OnboardReport, String> {
|
||
tokio::task::spawn_blocking(move || {
|
||
crate::mcp_inject::onboard_project(
|
||
&project_id,
|
||
&expected_digest,
|
||
confirmed,
|
||
&options.unwrap_or_default(),
|
||
)
|
||
})
|
||
.await
|
||
.map_err(|error| format!("一键接入线程异常: {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<VerifyCheck>,
|
||
pub runtime: crate::commands::settings::AgentRuntimeStatus,
|
||
/// 三端客户端对本项目的只读配置状态(P8b 附加字段):不计入 checks、不影响 pass。
|
||
#[serde(default)]
|
||
pub clients: Vec<crate::onboard::clients::ClientReport>,
|
||
}
|
||
|
||
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,
|
||
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<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)不列入。
|
||
/// 每条 commit 只归属一个 workspace(直接 ID 优先,其次 buff 路径):同路径重复登记时
|
||
/// 若按「ID 或路径」任一匹配计数,一行会吃到多条登记的并集(2026-10-03 真机 147 / 86)。
|
||
pub fn silent_projects(conn: &rusqlite::Connection) -> Result<Vec<(String, i64)>, 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<!-- 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 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<VerifyState>) = (
|
||
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::<Vec<_>>(),
|
||
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<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 内容", &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<i64> = silent.iter().map(|(_, n)| *n).collect();
|
||
counts.sort();
|
||
assert_eq!(
|
||
counts,
|
||
vec![2, 3],
|
||
"各计各的,不出现一行等于两边并集:{silent:?}"
|
||
);
|
||
assert_eq!(counts.iter().sum::<i64>(), 5, "总数等于去重后的 commit 数");
|
||
}
|
||
}
|