dev-manager-tauri/src-tauri/src/onboard/plan.rs
lanrtop 2f2a9f10a3
All checks were successful
Push & PR Check / check (push) Successful in 1m9s
Push & PR Check / check (pull_request) Successful in 1m1s
fix(onboard/parity): P8d 处置独立审查——锁内重新解析登记并以根目录绑 digest、阻断项可逐项跳过、接入文件与用户改动分开报告等(P8d 审查)
- ① 接入写锁内重新解析登记:登记消失 failed,根目录或端口变了 refused 并附当前计划;
  计划新增 root 并进 digest;SystemOnboardHost 每次重读端口;删除登记持同一把写锁先清理
  MCP 配置再删登记(delete_project_registration 注入写锁与清理闭包);批量重注入在锁内取快照
- ② OnboardOptions.skip_targets:manual_entry / shared_key 可逐项跳过(进 digest,计划单列
  skipped),跳过的一端记 skipped、不调用适配器;身份冲突、混合文件、needs_human 仍只能中止
- ③ 报告新增 written_files;repo_changes 限定项目根,分出 onboard_files 与 other_changes,
  建议提交信息与 next_action commit 只针对接入文件
- ④ 一键接入补写 mcp_inject 事件(inject_event),诊断注入状态随之更新
- ⑤ ClientAdapter::project_config_file:codex 步本次由 project_files 写入时记 applied,blocked 文案如实
- ⑥ lefthook 保留启动错误,本地存在却起不来记 failed;⑦ git_hooks 移到写锁外
- ⑧⑨⑩ 登记 known_limits / P8e 指引 / 转统筹决定;project-context.yaml 同步(T1)
- cargo test 402 passed / 0 failed / 6 ignored;pnpm typecheck 通过;四轮变异验证

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:27:57 +09:00

1169 lines
48 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.

//! 写入前计划(agent-client-parity P8c):用记录汇把一键接入会写的项目文件「演算」一遍,
//! 给出逐文件动作、有上限的 diff、混合文件预警与身份冲突,并对计划内容算 digest。
//!
//! 为什么:一键接入改写的是子项目受版本管理的文件,人在确认前必须看得到改什么;
//! apply 时带上确认过的 digest,计划被改(文件变了、本机状态变了)就拒绝写入。
//! 计划与 apply 走同一段分发代码(只换写入汇),所以「看到的」就是「将写的」。
//!
//! digest 的准确语义:绑定项目根目录(登记解析出的、将要写入的目录)、计划**会写入或核对过**的每个文件的
//! (路径、动作、写入后内容哈希,update 另加写入前内容哈希)、本机步骤与状态、阻断项与人确认跳过的项。
//! 计划不会触碰的目标——已存在的只建不改文件(lefthook.yml、mise.toml)、语义相同的 JSON、托管块版本相同的
//! 文件——不列出、不进 digest:它们怎么变,apply 都不写。Codex 信任与 next_actions 不影响写入内容,也不进 digest。
use crate::WriteSink;
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// 项目 Codex 配置(文件级托管块与本机 codex 步的只读判定都来自它)。
pub(crate) const CODEX_PROJECT_CONFIG: &str = ".codex/config.toml";
/// 记录汇:只记录不落盘;读取优先返回本汇里的待写内容。
#[derive(Default)]
pub(crate) struct PlanSink {
pending: RefCell<BTreeMap<PathBuf, String>>,
touched: RefCell<Vec<PathBuf>>,
}
impl WriteSink for PlanSink {
fn exists(&self, path: &Path) -> bool {
self.pending.borrow().contains_key(path) || path.exists()
}
fn read(&self, path: &Path) -> std::io::Result<Option<String>> {
if let Some(content) = self.pending.borrow().get(path) {
return Ok(Some(content.clone()));
}
read_disk(path)
}
fn write(&self, path: &Path, content: &str) -> std::io::Result<bool> {
{
let mut touched = self.touched.borrow_mut();
if !touched.iter().any(|seen| seen == path) {
touched.push(path.to_path_buf());
}
}
if let Some(current) = self.read(path)? {
if crate::same_text_ignoring_eol(&current, content) {
return Ok(false);
}
}
// 写回与磁盘一致(先改后又改回)时撤销待写,避免把「无变化」报成 update
let unchanged_on_disk =
read_disk(path)?.is_some_and(|disk| crate::same_text_ignoring_eol(&disk, content));
let mut pending = self.pending.borrow_mut();
if unchanged_on_disk {
pending.remove(path);
} else {
pending.insert(path.to_path_buf(), content.to_string());
}
Ok(true)
}
}
fn read_disk(path: &Path) -> std::io::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileAction {
Create,
Update,
Noop,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedFile {
/// 相对项目根,统一用 /
pub path: String,
pub action: FileAction,
/// update 时的行级差异(有上限,超出截断并标注);create 时为新文件概要
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
/// 计划写入内容的 sha256(noop 为当前内容的 sha256),进入 digest
pub content_sha256: String,
/// update 时写入前(当前磁盘)内容的 sha256,进入 digest:人确认之后、apply 之前文件又被改过,
/// 即使写入后内容不变也会因 digest 不符被拒(否则会覆盖掉人没在 diff 里见过的改动)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_sha256: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockReason {
/// 托管块文件块外有未提交改动,本次又要改写托管块
MixedFile,
/// .lian-jing/context.json 已属于别的 project_id
IdentityConflict,
/// 客户端已有手写同名配置(炼境不覆盖)
ManualEntry,
/// 生效的客户端条目属于别的项目(共享项目键)
SharedKey,
/// 需要人工处理或无法确认(读失败、标记错序等)
NeedsHuman,
}
impl BlockReason {
/// 人可在确认时选「跳过」的阻断原因(设计 §4 C1):手写同名条目与共享项目键——跳过即保留现状、该端不写
/// (设计 §4 第 2 步「手写同名配置记 manual_kept,不覆盖」)。身份冲突、混合文件与需人工处理的项只能中止,
/// 处理后重新计划(混合文件须先在子项目提交或丢弃块外改动,禁止字符串手术)。
pub fn skippable(self) -> bool {
matches!(self, Self::ManualEntry | Self::SharedKey)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockedItem {
pub target: String,
pub reason: BlockReason,
pub detail: String,
}
/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置。
/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | unavailable
/// | manual_kept | other_project | needs_human(后三者阻断确认,见 machine_step_blocker)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MachineStep {
pub step: String,
pub status: String,
pub detail: String,
}
/// Codex 是否信任项目目录(设计 §4 第 1 步单列、决策 Q5)。只决定 Codex 是否加载项目 .codex/config.toml,
/// 不影响炼境写入什么:不阻断、不进 digest;未信任或无法判定时列入 next_actions,由人在 Codex 里信任。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CodexTrust {
Trusted,
Untrusted,
NotInstalled,
/// Codex 用户配置读不出
Unknown,
}
/// 机器只列出、由人在别处完成的事项(设计 §4「人确认 C4」);不阻断、不进 digest。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NextAction {
pub action: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingPlan {
pub project_id: String,
/// 将要写入的项目根目录(登记路径解析结果,WSL 换算为 UNC),进入 digest:人确认的就是这个目录
pub root: String,
pub files: Vec<PlannedFile>,
/// 本机侧七步(token、shim、claude、codex、dsh、product_group_buff、git_hooks)的预计动作
pub machine_changes: Vec<MachineStep>,
/// 阻断项:非空时 apply 一律 refused
pub blocked: Vec<BlockedItem>,
/// 人确认跳过的阻断项(只限 BlockReason::skippable,由选项 skip_targets 指定):apply 时保留现状、
/// 对应一端记 skipped,不再阻断;进入 digest
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skipped: Vec<BlockedItem>,
/// 本次会往项目 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列):
/// 只在该文件还没有 permissions 时写入;按项目取消时以 bypass_permissions=false 重新计划,改写显式 default 模式。
/// 由文件内容决定,已随 .claude/settings.json 的内容哈希进入 digest。
pub bypass_permissions: bool,
pub codex_trust: CodexTrust,
pub next_actions: Vec<NextAction>,
/// 语义见模块文档;apply 须带回同一 digest
pub digest: String,
}
/// 接入要写的项目侧连接参数(与 inject 同源)。
pub struct ProjectWrite<'a> {
pub project_id: &'a str,
pub port: u16,
pub server_url: &'a str,
pub header_helper: &'a str,
/// .claude/settings.json 还没有 permissions 时写入的默认模式:true 为 bypassPermissions(现状),
/// false 为显式 default(按项目取消,P8 决策 Q4)。已有 permissions 一律不动。
pub bypass_permissions: bool,
}
/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与兼容投影 → Codex 托管块),
/// plan 与 apply 共用。返回分发过程中的失败与需人工项(不中断其余写入),target 为真正出错的文件。
/// 身份与兼容投影、Codex 托管块与 inject_project_mcp 调的是同一组函数(写入序列只有一份)。
pub(crate) fn write_project_files(
root: &Path,
project: &ProjectWrite,
sink: &dyn WriteSink,
) -> Vec<BlockedItem> {
let needs_human = |failure: crate::WriteFailure| BlockedItem {
target: relative_path(root, &failure.path),
reason: BlockReason::NeedsHuman,
detail: failure.reason,
};
let mut issues = Vec::new();
for result in crate::commands::onboarding::dispatch_templates_with(root, sink) {
if result.action == "failed" {
issues.push(BlockedItem {
target: result.name,
reason: BlockReason::NeedsHuman,
detail: result.detail,
});
}
}
if let Err(failure) = crate::commands::blueprint::sync_blueprint_rules_with(root, sink) {
issues.push(needs_human(failure));
}
if let Err(failure) = crate::mcp_inject::write_project_identity_files_with(
root,
project.project_id,
project.port,
project.server_url,
project.bypass_permissions,
sink,
) {
issues.push(needs_human(failure));
}
use super::clients::ClientOutcome;
match crate::mcp_inject::upsert_codex_mcp_config_with(
&root.join(CODEX_PROJECT_CONFIG),
project.server_url,
project.header_helper,
sink,
) {
Ok(ClientOutcome::ManualKept) => issues.push(BlockedItem {
target: CODEX_PROJECT_CONFIG.into(),
reason: BlockReason::ManualEntry,
detail: "已手工定义 [mcp_servers.lian-jing],炼境不会覆盖".into(),
}),
Err(reason) => issues.push(BlockedItem {
target: CODEX_PROJECT_CONFIG.into(),
reason: BlockReason::NeedsHuman,
detail: reason,
}),
Ok(_) => {}
}
issues
}
/// 本机步骤 → 阻断项(纯函数):手写同名条目、共享项目键与需人工处理的状态阻断确认;
/// noop / apply / not_installed / not_applicable / unavailable 只列出。
/// codex 步的只读判定完全来自项目 .codex/config.toml,阻断项指向该文件,与文件级同因阻断项合并。
pub(crate) fn machine_step_blocker(step: &MachineStep) -> Option<BlockedItem> {
let reason = match step.status.as_str() {
"manual_kept" => BlockReason::ManualEntry,
"other_project" => BlockReason::SharedKey,
"needs_human" => BlockReason::NeedsHuman,
_ => return None,
};
Some(BlockedItem {
target: machine_step_target(&step.step),
reason,
detail: step.detail.clone(),
})
}
/// 本机步骤的阻断项 target:codex 步的判定完全来自项目 .codex/config.toml,指向该文件;其余为步骤名。
/// apply 按同一映射找出人确认跳过的客户端步骤。
pub(crate) fn machine_step_target(step: &str) -> String {
if step == "codex" {
CODEX_PROJECT_CONFIG.into()
} else {
step.to_string()
}
}
/// Codex 信任 → next_actions(纯函数):未信任或无法判定时提示人在 Codex 里信任该目录。
fn codex_trust_next_action(trust: CodexTrust) -> Option<NextAction> {
let detail = match trust {
CodexTrust::Untrusted => "Codex 只在目录受信任后才加载项目 .codex/config.toml:请在 Codex 里信任该目录(炼境不改 Codex 信任表)",
CodexTrust::Unknown => "读不出 Codex 用户配置,无法判断该目录是否受信任:请在 Codex 里确认已信任该目录(未信任时不加载项目 .codex/config.toml)",
CodexTrust::Trusted | CodexTrust::NotInstalled => return None,
};
Some(NextAction {
action: "codex_trust".into(),
detail: detail.into(),
})
}
/// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作与 Codex 信任,生成计划与 digest。
/// skip_targets 是人确认跳过的阻断项 target:只有可跳过原因(BlockReason::skippable)的项移入 skipped,
/// 其余照旧阻断;与任何阻断项都不匹配的 target 不影响计划。不写任何文件(目录快照前后字节一致)。
pub(crate) fn plan_onboarding(
root: &Path,
project: &ProjectWrite,
machine_changes: Vec<MachineStep>,
codex_trust: CodexTrust,
skip_targets: &[String],
) -> OnboardingPlan {
let sink = PlanSink::default();
let mut blocked = identity_conflict(root, project.project_id)
.into_iter()
.collect::<Vec<_>>();
blocked.extend(write_project_files(root, project, &sink));
let pending = sink.pending.borrow();
let mut files = Vec::new();
for path in sink.touched.borrow().iter() {
let relative = relative_path(root, path);
let disk = read_disk(path).ok().flatten();
let (action, content, diff, before_sha256) = match (pending.get(path), &disk) {
(Some(new), None) => (
FileAction::Create,
new.clone(),
Some(format!("新建,{} 行", new.lines().count())),
None,
),
(Some(new), Some(old)) => (
FileAction::Update,
new.clone(),
Some(bounded_diff(old, new, DIFF_LINE_LIMIT)),
Some(sha256_hex(&old.replace("\r\n", "\n"))),
),
(None, current) => (
FileAction::Noop,
current.clone().unwrap_or_default(),
None,
None,
),
};
let mut warning = None;
if action == FileAction::Update
&& crate::commands::onboarding::MANAGED_BLOCK_FILES.contains(&relative.as_str())
&& crate::commands::onboarding::has_uncommitted_outside_managed(
root,
&relative,
disk.as_deref().unwrap_or(""),
)
{
let text = crate::commands::onboarding::mixed_file_warning(&relative);
blocked.push(BlockedItem {
target: relative.clone(),
reason: BlockReason::MixedFile,
detail: text.clone(),
});
warning = Some(text);
}
files.push(PlannedFile {
path: relative,
action,
diff,
warning,
content_sha256: sha256_hex(&content.replace("\r\n", "\n")),
before_sha256,
});
}
blocked.extend(machine_changes.iter().filter_map(machine_step_blocker));
// 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」
let mut seen = std::collections::HashSet::new();
blocked.retain(|item| seen.insert((item.target.clone(), item.reason)));
let (skipped, blocked): (Vec<_>, Vec<_>) = blocked.into_iter().partition(|item| {
item.reason.skippable() && skip_targets.iter().any(|target| *target == item.target)
});
let settings = root.join(".claude").join("settings.json");
let bypass_permissions = writes_bypass_permissions(
read_disk(&settings).ok().flatten().as_deref(),
pending.get(&settings).map(String::as_str),
);
let root_text = root.display().to_string();
let digest = plan_digest(
project.project_id,
&root_text,
&files,
&machine_changes,
&blocked,
&skipped,
);
OnboardingPlan {
project_id: project.project_id.to_string(),
root: root_text,
files,
machine_changes,
blocked,
skipped,
bypass_permissions,
codex_trust,
next_actions: codex_trust_next_action(codex_trust).into_iter().collect(),
digest,
}
}
/// 写入后 settings.json 的 permissions.defaultMode 是 bypassPermissions、写入前不是:即本次会新写入该默认值。
fn writes_bypass_permissions(before: Option<&str>, after: Option<&str>) -> bool {
let mode = |text: Option<&str>| -> Option<String> {
let value: serde_json::Value =
serde_json::from_str(text?.trim_start_matches('\u{feff}')).ok()?;
value
.pointer("/permissions/defaultMode")?
.as_str()
.map(str::to_string)
};
let bypass = Some("bypassPermissions".to_string());
after.is_some() && mode(after) == bypass && mode(before) != bypass
}
/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成;
/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。
/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。
pub(crate) fn token_step(state: crate::credential_store::McpAuthTokenState) -> MachineStep {
use crate::credential_store::McpAuthTokenState;
let (status, detail) = match state {
McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()),
McpAuthTokenState::Missing => (
"apply",
"将在本机受保护存储生成项目 MCP 认证 token".to_string(),
),
McpAuthTokenState::NeedsRepair(reason) => (
"needs_human",
format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"),
),
};
MachineStep {
step: "token".into(),
status: status.into(),
detail,
}
}
/// 客户端只读判定结果 → 计划里的本机动作状态。
pub(crate) fn outcome_status(outcome: &super::clients::ClientOutcome) -> &'static str {
use super::clients::ClientOutcome;
match outcome {
ClientOutcome::Converged => "noop",
ClientOutcome::Missing
| ClientOutcome::Applied
| ClientOutcome::Refreshed
| ClientOutcome::Removed
| ClientOutcome::Deferred => "apply",
ClientOutcome::NotInstalled => "not_installed",
ClientOutcome::NotApplicable { .. } => "not_applicable",
ClientOutcome::ManualKept => "manual_kept",
ClientOutcome::OtherProject { .. } => "other_project",
ClientOutcome::NeedsHuman { .. } => "needs_human",
ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable",
}
}
/// context.json 已有不同的 project_id:一键接入会改写身份锚点,必须人确认(不自动覆盖)。
fn identity_conflict(root: &Path, project_id: &str) -> Option<BlockedItem> {
let text = read_disk(&root.join(".lian-jing").join("context.json")).ok()??;
let existing = serde_json::from_str::<serde_json::Value>(text.trim_start_matches('\u{feff}'))
.ok()?
.get("project_id")?
.as_str()?
.to_string();
(existing != project_id).then(|| BlockedItem {
target: ".lian-jing/context.json".into(),
reason: BlockReason::IdentityConflict,
detail: format!("目录已属于项目 {existing},与本次接入的 {project_id} 不一致"),
})
}
fn relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn sha256_hex(text: &str) -> String {
use sha2::Digest;
sha2::Sha256::digest(text.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
/// digest 覆盖项目 ID、项目根目录、逐文件(路径、动作、写入前与写入后内容哈希)、本机动作、阻断项与跳过项;
/// Codex 信任与 next_actions 不影响写入内容,不进 digest(用户去 Codex 信任目录后不必重新确认)。
/// 根目录进 digest(P8d 审查 ①):文件路径与哈希都相对根目录,两个内容相同的目录算出的其余部分一样,
/// 不绑根目录就会在登记迁到另一目录后把人确认过的计划写进人没见过的目录。
fn plan_digest(
project_id: &str,
root: &str,
files: &[PlannedFile],
machine: &[MachineStep],
blocked: &[BlockedItem],
skipped: &[BlockedItem],
) -> String {
let canonical = serde_json::json!({
"project_id": project_id,
"root": root,
"files": files.iter().map(|f| (f.path.as_str(), f.action, f.before_sha256.as_deref(), f.content_sha256.as_str())).collect::<Vec<_>>(),
"machine": machine.iter().map(|m| (m.step.as_str(), m.status.as_str())).collect::<Vec<_>>(),
"blocked": blocked.iter().map(|b| (b.target.as_str(), b.reason)).collect::<Vec<_>>(),
"skipped": skipped.iter().map(|b| (b.target.as_str(), b.reason)).collect::<Vec<_>>(),
});
sha256_hex(&canonical.to_string())
}
const DIFF_LINE_LIMIT: usize = 60;
/// 行级差异(去掉公共前后缀后列出删除与新增行),超过 limit 行截断并注明总行数。
fn bounded_diff(old: &str, new: &str, limit: usize) -> String {
let old = old.replace("\r\n", "\n");
let new = new.replace("\r\n", "\n");
let a: Vec<&str> = old.lines().collect();
let b: Vec<&str> = new.lines().collect();
let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
let max_suffix = a.len().min(b.len()) - prefix;
let suffix = a
.iter()
.rev()
.zip(b.iter().rev())
.take(max_suffix)
.take_while(|(x, y)| x == y)
.count();
let mut lines = vec![format!("@@ 第 {} 行起", prefix + 1)];
lines.extend(
a[prefix..a.len() - suffix]
.iter()
.map(|line| format!("-{line}")),
);
lines.extend(
b[prefix..b.len() - suffix]
.iter()
.map(|line| format!("+{line}")),
);
let total = lines.len();
if total > limit {
lines.truncate(limit);
lines.push(format!("…(diff 共 {total} 行,只显示前 {limit} 行)"));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn git(dir: &Path, args: &[&str]) {
let out = 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 可执行");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// 目录快照:相对路径 → 字节(含 .git,证明 plan 零写入)。
fn snapshot(root: &Path) -> BTreeMap<String, Vec<u8>> {
let mut out = BTreeMap::new();
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
pending.push(path);
} else {
out.insert(relative_path(root, &path), std::fs::read(&path).unwrap());
}
}
}
out
}
const PROJECT: ProjectWrite<'static> = ProjectWrite {
project_id: "p8c-test",
port: 27190,
server_url: "http://127.0.0.1:27190/mcp/projects/p8c-test",
header_helper: "\"C:\\x\\lianjing-mcp-header.cmd\" abc",
bypass_permissions: true,
};
fn machine(status: &str) -> Vec<MachineStep> {
vec![MachineStep {
step: "claude".into(),
status: status.into(),
detail: String::new(),
}]
}
/// 本机只有 claude 一步、Codex 已信任时的计划。
fn plan_for(root: &Path, status: &str) -> OnboardingPlan {
plan_onboarding(root, &PROJECT, machine(status), CodexTrust::Trusted, &[])
}
fn blocked_pairs(plan: &OnboardingPlan) -> Vec<(String, BlockReason)> {
plan.blocked
.iter()
.map(|item| (item.target.clone(), item.reason))
.collect()
}
fn temp_repo(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("lj_p8c_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
git(&root, &["init", "-q"]);
std::fs::write(root.join("README.md"), "readme\n").unwrap();
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "--no-verify", "-m", "init"]);
root
}
#[test]
fn plan_on_empty_repo_creates_everything_and_writes_nothing() {
let root = temp_repo("empty");
let before = snapshot(&root);
let plan = plan_for(&root, "apply");
assert_eq!(snapshot(&root), before, "plan 不得写任何文件");
assert!(plan.files.len() >= 12, "实际 {} 个文件", plan.files.len());
assert!(
plan.files.iter().all(|f| f.action == FileAction::Create),
"{:?}",
plan.files
);
for expected in [
"AGENTS.md",
"CLAUDE.md",
".blueprint/CONVENTIONS.md",
".lian-jing/context.json",
".claude/settings.json",
".claude/liangjing.json",
".codex/config.toml",
".agents/skills/lian-jing/SKILL.md",
".claude/skills/lian-jing/SKILL.md",
] {
assert!(
plan.files.iter().any(|f| f.path == expected),
"缺 {expected}"
);
}
assert!(plan.blocked.is_empty(), "{:?}", plan.blocked);
assert_eq!(plan.digest.len(), 64);
let _ = std::fs::remove_dir_all(&root);
}
/// 「看到的就是将写的」:计划列出的文件集合与内容哈希,和随后 apply 实际写入的完全一致
/// (P8d 的 digest 复核依赖这条不变式)。
#[test]
fn plan_predicts_exactly_what_apply_writes() {
let root = temp_repo("predict");
let plan = plan_for(&root, "apply");
assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty());
for file in &plan.files {
let written = std::fs::read_to_string(root.join(&file.path)).unwrap();
assert_eq!(
sha256_hex(&written.replace("\r\n", "\n")),
file.content_sha256,
"{} 计划内容与实际写入不一致",
file.path
);
}
let status = std::process::Command::new("git")
.args(["status", "--porcelain", "-uall"])
.current_dir(&root)
.output()
.expect("git 可执行");
let mut changed: Vec<String> = String::from_utf8_lossy(&status.stdout)
.lines()
.map(|line| line[3..].trim_matches('"').to_string())
.collect();
changed.sort();
let mut planned: Vec<String> = plan.files.iter().map(|f| f.path.clone()).collect();
planned.sort();
assert_eq!(changed, planned, "apply 写入的文件集合与计划一致");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn plan_after_apply_is_noop_and_digest_tracks_changes() {
let root = temp_repo("applied");
// apply:同一写入序列换成真实写入汇
assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty());
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "--no-verify", "-m", "onboard"]);
let first = plan_for(&root, "noop");
let second = plan_for(&root, "noop");
assert!(
first.files.iter().all(|f| f.action == FileAction::Noop),
"{:?}",
first.files
);
assert_eq!(first.digest, second.digest, "同一状态两次计划 digest 相同");
// 本机预计动作变化 → digest 变化
assert_ne!(plan_for(&root, "apply").digest, first.digest);
// CRLF 检出的同内容文件仍是 noop
let packs = root.join(".lian-jing/capability-packs.json");
// 模板在 Windows 检出时可能已是 CRLF:先归一成 LF 再整体转 CRLF,避免造出 \r\r\n
let lf = std::fs::read_to_string(&packs)
.unwrap()
.replace("\r\n", "\n");
std::fs::write(&packs, lf.replace('\n', "\r\n")).unwrap();
let crlf = plan_for(&root, "noop");
let not_noop: Vec<_> = crlf
.files
.iter()
.filter(|f| f.action != FileAction::Noop)
.map(|f| (f.path.clone(), f.action, f.diff.clone()))
.collect();
assert!(not_noop.is_empty(), "{not_noop:?}");
// 计划会写入或核对的文件内容变化 → 该文件 update、digest 变化
std::fs::write(&packs, "{}\n").unwrap();
let changed = plan_for(&root, "noop");
let find_packs = |plan: &OnboardingPlan| {
plan.files
.iter()
.find(|f| f.path == ".lian-jing/capability-packs.json")
.cloned()
.unwrap()
};
let entry = find_packs(&changed);
assert_eq!(entry.action, FileAction::Update);
assert!(entry.diff.as_deref().unwrap().contains("-{}"));
assert_eq!(
entry.before_sha256.as_deref(),
Some(sha256_hex("{}\n").as_str())
);
assert_ne!(changed.digest, first.digest);
// 写入前内容进 digest:确认后文件又被改、写入后内容不变 → digest 仍变(apply 会拒绝)
std::fs::write(&packs, "{\"edited\":true}\n").unwrap();
let edited = plan_for(&root, "noop");
let edited_entry = find_packs(&edited);
assert_eq!(edited_entry.action, FileAction::Update);
assert_eq!(
edited_entry.content_sha256, entry.content_sha256,
"写入后内容相同"
);
assert_ne!(
edited.digest, changed.digest,
"写入前内容不同 → digest 不同"
);
// 计划不会触碰的目标(已存在的只建不改文件)不列出、不进 digest:怎么改 apply 都不写
std::fs::write(root.join("lefthook.yml"), "# 项目自己的 hook\n").unwrap();
let untouched = plan_for(&root, "noop");
assert!(untouched.files.iter().all(|f| f.path != "lefthook.yml"));
assert_eq!(untouched.digest, edited.digest);
let _ = std::fs::remove_dir_all(&root);
}
/// Codex 未信任是 next_action,不是阻断项(设计 §4 blocked 只有四类,Q5 / C4):
/// 新项目几乎都未信任,若阻断则一键接入对每个新项目都走不通;信任与否也不改变 digest。
#[test]
fn codex_trust_is_next_action_not_blocker_and_outside_digest() {
let root = temp_repo("trust");
let trusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Trusted, &[]);
let untrusted = plan_onboarding(
&root,
&PROJECT,
machine("apply"),
CodexTrust::Untrusted,
&[],
);
assert!(untrusted.blocked.is_empty(), "{:?}", untrusted.blocked);
assert_eq!(untrusted.codex_trust, CodexTrust::Untrusted);
assert_eq!(
untrusted
.next_actions
.iter()
.map(|a| a.action.as_str())
.collect::<Vec<_>>(),
["codex_trust"]
);
assert!(trusted.next_actions.is_empty());
assert_eq!(
trusted.digest, untrusted.digest,
"用户去 Codex 信任目录后无需重新确认"
);
let unknown = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Unknown, &[]);
assert!(unknown.blocked.is_empty() && unknown.next_actions.len() == 1);
let absent = plan_onboarding(
&root,
&PROJECT,
machine("apply"),
CodexTrust::NotInstalled,
&[],
);
assert!(absent.next_actions.is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn machine_step_blocker_maps_statuses() {
let step = |name: &str, status: &str| MachineStep {
step: name.into(),
status: status.into(),
detail: format!("{name}:{status}"),
};
for status in [
"noop",
"apply",
"not_installed",
"not_applicable",
"unavailable",
] {
assert!(
machine_step_blocker(&step("claude", status)).is_none(),
"{status}"
);
}
let pair = |name: &str, status: &str| {
machine_step_blocker(&step(name, status)).map(|b| (b.target, b.reason))
};
assert_eq!(
pair("claude", "manual_kept"),
Some(("claude".into(), BlockReason::ManualEntry))
);
assert_eq!(
pair("claude", "other_project"),
Some(("claude".into(), BlockReason::SharedKey))
);
assert_eq!(
pair("token", "needs_human"),
Some(("token".into(), BlockReason::NeedsHuman))
);
assert_eq!(
pair("codex", "manual_kept"),
Some((CODEX_PROJECT_CONFIG.into(), BlockReason::ManualEntry)),
"codex 步的判定来自项目 .codex/config.toml,阻断项指向该文件"
);
}
/// 回归(P8c 审查):「存在即跳过」只判存在。项目自有的非 UTF-8 文件(PowerShell 5 重定向生成的
/// UTF-16LE lefthook.yml)照旧 skipped,不变成 failed、不阻断计划;全托管文件读不出则 failed(不盲目覆盖)。
#[test]
fn existing_non_utf8_files_skip_or_fail_by_ownership() {
let root = temp_repo("utf16");
let utf16: Vec<u8> = [0xFF, 0xFE]
.into_iter()
.chain(
"pre-commit:\r\n commands: {}\r\n"
.encode_utf16()
.flat_map(u16::to_le_bytes),
)
.collect();
std::fs::write(root.join("lefthook.yml"), &utf16).unwrap();
let results =
crate::commands::onboarding::dispatch_templates_with(&root, &PlanSink::default());
let lefthook = results.iter().find(|r| r.name == "lefthook.yml").unwrap();
assert_eq!(lefthook.action, "skipped", "{}", lefthook.detail);
let plan = plan_for(&root, "apply");
assert!(plan.blocked.is_empty(), "{:?}", plan.blocked);
assert!(plan.files.iter().all(|f| f.path != "lefthook.yml"));
let envoy = root.join(".claude/commands/envoy.md");
std::fs::create_dir_all(envoy.parent().unwrap()).unwrap();
std::fs::write(&envoy, &utf16).unwrap();
let plan = plan_for(&root, "apply");
assert_eq!(
blocked_pairs(&plan),
[(
".claude/commands/envoy.md".to_string(),
BlockReason::NeedsHuman
)]
);
// 真实写入同样:lefthook.yml 原样保留,envoy.md 不被覆盖
let results = crate::commands::onboarding::dispatch_templates_with(&root, &crate::DiskSink);
let action = |name: &str| {
results
.iter()
.find(|r| r.name == name)
.map(|r| r.action.clone())
.unwrap()
};
assert_eq!(action("lefthook.yml"), "skipped");
assert_eq!(action(".claude/commands/envoy.md"), "failed");
assert_eq!(std::fs::read(root.join("lefthook.yml")).unwrap(), utf16);
assert_eq!(std::fs::read(&envoy).unwrap(), utf16);
let _ = std::fs::remove_dir_all(&root);
}
/// 阻断项指向真正出错的文件,同一文件同一原因只留一条(P8c 审查)。
#[test]
fn blocked_targets_point_at_failing_files_without_duplicates() {
let root = temp_repo("targets");
// AGENTS.md 读不出:模板分发与蓝图规则同步各报一次,合并为一条
std::fs::write(root.join("AGENTS.md"), [0xFF, 0xFE, 0x00, 0xD8]).unwrap();
// settings.json 不是合法 JSON:身份与兼容投影在此失败
std::fs::create_dir_all(root.join(".claude")).unwrap();
std::fs::write(root.join(".claude/settings.json"), "{ not json").unwrap();
// Codex 托管块标记错序:文件级与本机 codex 步各报一次,合并为一条
std::fs::create_dir_all(root.join(".codex")).unwrap();
std::fs::write(
root.join(".codex/config.toml"),
"# LIANJING_MCP_MANAGED_END\n# LIANJING_MCP_MANAGED_START\n",
)
.unwrap();
let mut steps = machine("noop");
steps.push(MachineStep {
step: "codex".into(),
status: "needs_human".into(),
detail: "炼境 Codex 管理块不完整或顺序错误".into(),
});
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted, &[]);
let mut pairs = blocked_pairs(&plan);
pairs.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(
pairs,
[
(".claude/settings.json".to_string(), BlockReason::NeedsHuman),
(CODEX_PROJECT_CONFIG.to_string(), BlockReason::NeedsHuman),
("AGENTS.md".to_string(), BlockReason::NeedsHuman),
]
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn mixed_file_and_identity_conflict_are_blocked() {
let root = temp_repo("blocked");
assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty());
// 把 AGENTS.md 的接入包托管块版本改旧并提交,让计划要升级它
let agents = root.join("AGENTS.md");
let text = std::fs::read_to_string(&agents).unwrap();
let start = text.find("ONBOARDING_MANAGED_START version=\"").unwrap()
+ "ONBOARDING_MANAGED_START version=\"".len();
let end = start + text[start..].find('"').unwrap();
std::fs::write(&agents, format!("{}0.0.1{}", &text[..start], &text[end..])).unwrap();
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "--no-verify", "-m", "old block"]);
// 块外未提交改动
let dirty = std::fs::read_to_string(&agents).unwrap() + "\n项目自己的未提交段落\n";
std::fs::write(&agents, dirty).unwrap();
// 身份锚点属于别的项目
std::fs::write(
root.join(".lian-jing/context.json"),
r#"{"schema_version":1,"project_id":"someone-else"}"#,
)
.unwrap();
let plan = plan_for(&root, "noop");
let agents_entry = plan.files.iter().find(|f| f.path == "AGENTS.md").unwrap();
assert_eq!(agents_entry.action, FileAction::Update);
assert!(agents_entry.warning.is_some(), "混合文件须带预警");
assert!(plan
.blocked
.iter()
.any(|b| b.reason == BlockReason::MixedFile && b.target == "AGENTS.md"));
assert!(plan.blocked.iter().any(
|b| b.reason == BlockReason::IdentityConflict && b.detail.contains("someone-else")
));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn manual_codex_entry_and_machine_manual_states_block() {
let root = temp_repo("manual");
std::fs::create_dir_all(root.join(".codex")).unwrap();
std::fs::write(
root.join(".codex/config.toml"),
"[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n",
)
.unwrap();
let steps = || {
let mut steps = machine("other_project");
// 本机 codex 步的只读判定同样来自这份文件:与文件级 manual_entry 同因,只留一条
steps.push(MachineStep {
step: "codex".into(),
status: "manual_kept".into(),
detail: "手写条目".into(),
});
steps
};
let plan = plan_onboarding(&root, &PROJECT, steps(), CodexTrust::Trusted, &[]);
assert_eq!(
plan.blocked
.iter()
.filter(|b| b.target == ".codex/config.toml")
.map(|b| b.reason)
.collect::<Vec<_>>(),
[BlockReason::ManualEntry]
);
assert!(plan
.blocked
.iter()
.any(|b| b.reason == BlockReason::SharedKey && b.target == "claude"));
assert!(plan.skipped.is_empty());
// 人逐项选「跳过」(P8d 审查 ②):手写条目与共享项目键移入 skipped,不再阻断;跳过进 digest
let skip = [".codex/config.toml".to_string(), "claude".to_string()];
let skipped = plan_onboarding(&root, &PROJECT, steps(), CodexTrust::Trusted, &skip);
assert!(skipped.blocked.is_empty(), "{:?}", skipped.blocked);
assert_eq!(
skipped
.skipped
.iter()
.map(|b| (b.target.as_str(), b.reason))
.collect::<Vec<_>>(),
[
(".codex/config.toml", BlockReason::ManualEntry),
("claude", BlockReason::SharedKey)
]
);
assert_ne!(skipped.digest, plan.digest, "确认的是「跳过这些」的计划");
// 只跳过其中一项:另一项照旧阻断;与任何阻断项都不匹配的 target 不改变计划
let partial = plan_onboarding(
&root,
&PROJECT,
steps(),
CodexTrust::Trusted,
&["claude".to_string(), "no-such-target".to_string()],
);
assert_eq!(blocked_pairs(&partial).len(), 1);
assert_eq!(
partial.digest,
plan_onboarding(
&root,
&PROJECT,
steps(),
CodexTrust::Trusted,
&["claude".to_string()]
)
.digest
);
let _ = std::fs::remove_dir_all(&root);
}
/// 身份冲突、混合文件与需人工处理的项不可跳过(P8d 审查 ②):即使列进 skip_targets 也照旧阻断。
#[test]
fn identity_conflict_and_needs_human_cannot_be_skipped() {
let root = temp_repo("noskip");
std::fs::create_dir_all(root.join(".lian-jing")).unwrap();
std::fs::write(
root.join(".lian-jing/context.json"),
r#"{"schema_version":1,"project_id":"someone-else"}"#,
)
.unwrap();
let mut steps = machine("noop");
steps.push(MachineStep {
step: "token".into(),
status: "needs_human".into(),
detail: "needs_repair".into(),
});
let skip = [".lian-jing/context.json".to_string(), "token".to_string()];
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted, &skip);
let mut pairs = blocked_pairs(&plan);
pairs.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(
pairs,
[
(
".lian-jing/context.json".to_string(),
BlockReason::IdentityConflict
),
("token".to_string(), BlockReason::NeedsHuman),
]
);
assert!(plan.skipped.is_empty());
let _ = std::fs::remove_dir_all(&root);
}
/// 根目录进计划与 digest(P8d 审查 ①):两个内容相同、目录名也相同(模板会写入目录名)只是父目录不同的
/// 根目录,计划的其余部分一样,digest 仍不同。
#[test]
fn plan_shows_root_and_binds_it_into_digest() {
let parent = |tag: &str| {
let dir = std::env::temp_dir().join(format!("lj_p8c_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let root = dir.join("proj");
std::fs::create_dir_all(&root).unwrap();
git(&root, &["init", "-q"]);
std::fs::write(root.join("README.md"), "readme\n").unwrap();
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "--no-verify", "-m", "init"]);
(dir, root)
};
let (first_parent, first) = parent("root_a");
let (second_parent, second) = parent("root_b");
let a = plan_for(&first, "apply");
let b = plan_for(&second, "apply");
assert_eq!(a.root, first.display().to_string());
assert_eq!(
a.files
.iter()
.map(|f| (&f.path, f.action, &f.content_sha256))
.collect::<Vec<_>>(),
b.files
.iter()
.map(|f| (&f.path, f.action, &f.content_sha256))
.collect::<Vec<_>>(),
"两个目录内容相同"
);
assert_ne!(a.digest, b.digest, "确认的计划绑定将要写入的目录");
let _ = std::fs::remove_dir_all(&first_parent);
let _ = std::fs::remove_dir_all(&second_parent);
}
#[test]
fn plan_sink_overlays_pending_writes_and_reverts_to_noop() {
let root = std::env::temp_dir().join(format!("lj_p8c_sink_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let file = root.join("a.md");
std::fs::write(&file, "v1\n").unwrap();
let sink = PlanSink::default();
assert!(sink.write(&file, "v2\n").unwrap());
assert_eq!(
sink.read(&file).unwrap().as_deref(),
Some("v2\n"),
"后续步骤读到待写内容"
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "v1\n", "磁盘未动");
assert!(sink.write(&file, "v1\n").unwrap(), "改回磁盘原样");
assert!(sink.pending.borrow().is_empty(), "改回原样即撤销待写");
assert!(!sink.write(&file, "v1\r\n").unwrap(), "仅行尾不同视为未变");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn token_step_tells_missing_from_needs_repair() {
use crate::credential_store::McpAuthTokenState;
let present = token_step(McpAuthTokenState::Present);
assert_eq!(
(present.step.as_str(), present.status.as_str()),
("token", "noop")
);
assert!(machine_step_blocker(&present).is_none());
let missing = token_step(McpAuthTokenState::Missing);
assert_eq!(missing.status, "apply", "只有未配置才会生成");
assert!(machine_step_blocker(&missing).is_none());
let repair = token_step(McpAuthTokenState::NeedsRepair(
"MCP 认证材料 needs_repair".into(),
));
assert_eq!(repair.status, "needs_human");
assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成"));
let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认");
assert_eq!(
(blocked.target.as_str(), blocked.reason),
("token", BlockReason::NeedsHuman)
);
}
#[test]
fn bounded_diff_trims_common_lines_and_truncates() {
let diff = bounded_diff("a\nb\nc\n", "a\nB\nc\n", 60);
assert_eq!(diff, "@@ 第 2 行起\n-b\n+B");
let old: String = (0..200).map(|i| format!("o{i}\n")).collect();
let new: String = (0..200).map(|i| format!("n{i}\n")).collect();
let long = bounded_diff(&old, &new, 60);
assert_eq!(long.lines().count(), 61);
assert!(long.ends_with("…(diff 共 401 行,只显示前 60 行)"));
}
}