fix(onboard/parity): P8b 审查修复——未装记 not_installed、手写保留留痕、成功日志后移、只读判定误判(P8b 审查)
独立审查 agent 只读审查 d1b9b05(未发现死锁、重入或崩溃),十条发现处置:
- Claude apply:无全局配置且找不到 CLI 记 not_installed,与 DSH、static 同口径
- 手写条目保留(manual_kept)时补日志:DSH 照原文案,Codex 带 project_id
- 「MCP 配置注入成功」移到三端收敛之后,Codex 失败时改记 warn「注入失败」,
诊断不再把失败显示成成功
- 控制面 Claude 静态判定:遮蔽全局注册的残留 local 条目报 missing
- 嵌套项目:根目录是 git 根时只认等于根目录的键,否则取最具体的键
- 适应度测试改为递归;registry() 注明适配器暂寄 mcp_inject 的传递依赖
- 状态名对齐设计:skipped 改为 not_applicable{control_plane|wsl} 与 deferred
- 只读判定:读失败与 YAML 解析失败报 unavailable,Codex 块与手写同名表并存、
标记错序报 needs_human,DSH 块外手写报 manual_kept;client_static_status 改用
与注入同源的 project_root
- 修正「目录不存在」假阳性用例,补 hard_failure、inject 回执、apply 映射、
clients 不影响 pass 的测试;cargo test 300 passed,两处变异验证失败如期
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d1b9b0564e
commit
59f2c9d5fb
@ -243,8 +243,10 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
|
|||||||
- acceptance: "agent_verify: [cargo test onboard::clients] 注册表恰好列出三端,每端声明 detect、static_status、apply;sync_claude_local 的每条分支映射到一种 ClientOutcome(converged / applied / refreshed / manual_kept / other_project / unavailable{reason} / skipped{control_plane|wsl});DSH 未安装记 not_installed,块外有手写条目记 manual_kept,缺 claude CLI 时 claude 记 unavailable、其余照常;原有 log_event 的类别和文案不变。[cargo test] onboard_module_is_projection_free:src/onboard/ 下源码不含 tauri::、db::pool(、#[tauri::command]。[cargo test onboarding] 新增 clients 字段不改变 checks 计数和 VerifyState,控制面和 WSL 记 not_applicable、不拉低 pass。[MCP] 对 enterprise 连续两次 inject_mcp:第二次三端都是 converged,耗时 <1 秒(未起 claude CLI),四个项目文件 mtime 不变。"
|
- acceptance: "agent_verify: [cargo test onboard::clients] 注册表恰好列出三端,每端声明 detect、static_status、apply;sync_claude_local 的每条分支映射到一种 ClientOutcome(converged / applied / refreshed / manual_kept / other_project / unavailable{reason} / skipped{control_plane|wsl});DSH 未安装记 not_installed,块外有手写条目记 manual_kept,缺 claude CLI 时 claude 记 unavailable、其余照常;原有 log_event 的类别和文案不变。[cargo test] onboard_module_is_projection_free:src/onboard/ 下源码不含 tauri::、db::pool(、#[tauri::command]。[cargo test onboarding] 新增 clients 字段不改变 checks 计数和 VerifyState,控制面和 WSL 记 not_applicable、不拉低 pass。[MCP] 对 enterprise 连续两次 inject_mcp:第二次三端都是 converged,耗时 <1 秒(未起 claude CLI),四个项目文件 mtime 不变。"
|
||||||
- 起因:inject_project_mcp 返回 Result<(),String>,sync_claude_local 返回 (),refresh_dsh_patch 失败只写日志——Claude 与 DSH 的成败没有结构化结果,直接编排会把失败报成成功(R4),所以 P8d 必须排在本卡之后。
|
- 起因:inject_project_mcp 返回 Result<(),String>,sync_claude_local 返回 (),refresh_dsh_patch 失败只写日志——Claude 与 DSH 的成败没有结构化结果,直接编排会把失败报成成功(R4),所以 P8d 必须排在本卡之后。
|
||||||
- 实现(2026-10-03,分支 feat/agent-client-parity-p8):新增 src-tauri/src/onboard/(mod.rs 含适应度测试;clients.rs 定义 ClientId、ClientOutcome、ClientReport、ProjectFacts、DesiredMcp、ClientAdapter trait 与 registry())。三端适配器实现在 mcp_inject(需要其私有辅助函数),注册表反向接入。inject_project_mcp_locked 写完项目身份与 Claude 兼容投影后经 registry() 逐端 apply 并收集报告;新增 inject_project_mcp_report 返回逐端结果,inject_project_mcp 签名不变,只有 Codex 托管块写入失败算整体失败(沿用旧语义)。sync_claude_local 抽出依赖注入版 sync_claude_local_with(Claude 配置与 CLI 执行器由调用方给出),每条分支返回一种 ClientOutcome,日志类别与文案不变;ensure_dsh_patch 抽出 ensure_dsh_patch_at(DSH 目录、凭据存储、会话插件引用注入),refresh_dsh_patch 返回结果;upsert_codex_mcp_config 返回 converged/applied/manual_kept。只读判定 claude_static_outcome / codex_static_outcome / dsh_static_outcome + client_static_status 供 verify_onboarding 的 clients 附加字段(MCP 投影)。inject_mcp 回执改为逐端列出实际结果。
|
- 实现(2026-10-03,分支 feat/agent-client-parity-p8):新增 src-tauri/src/onboard/(mod.rs 含适应度测试;clients.rs 定义 ClientId、ClientOutcome、ClientReport、ProjectFacts、DesiredMcp、ClientAdapter trait 与 registry())。三端适配器实现在 mcp_inject(需要其私有辅助函数),注册表反向接入。inject_project_mcp_locked 写完项目身份与 Claude 兼容投影后经 registry() 逐端 apply 并收集报告;新增 inject_project_mcp_report 返回逐端结果,inject_project_mcp 签名不变,只有 Codex 托管块写入失败算整体失败(沿用旧语义)。sync_claude_local 抽出依赖注入版 sync_claude_local_with(Claude 配置与 CLI 执行器由调用方给出),每条分支返回一种 ClientOutcome,日志类别与文案不变;ensure_dsh_patch 抽出 ensure_dsh_patch_at(DSH 目录、凭据存储、会话插件引用注入),refresh_dsh_patch 返回结果;upsert_codex_mcp_config 返回 converged/applied/manual_kept。只读判定 claude_static_outcome / codex_static_outcome / dsh_static_outcome + client_static_status 供 verify_onboarding 的 clients 附加字段(MCP 投影)。inject_mcp 回执改为逐端列出实际结果。
|
||||||
- 行为变化(有意为之,R5):① 项目 .codex/config.toml 已手写 [mcp_servers.lian-jing] 时由整体报错改为 Codex 记 manual_kept、Claude 与 DSH 照常接入;② Codex 托管块写入失败时 Claude 与 DSH 仍会完成(旧代码先写 Codex、失败即中止),inject_project_mcp 仍返回 Err;③ WSL 项目不再对 Claude local 起 CLI,记 skipped{wsl}(2026-07-04 放弃 WSL 路径项目裁决;DSH 白名单本就排除 WSL);④ Codex 托管块的写入顺序移到 Claude 兼容投影之后。
|
- 行为变化(有意为之,R5):① 项目 .codex/config.toml 已手写 [mcp_servers.lian-jing] 时由整体报错改为 Codex 记 manual_kept、Claude 与 DSH 照常接入;② Codex 托管块写入失败时 Claude 与 DSH 仍会完成(旧代码先写 Codex、失败即中止),inject_project_mcp 仍返回 Err;③ WSL 项目不再对 Claude local 起 CLI,记 not_applicable{wsl}(2026-07-04 放弃 WSL 路径项目裁决;DSH 白名单本就排除 WSL);④ Codex 托管块的写入顺序移到 Claude 兼容投影之后。
|
||||||
- verification: "2026-10-03:cargo test 295 passed / 0 failed / 5 ignored(新增 9 条:onboard::clients 4 条——注册表恰好三端且顺序固定、settled 判定、扁平序列化、WSL 根识别;onboard 适应度测试;mcp_inject 4 条——claude_sync_maps_every_branch_to_an_outcome 用假 CLI 覆盖 converged×2/unavailable×3/applied/failed×3(含回滚调用序列 get→remove→add-json→add-json)/refreshed/removed/other_project/manual_kept/needs_human,static_outcomes_are_read_only_judgments,dsh_patch_reports_not_installed_applied_converged_and_manual_kept(临时 DSH 目录,手写条目不被覆盖),adapters_skip_control_plane_and_wsl_without_touching_disk;原有 Codex 手写冲突测试改断言 manual_kept)。[MCP] 两次 inject_mcp 的耗时与 mtime 断言须在合并后、运行中的炼境重编译生效时实测,通过后本卡标 ✅。"
|
- verification: "2026-10-03:cargo test 295 passed / 0 failed / 5 ignored(新增 9 条:onboard::clients 4 条——注册表恰好三端且顺序固定、settled 判定、扁平序列化、WSL 根识别;onboard 适应度测试;mcp_inject 4 条——claude_sync_maps_every_branch_to_an_outcome 用假 CLI 覆盖 converged×2/unavailable×3/applied/failed×3(含回滚调用序列 get→remove→add-json→add-json)/refreshed/removed/other_project/manual_kept/needs_human,static_outcomes_are_read_only_judgments,dsh_patch_reports_not_installed_applied_converged_and_manual_kept(临时 DSH 目录,手写条目不被覆盖),adapters_skip_control_plane_and_wsl_without_touching_disk;原有 Codex 手写冲突测试改断言 manual_kept)。[MCP] 两次 inject_mcp 的耗时与 mtime 断言须在合并后、运行中的炼境重编译生效时实测,通过后本卡标 ✅。"
|
||||||
|
- review: "2026-10-03 独立审查 agent 只读审查 d1b9b05(未发现死锁、重入或崩溃),10 条发现处置:① Claude apply 未装时报 unavailable → 无全局配置且找不到 CLI 记 not_installed(与 DSH、static 同口径);② 手写条目保留时丢日志 → DSH 照原文案补记 mcp_client_sync warn,Codex 记一条带 project_id 的 warn;③「MCP 配置注入成功」写在 Codex 之前,Codex 失败时诊断仍显示成功 → 移到三端收敛之后,整体失败时改记 warn「注入失败」;④ 控制面 Claude 静态判定掩盖遮蔽全局注册的残留 local 条目 → 有残留报 missing;⑤ 嵌套项目误判 other_project → 根目录自身是 git 根时只认等于根目录的键,否则取最具体的键;⑥ 适应度测试不递归 → 改为递归,并在 registry() 注明适配器暂寄 mcp_inject 的传递依赖(绞杀者过渡);⑦ 状态名与设计不一致 → skipped 改为 not_applicable{control_plane|wsl} 与 deferred;⑧ 只读判定读失败当未配置、块与手写并存、路径来源不同 → 读失败与 YAML 解析失败报 unavailable,Codex 块与手写同名表并存报 needs_human,DSH 块外手写报 manual_kept,标记错序报 needs_human,client_static_status 改用与注入同源的 project_root;⑨「目录不存在」用例为假阳性 → 改为 CLI 存在仍不起 CLI 并断言原因,另补 hard_failure、inject 回执、apply 结果映射、clients 不影响 pass 的测试;⑩ 记入 known_limits。修复后 cargo test 300 passed / 0 failed / 5 ignored(较首版新增 5 条),变异验证:删目录守卫、去掉自身 git 根判定,对应用例均失败。"
|
||||||
|
- known_limits: "WSL 项目已有的 Claude local 托管条目在端口变更后不再刷新(重注入对 WSL 记 not_applicable),而 remove_project_mcp 与 on_project_root_changed 对 WSL 根仍会起 CLI 清理——口径不完全一致,WSL 路径项目已按 2026-07-04 裁决放弃,暂不处理。Codex 托管块已存在时 upsert 不检查块外手写同名表(存量问题),只读判定会报 needs_human 提醒人工清理。注册表与适配器的端到端组合(缺 CLI 时其余两端照常)只在单元层分别验证,未做真实客户端的组合实测。"
|
||||||
|
|
||||||
### 📋 P8c — 写入前计划:WriteSink + plan_onboarding
|
### 📋 P8c — 写入前计划:WriteSink + plan_onboarding
|
||||||
- status: todo
|
- status: todo
|
||||||
|
|||||||
@ -26,7 +26,7 @@ contracts:
|
|||||||
- ".lian-jing/context.json"
|
- ".lian-jing/context.json"
|
||||||
- ".lian-jing/capability-packs.json"
|
- ".lian-jing/capability-packs.json"
|
||||||
- ".claude/liangjing.json"
|
- ".claude/liangjing.json"
|
||||||
doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。接入经客户端适配器注册表(onboard::clients::registry:Claude、Codex、DSH)逐端收敛并返回结构化结果 ClientOutcome(converged/applied/refreshed/removed/manual_kept/other_project/not_installed/skipped{control_plane|wsl|deferred}/missing/unavailable/needs_human/failed);inject_mcp 回执逐端列出,只有 Codex 托管块写入失败算整体失败;项目 .codex/config.toml 已手写同名 server 记 manual_kept,不再整体报错;verify_onboarding 附加 clients 字段(三端只读状态,不计入 checks、不影响 pass)。src-tauri/src/onboard/ 是一键接入本体模块,源码不得依赖 Tauri、不得直接取全局连接池(适应度测试 onboard_module_is_projection_free)。"
|
doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。接入经客户端适配器注册表(onboard::clients::registry:Claude、Codex、DSH)逐端收敛并返回结构化结果 ClientOutcome(converged/applied/refreshed/removed/manual_kept/other_project/not_installed/not_applicable{control_plane|wsl}/deferred/missing/unavailable/needs_human/failed);inject_mcp 回执逐端列出,只有 Codex 托管块写入失败算整体失败;项目 .codex/config.toml 已手写同名 server 记 manual_kept,不再整体报错;verify_onboarding 附加 clients 字段(三端只读状态,不计入 checks、不影响 pass)。src-tauri/src/onboard/ 是一键接入本体模块,源码不得依赖 Tauri、不得直接取全局连接池(适应度测试 onboard_module_is_projection_free)。"
|
||||||
|
|
||||||
- label: Tauri command 接口(前后端契约)
|
- label: Tauri command 接口(前后端契约)
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@ -1832,9 +1832,46 @@ mod tests {
|
|||||||
required: Some(false),
|
required: Some(false),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let report = summarize_onboarding("p1", checks, ready);
|
let mut report = summarize_onboarding("p1", checks, ready);
|
||||||
assert!(report.pass);
|
assert!(report.pass);
|
||||||
assert!(report.summary.contains("runtime ready"));
|
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]
|
#[test]
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use crate::onboard::clients::{
|
use crate::onboard::clients::{
|
||||||
ClientAdapter, ClientId, ClientOutcome, ClientReport, DesiredMcp, ProjectFacts, SkipReason,
|
ClientAdapter, ClientId, ClientOutcome, ClientReport, DesiredMcp, NotApplicableReason,
|
||||||
|
ProjectFacts,
|
||||||
};
|
};
|
||||||
use crate::{db, db::log_event};
|
use crate::{db, db::log_event};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@ -760,36 +761,47 @@ fn claude_static_outcome(
|
|||||||
desired: &Value,
|
desired: &Value,
|
||||||
) -> ClientOutcome {
|
) -> ClientOutcome {
|
||||||
let root_text = root.display().to_string();
|
let root_text = root.display().to_string();
|
||||||
let entries: Vec<&Value> = config
|
// Claude 以 git 根为项目键:根目录自身是 git 根时只认等于根目录的键,祖先目录的条目不在本目录生效
|
||||||
|
let own_git_root = root.join(".git").exists();
|
||||||
|
let applies = |key: &str| {
|
||||||
|
if own_git_root {
|
||||||
|
normalize_root_key(key) == normalize_root_key(&root_text)
|
||||||
|
} else {
|
||||||
|
key_covers_root(key, &root_text)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut entries: Vec<(String, &Value)> = config
|
||||||
.get("projects")
|
.get("projects")
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
.map(|projects| {
|
.map(|projects| {
|
||||||
projects
|
projects
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(key, _)| key_covers_root(key, &root_text))
|
.filter(|(key, _)| applies(key))
|
||||||
.filter_map(|(_, project)| project.get("mcpServers")?.get(MCP_KEY))
|
.filter_map(|(key, project)| {
|
||||||
|
Some((
|
||||||
|
normalize_root_key(key),
|
||||||
|
project.get("mcpServers")?.get(MCP_KEY)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if entries.iter().any(|entry| *entry == desired) {
|
// 嵌套项目:最具体(最长)的键才是本目录生效的条目,祖先目录的条目属于外层项目
|
||||||
|
entries.sort_by_key(|(key, _)| std::cmp::Reverse(key.len()));
|
||||||
|
let Some((_, effective)) = entries.first() else {
|
||||||
|
return ClientOutcome::Missing;
|
||||||
|
};
|
||||||
|
if *effective == desired {
|
||||||
return ClientOutcome::Converged;
|
return ClientOutcome::Converged;
|
||||||
}
|
}
|
||||||
let owners: Vec<Option<String>> = entries
|
match effective
|
||||||
.iter()
|
|
||||||
.map(|entry| {
|
|
||||||
entry
|
|
||||||
.get("url")
|
.get("url")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(lianjing_project_of)
|
.and_then(lianjing_project_of)
|
||||||
})
|
{
|
||||||
.collect();
|
None => ClientOutcome::ManualKept,
|
||||||
match owners.first() {
|
Some(owner) if owner == project_id => ClientOutcome::Missing,
|
||||||
None => ClientOutcome::Missing,
|
Some(owner) => ClientOutcome::OtherProject { owner },
|
||||||
Some(None) => ClientOutcome::ManualKept,
|
|
||||||
Some(Some(owner)) if owner == project_id => ClientOutcome::Missing,
|
|
||||||
Some(Some(owner)) => ClientOutcome::OtherProject {
|
|
||||||
owner: owner.clone(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1177,11 +1189,26 @@ fn dsh_static_outcome(
|
|||||||
else {
|
else {
|
||||||
return match has_manual_lian_jing(patch) {
|
return match has_manual_lian_jing(patch) {
|
||||||
Ok(true) => ClientOutcome::ManualKept,
|
Ok(true) => ClientOutcome::ManualKept,
|
||||||
_ => ClientOutcome::Missing,
|
Ok(false) => ClientOutcome::Missing,
|
||||||
|
Err(reason) => ClientOutcome::Unavailable { reason },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
if end < start {
|
if end < start {
|
||||||
return ClientOutcome::Missing;
|
// apply 遇到同样的标记错序会报 failed;静态判定如实标需人工
|
||||||
|
return ClientOutcome::NeedsHuman {
|
||||||
|
reason: "DSH 补丁文件中的炼境托管块不完整或顺序错误".into(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 块外手写 lian-jing 时 apply 永远保留手写、不更新块:静态判定与之同口径
|
||||||
|
let outside = format!(
|
||||||
|
"{}{}",
|
||||||
|
&patch[..start],
|
||||||
|
&patch[end + DSH_MANAGED_END.len()..]
|
||||||
|
);
|
||||||
|
match has_manual_lian_jing(&outside) {
|
||||||
|
Ok(true) => return ClientOutcome::ManualKept,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(reason) => return ClientOutcome::Unavailable { reason },
|
||||||
}
|
}
|
||||||
let block = &patch[start..end];
|
let block = &patch[start..end];
|
||||||
if !block.contains(&format!("http://127.0.0.1:{port}/mcp")) {
|
if !block.contains(&format!("http://127.0.0.1:{port}/mcp")) {
|
||||||
@ -1218,15 +1245,23 @@ pub fn refresh_dsh_patch(exclude: Option<&str>) -> ClientOutcome {
|
|||||||
.get()
|
.get()
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
.and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude));
|
.and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude));
|
||||||
match result {
|
let log_warn = |error: &str| {
|
||||||
Ok(outcome) => outcome,
|
|
||||||
Err(error) => {
|
|
||||||
db::log_event(
|
db::log_event(
|
||||||
CLIENT_SYNC_LOG,
|
CLIENT_SYNC_LOG,
|
||||||
"warn",
|
"warn",
|
||||||
&format!("DSH 托管块刷新失败: {error}"),
|
&format!("DSH 托管块刷新失败: {error}"),
|
||||||
Some(&json!({ "client": "dsh" }).to_string()),
|
Some(&json!({ "client": "dsh" }).to_string()),
|
||||||
);
|
)
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(ClientOutcome::ManualKept) => {
|
||||||
|
// 原为报错路径:手写条目保留后仍按原文案留痕
|
||||||
|
log_warn(DSH_MANUAL_MESSAGE);
|
||||||
|
ClientOutcome::ManualKept
|
||||||
|
}
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(error) => {
|
||||||
|
log_warn(&error);
|
||||||
ClientOutcome::Failed { reason: error }
|
ClientOutcome::Failed { reason: error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1348,13 +1383,31 @@ fn codex_static_outcome(
|
|||||||
return ClientOutcome::Missing;
|
return ClientOutcome::Missing;
|
||||||
};
|
};
|
||||||
let normalized = current.replace("\r\n", "\n");
|
let normalized = current.replace("\r\n", "\n");
|
||||||
match render_codex_block(server_url, header_helper) {
|
let outside_block = match (
|
||||||
Ok(block) if normalized.contains(&block) => ClientOutcome::Converged,
|
normalized.find(CODEX_MCP_MANAGED_START),
|
||||||
_ if !normalized.contains(CODEX_MCP_MANAGED_START)
|
normalized.find(CODEX_MCP_MANAGED_END),
|
||||||
&& has_manual_codex_entry(&normalized) =>
|
) {
|
||||||
{
|
(Some(start), Some(end)) if end >= start => format!(
|
||||||
ClientOutcome::ManualKept
|
"{}{}",
|
||||||
|
&normalized[..start],
|
||||||
|
&normalized[end + CODEX_MCP_MANAGED_END.len()..]
|
||||||
|
),
|
||||||
|
(None, None) => normalized.clone(),
|
||||||
|
_ => {
|
||||||
|
return ClientOutcome::NeedsHuman {
|
||||||
|
reason: "炼境 Codex 管理块不完整或顺序错误".into(),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let manual = has_manual_codex_entry(&outside_block);
|
||||||
|
let has_block = normalized.contains(CODEX_MCP_MANAGED_START);
|
||||||
|
match render_codex_block(server_url, header_helper) {
|
||||||
|
// 托管块与块外手写同名表并存:Codex 加载配置会因重复表失败
|
||||||
|
_ if has_block && manual => ClientOutcome::NeedsHuman {
|
||||||
|
reason: format!("项目 .codex/config.toml 托管块外另有手写 [mcp_servers.{MCP_KEY}],重复定义会使 Codex 配置加载失败"),
|
||||||
|
},
|
||||||
|
Ok(block) if normalized.contains(&block) => ClientOutcome::Converged,
|
||||||
|
_ if manual => ClientOutcome::ManualKept,
|
||||||
_ => ClientOutcome::Missing,
|
_ => ClientOutcome::Missing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1507,10 +1560,7 @@ fn inject_project_mcp_locked(
|
|||||||
"url": server_url
|
"url": server_url
|
||||||
});
|
});
|
||||||
|
|
||||||
write_json(&path, &settings).inspect(|_| {
|
write_json(&path, &settings)?;
|
||||||
let ctx = serde_json::json!({"project_id": project_id}).to_string();
|
|
||||||
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
||||||
write_liangjing_context(project_id, port)?;
|
write_liangjing_context(project_id, port)?;
|
||||||
@ -1523,10 +1573,22 @@ fn inject_project_mcp_locked(
|
|||||||
header_helper,
|
header_helper,
|
||||||
refresh_dsh,
|
refresh_dsh,
|
||||||
};
|
};
|
||||||
Ok(crate::onboard::clients::registry()
|
let reports: Vec<ClientReport> = crate::onboard::clients::registry()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|adapter| ClientReport::new(adapter.id(), adapter.apply(&facts, &desired)))
|
.map(|adapter| ClientReport::new(adapter.id(), adapter.apply(&facts, &desired)))
|
||||||
.collect())
|
.collect();
|
||||||
|
// 诊断按 project_id 取最后一条 mcp_inject 事件:成功日志须在三端收敛之后、且只在没有整体失败时写
|
||||||
|
let ctx = json!({ "project_id": project_id }).to_string();
|
||||||
|
match hard_failure(&reports) {
|
||||||
|
None => log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)),
|
||||||
|
Some(error) => log_event(
|
||||||
|
"mcp_inject",
|
||||||
|
"warn",
|
||||||
|
&format!("注入失败: {error}"),
|
||||||
|
Some(&ctx),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
Ok(reports)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectFacts<'a> {
|
fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectFacts<'a> {
|
||||||
@ -1539,10 +1601,13 @@ fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectF
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。
|
/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。
|
||||||
pub fn client_static_status(project_id: &str, root: &std::path::Path) -> Vec<ClientReport> {
|
/// 根目录与注入同源(project_root 校验登记路径),避免两边对同一项目算出不同的键。
|
||||||
|
pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
|
||||||
let port = crate::mcp_server::read_port();
|
let port = crate::mcp_server::read_port();
|
||||||
let header_helper = match crate::credential_store::mcp_auth_digest(project_id) {
|
let resolved = crate::credential_store::mcp_auth_digest(project_id)
|
||||||
Ok(digest) => shim_helper_command(&digest),
|
.and_then(|digest| project_root(project_id).map(|root| (digest, root)));
|
||||||
|
let (digest, root) = match resolved {
|
||||||
|
Ok(resolved) => resolved,
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
return crate::onboard::clients::registry()
|
return crate::onboard::clients::registry()
|
||||||
.iter()
|
.iter()
|
||||||
@ -1557,7 +1622,8 @@ pub fn client_static_status(project_id: &str, root: &std::path::Path) -> Vec<Cli
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let facts = project_facts(project_id, root);
|
let header_helper = shim_helper_command(&digest);
|
||||||
|
let facts = project_facts(project_id, &root);
|
||||||
let desired = DesiredMcp {
|
let desired = DesiredMcp {
|
||||||
port,
|
port,
|
||||||
server_url: crate::mcp_server::scoped_mcp_url(port, project_id),
|
server_url: crate::mcp_server::scoped_mcp_url(port, project_id),
|
||||||
@ -1579,11 +1645,11 @@ pub(crate) static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter;
|
|||||||
pub(crate) static CODEX_ADAPTER: CodexAdapter = CodexAdapter;
|
pub(crate) static CODEX_ADAPTER: CodexAdapter = CodexAdapter;
|
||||||
pub(crate) static DSH_ADAPTER: DshAdapter = DshAdapter;
|
pub(crate) static DSH_ADAPTER: DshAdapter = DshAdapter;
|
||||||
|
|
||||||
const SKIP_CONTROL_PLANE: ClientOutcome = ClientOutcome::Skipped {
|
const NA_CONTROL_PLANE: ClientOutcome = ClientOutcome::NotApplicable {
|
||||||
reason: SkipReason::ControlPlane,
|
reason: NotApplicableReason::ControlPlane,
|
||||||
};
|
};
|
||||||
const SKIP_WSL: ClientOutcome = ClientOutcome::Skipped {
|
const NA_WSL: ClientOutcome = ClientOutcome::NotApplicable {
|
||||||
reason: SkipReason::Wsl,
|
reason: NotApplicableReason::Wsl,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl ClientAdapter for ClaudeAdapter {
|
impl ClientAdapter for ClaudeAdapter {
|
||||||
@ -1597,11 +1663,8 @@ impl ClientAdapter for ClaudeAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||||||
if project.control_plane {
|
|
||||||
return SKIP_CONTROL_PLANE;
|
|
||||||
}
|
|
||||||
if project.wsl {
|
if project.wsl {
|
||||||
return SKIP_WSL;
|
return NA_WSL;
|
||||||
}
|
}
|
||||||
if !self.detect() {
|
if !self.detect() {
|
||||||
return ClientOutcome::NotInstalled;
|
return ClientOutcome::NotInstalled;
|
||||||
@ -1611,6 +1674,9 @@ impl ClientAdapter for ClaudeAdapter {
|
|||||||
reason: "无法读取 Claude 全局配置".into(),
|
reason: "无法读取 Claude 全局配置".into(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
if project.control_plane {
|
||||||
|
return claude_control_plane_outcome(&config, project.project_id, project.root);
|
||||||
|
}
|
||||||
let entry = claude_local_server_entry(&desired.server_url, &desired.header_helper);
|
let entry = claude_local_server_entry(&desired.server_url, &desired.header_helper);
|
||||||
claude_static_outcome(&config, project.project_id, project.root, &entry)
|
claude_static_outcome(&config, project.project_id, project.root, &entry)
|
||||||
}
|
}
|
||||||
@ -1621,17 +1687,46 @@ impl ClientAdapter for ClaudeAdapter {
|
|||||||
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||||||
try_register_globally(desired.port);
|
try_register_globally(desired.port);
|
||||||
if project.wsl {
|
if project.wsl {
|
||||||
return SKIP_WSL;
|
return NA_WSL;
|
||||||
|
}
|
||||||
|
// 未装 Claude(无全局配置、也找不到 CLI)与 DSH 未装同口径记 not_installed,不计失败
|
||||||
|
if !self.detect() && find_claude_bin().is_none() {
|
||||||
|
return ClientOutcome::NotInstalled;
|
||||||
}
|
}
|
||||||
let wanted = (!project.control_plane)
|
let wanted = (!project.control_plane)
|
||||||
.then(|| (desired.server_url.clone(), desired.header_helper.clone()));
|
.then(|| (desired.server_url.clone(), desired.header_helper.clone()));
|
||||||
let outcome = sync_claude_local(project.project_id, project.root, wanted);
|
finalize_for_project(
|
||||||
if project.control_plane && outcome.is_settled() {
|
project,
|
||||||
SKIP_CONTROL_PLANE
|
sync_claude_local(project.project_id, project.root, wanted),
|
||||||
} else {
|
)
|
||||||
outcome
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 控制面不该有本项目的 local 托管条目:残留会遮蔽 user 级全局注册、丢掉跨项目工具(apply 会清理),
|
||||||
|
/// 有残留报 missing,没有才是 not_applicable。
|
||||||
|
fn claude_control_plane_outcome(
|
||||||
|
config: &Value,
|
||||||
|
project_id: &str,
|
||||||
|
root: &std::path::Path,
|
||||||
|
) -> ClientOutcome {
|
||||||
|
let root_text = root.display().to_string();
|
||||||
|
let residual = managed_claude_entries(config, project_id)
|
||||||
|
.iter()
|
||||||
|
.any(|(key, _)| key_covers_root(key, &root_text));
|
||||||
|
if residual {
|
||||||
|
ClientOutcome::Missing
|
||||||
|
} else {
|
||||||
|
NA_CONTROL_PLANE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 机器级或按边界保留的成功结果,在控制面 / WSL 项目上报 not_applicable;未成功的结果原样保留。
|
||||||
|
fn finalize_for_project(project: &ProjectFacts, outcome: ClientOutcome) -> ClientOutcome {
|
||||||
|
match (project.control_plane, project.wsl, outcome.is_settled()) {
|
||||||
|
(true, _, true) => NA_CONTROL_PLANE,
|
||||||
|
(_, true, true) => NA_WSL,
|
||||||
|
_ => outcome,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientAdapter for CodexAdapter {
|
impl ClientAdapter for CodexAdapter {
|
||||||
@ -1653,7 +1748,10 @@ impl ClientAdapter for CodexAdapter {
|
|||||||
if !self.detect() {
|
if !self.detect() {
|
||||||
return ClientOutcome::NotInstalled;
|
return ClientOutcome::NotInstalled;
|
||||||
}
|
}
|
||||||
let current = std::fs::read_to_string(project.root.join(".codex").join("config.toml")).ok();
|
let current = match read_optional(&project.root.join(".codex").join("config.toml")) {
|
||||||
|
Ok(current) => current,
|
||||||
|
Err(reason) => return ClientOutcome::Unavailable { reason },
|
||||||
|
};
|
||||||
codex_static_outcome(
|
codex_static_outcome(
|
||||||
current.as_deref(),
|
current.as_deref(),
|
||||||
&desired.server_url,
|
&desired.server_url,
|
||||||
@ -1664,8 +1762,30 @@ impl ClientAdapter for CodexAdapter {
|
|||||||
/// 项目 .codex/config.toml 随仓库分发:Codex 未装也照写,保证换客户端时即可用。
|
/// 项目 .codex/config.toml 随仓库分发:Codex 未装也照写,保证换客户端时即可用。
|
||||||
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||||||
let path = project.root.join(".codex").join("config.toml");
|
let path = project.root.join(".codex").join("config.toml");
|
||||||
upsert_codex_mcp_config(&path, &desired.server_url, &desired.header_helper)
|
let outcome = upsert_codex_mcp_config(&path, &desired.server_url, &desired.header_helper)
|
||||||
.unwrap_or_else(|reason| ClientOutcome::Failed { reason })
|
.unwrap_or_else(|reason| ClientOutcome::Failed { reason });
|
||||||
|
if outcome == ClientOutcome::ManualKept {
|
||||||
|
// 原为整体报错(调用方记日志);改为保留后仍留痕,诊断里看得到
|
||||||
|
db::log_event(
|
||||||
|
CLIENT_SYNC_LOG,
|
||||||
|
"warn",
|
||||||
|
&format!(
|
||||||
|
"{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Some(&inject_log_context(project.project_id, "codex")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。
|
||||||
|
fn read_optional(path: &std::path::Path) -> Result<Option<String>, 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(format!("读取 {} 失败: {error}", path.display())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1680,15 +1800,18 @@ impl ClientAdapter for DshAdapter {
|
|||||||
|
|
||||||
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||||||
if project.control_plane {
|
if project.control_plane {
|
||||||
return SKIP_CONTROL_PLANE;
|
return NA_CONTROL_PLANE;
|
||||||
}
|
}
|
||||||
if project.wsl {
|
if project.wsl {
|
||||||
return SKIP_WSL;
|
return NA_WSL;
|
||||||
}
|
}
|
||||||
let Some(home) = dsh_home().ok().filter(|home| home.is_dir()) else {
|
let Some(home) = dsh_home().ok().filter(|home| home.is_dir()) else {
|
||||||
return ClientOutcome::NotInstalled;
|
return ClientOutcome::NotInstalled;
|
||||||
};
|
};
|
||||||
let patch = std::fs::read_to_string(home.join("cordis.patch.yml")).unwrap_or_default();
|
let patch = match read_optional(&home.join("cordis.patch.yml")) {
|
||||||
|
Ok(patch) => patch.unwrap_or_default(),
|
||||||
|
Err(reason) => return ClientOutcome::Unavailable { reason },
|
||||||
|
};
|
||||||
let key = normalize_dsh_root(&project.root.display().to_string());
|
let key = normalize_dsh_root(&project.root.display().to_string());
|
||||||
let canonical = std::fs::canonicalize(project.root)
|
let canonical = std::fs::canonicalize(project.root)
|
||||||
.ok()
|
.ok()
|
||||||
@ -1700,16 +1823,9 @@ impl ClientAdapter for DshAdapter {
|
|||||||
/// DSH 托管块是机器级的:刷新一次即同步全部已接入项目的白名单。
|
/// DSH 托管块是机器级的:刷新一次即同步全部已接入项目的白名单。
|
||||||
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||||||
if !desired.refresh_dsh {
|
if !desired.refresh_dsh {
|
||||||
return ClientOutcome::Skipped {
|
return ClientOutcome::Deferred;
|
||||||
reason: SkipReason::Deferred,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let outcome = refresh_dsh_patch(None);
|
|
||||||
match (project.control_plane, project.wsl, outcome.is_settled()) {
|
|
||||||
(true, _, true) => SKIP_CONTROL_PLANE,
|
|
||||||
(_, true, true) => SKIP_WSL,
|
|
||||||
_ => outcome,
|
|
||||||
}
|
}
|
||||||
|
finalize_for_project(project, refresh_dsh_patch(None))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2170,19 +2286,24 @@ mod tests {
|
|||||||
sync_with_fake("p1", &root, desired(), Some(&empty), no_cli),
|
sync_with_fake("p1", &root, desired(), Some(&empty), no_cli),
|
||||||
ClientOutcome::Unavailable { reason } if reason.contains("claude CLI")
|
ClientOutcome::Unavailable { reason } if reason.contains("claude CLI")
|
||||||
));
|
));
|
||||||
// 目录不存在 → unavailable(不起 CLI)
|
// 目录不存在 → unavailable:CLI 在也不起(审查修复:原用例缺 CLI,删掉目录守卫也会通过)
|
||||||
|
let not_local = "No MCP server found with name: \"lian-jing\".";
|
||||||
|
let untouched = FakeClaude::new(not_local, true, true);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
sync_with_fake("p1", &root.join("gone"), desired(), Some(&empty), no_cli),
|
sync_with_fake("p1", &root.join("gone"), desired(), Some(&empty), Some(&untouched)),
|
||||||
ClientOutcome::Unavailable { .. }
|
ClientOutcome::Unavailable { reason } if reason.contains("项目目录不存在")
|
||||||
));
|
));
|
||||||
|
assert!(
|
||||||
|
untouched.calls.borrow().is_empty(),
|
||||||
|
"目录不存在时不得起 CLI"
|
||||||
|
);
|
||||||
// get 输出无法识别 → unavailable
|
// get 输出无法识别 → unavailable
|
||||||
let garbled = FakeClaude::new("???", true, true);
|
let garbled = FakeClaude::new("???", true, true);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
sync_with_fake("p1", &root, desired(), Some(&empty), Some(&garbled)),
|
sync_with_fake("p1", &root, desired(), Some(&empty), Some(&garbled)),
|
||||||
ClientOutcome::Unavailable { .. }
|
ClientOutcome::Unavailable { reason } if reason.contains("无法识别")
|
||||||
));
|
));
|
||||||
// 本目录无 local 条目:写入成功 → applied,失败 → failed
|
// 本目录无 local 条目:写入成功 → applied,失败 → failed
|
||||||
let not_local = "No MCP server found with name: \"lian-jing\".";
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sync_with_fake(
|
sync_with_fake(
|
||||||
"p1",
|
"p1",
|
||||||
@ -2395,10 +2516,10 @@ mod tests {
|
|||||||
control_plane: true,
|
control_plane: true,
|
||||||
wsl: false,
|
wsl: false,
|
||||||
};
|
};
|
||||||
let skip_ctrl = ClientOutcome::Skipped {
|
let skip_ctrl = ClientOutcome::NotApplicable {
|
||||||
reason: SkipReason::ControlPlane,
|
reason: NotApplicableReason::ControlPlane,
|
||||||
};
|
};
|
||||||
assert_eq!(CLAUDE_ADAPTER.static_status(&control, &desired), skip_ctrl);
|
// Claude 的控制面判定要读本机配置,改测纯函数(见 control_plane_claude_residual_is_not_hidden)
|
||||||
assert_eq!(DSH_ADAPTER.static_status(&control, &desired), skip_ctrl);
|
assert_eq!(DSH_ADAPTER.static_status(&control, &desired), skip_ctrl);
|
||||||
let wsl_root = std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p");
|
let wsl_root = std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p");
|
||||||
let wsl = ProjectFacts {
|
let wsl = ProjectFacts {
|
||||||
@ -2407,21 +2528,152 @@ mod tests {
|
|||||||
control_plane: false,
|
control_plane: false,
|
||||||
wsl: true,
|
wsl: true,
|
||||||
};
|
};
|
||||||
let skip_wsl = ClientOutcome::Skipped {
|
let skip_wsl = ClientOutcome::NotApplicable {
|
||||||
reason: SkipReason::Wsl,
|
reason: NotApplicableReason::Wsl,
|
||||||
};
|
};
|
||||||
assert_eq!(CLAUDE_ADAPTER.static_status(&wsl, &desired), skip_wsl);
|
assert_eq!(CLAUDE_ADAPTER.static_status(&wsl, &desired), skip_wsl);
|
||||||
assert_eq!(DSH_ADAPTER.static_status(&wsl, &desired), skip_wsl);
|
assert_eq!(DSH_ADAPTER.static_status(&wsl, &desired), skip_wsl);
|
||||||
// 批量重注入:DSH 逐项目跳过,由调用方末尾统一刷新
|
// 批量重注入:DSH 逐项目跳过,由调用方末尾统一刷新
|
||||||
|
assert_eq!(DSH_ADAPTER.apply(&wsl, &desired), ClientOutcome::Deferred);
|
||||||
|
assert!(
|
||||||
|
[skip_ctrl.clone(), skip_wsl.clone()]
|
||||||
|
.iter()
|
||||||
|
.all(ClientOutcome::is_settled),
|
||||||
|
"不适用不计失败"
|
||||||
|
);
|
||||||
|
// apply 的结果映射:成功态在控制面 / WSL 上报 not_applicable,失败原样保留
|
||||||
|
let failed = ClientOutcome::Failed { reason: "x".into() };
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
DSH_ADAPTER.apply(&wsl, &desired),
|
finalize_for_project(&control, ClientOutcome::Removed),
|
||||||
ClientOutcome::Skipped {
|
skip_ctrl
|
||||||
reason: SkipReason::Deferred
|
);
|
||||||
|
assert_eq!(
|
||||||
|
finalize_for_project(&control, ClientOutcome::NotInstalled),
|
||||||
|
skip_ctrl
|
||||||
|
);
|
||||||
|
assert_eq!(finalize_for_project(&control, failed.clone()), failed);
|
||||||
|
assert_eq!(
|
||||||
|
finalize_for_project(&wsl, ClientOutcome::Converged),
|
||||||
|
skip_wsl
|
||||||
|
);
|
||||||
|
let normal = ProjectFacts {
|
||||||
|
project_id: "p",
|
||||||
|
root,
|
||||||
|
control_plane: false,
|
||||||
|
wsl: false,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
finalize_for_project(&normal, ClientOutcome::Applied),
|
||||||
|
ClientOutcome::Applied
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_plane_claude_residual_is_not_hidden() {
|
||||||
|
let root = std::path::Path::new("E:\\code\\ctrl");
|
||||||
|
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
|
||||||
|
let residual =
|
||||||
|
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/ctrl", helper);
|
||||||
|
let with =
|
||||||
|
json!({ "projects": { "E:/code/ctrl": { "mcpServers": { "lian-jing": residual } } } });
|
||||||
|
assert_eq!(
|
||||||
|
claude_control_plane_outcome(&with, "ctrl", root),
|
||||||
|
ClientOutcome::Missing
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
claude_control_plane_outcome(&json!({ "projects": {} }), "ctrl", root),
|
||||||
|
ClientOutcome::NotApplicable {
|
||||||
|
reason: NotApplicableReason::ControlPlane
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert!(
|
}
|
||||||
[skip_ctrl, skip_wsl].iter().all(ClientOutcome::is_settled),
|
|
||||||
"不适用不计失败"
|
#[test]
|
||||||
|
fn claude_static_prefers_own_git_root_over_ancestor_entry() {
|
||||||
|
let base = std::env::temp_dir().join(format!("lj_p8b_nested_{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
let child = base.join("sub").join("child");
|
||||||
|
std::fs::create_dir_all(child.join(".git")).unwrap();
|
||||||
|
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
|
||||||
|
let desired =
|
||||||
|
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/child", helper);
|
||||||
|
let parent_entry =
|
||||||
|
claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/parent", helper);
|
||||||
|
let key = |path: &std::path::Path| path.display().to_string().replace('\\', "/");
|
||||||
|
// 子项目是独立 git 根、自己还没有条目:祖先目录的条目不在本目录生效 → missing(apply 会写入)
|
||||||
|
let only_parent =
|
||||||
|
json!({ "projects": { key(&base): { "mcpServers": { "lian-jing": parent_entry } } } });
|
||||||
|
assert_eq!(
|
||||||
|
claude_static_outcome(&only_parent, "child", &child, &desired),
|
||||||
|
ClientOutcome::Missing
|
||||||
|
);
|
||||||
|
// 子项目自己的条目已一致 → converged(不被祖先条目干扰)
|
||||||
|
let both = json!({ "projects": {
|
||||||
|
key(&base): { "mcpServers": { "lian-jing": parent_entry } },
|
||||||
|
key(&child): { "mcpServers": { "lian-jing": desired } }
|
||||||
|
} });
|
||||||
|
assert_eq!(
|
||||||
|
claude_static_outcome(&both, "child", &child, &desired),
|
||||||
|
ClientOutcome::Converged
|
||||||
|
);
|
||||||
|
// 非 git 根的子目录(共享外层项目键):外层条目生效 → other_project
|
||||||
|
let plain = base.join("plain");
|
||||||
|
std::fs::create_dir_all(&plain).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
claude_static_outcome(&only_parent, "plain", &plain, &desired),
|
||||||
|
ClientOutcome::OtherProject {
|
||||||
|
owner: "parent".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_outcomes_flag_coexisting_manual_entries_and_unreadable_patch() {
|
||||||
|
let url = "http://127.0.0.1:27190/mcp/projects/p1";
|
||||||
|
let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc";
|
||||||
|
let block = render_codex_block(url, helper).unwrap();
|
||||||
|
let duplicated = format!("{block}\n[mcp_servers.lian-jing]\nurl = \"x\"\n");
|
||||||
|
assert!(matches!(
|
||||||
|
codex_static_outcome(Some(&duplicated), url, helper),
|
||||||
|
ClientOutcome::NeedsHuman { .. }
|
||||||
|
));
|
||||||
|
let keys = vec![normalize_dsh_root("E:\\code\\p1")];
|
||||||
|
let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None);
|
||||||
|
let with_manual = format!("- insert:\n - id: mcp-lian-jing\n name: x\n{dsh}");
|
||||||
|
assert_eq!(
|
||||||
|
dsh_static_outcome(&with_manual, &keys, "p1", 27190),
|
||||||
|
ClientOutcome::ManualKept
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
dsh_static_outcome("- [unclosed", &keys, "p1", 27190),
|
||||||
|
ClientOutcome::Unavailable { .. }
|
||||||
|
));
|
||||||
|
let misordered = format!("{DSH_MANAGED_END}\n{DSH_MANAGED_START}\n");
|
||||||
|
assert!(matches!(
|
||||||
|
dsh_static_outcome(&misordered, &keys, "p1", 27190),
|
||||||
|
ClientOutcome::NeedsHuman { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hard_failure_is_only_codex_failed() {
|
||||||
|
let report = |client, outcome| ClientReport::new(client, outcome);
|
||||||
|
let failed = || ClientOutcome::Failed {
|
||||||
|
reason: "boom".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
hard_failure(&[report(ClientId::Codex, failed())]),
|
||||||
|
Some("boom".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hard_failure(&[
|
||||||
|
report(ClientId::Claude, failed()),
|
||||||
|
report(ClientId::Dsh, failed()),
|
||||||
|
report(ClientId::Codex, ClientOutcome::ManualKept),
|
||||||
|
]),
|
||||||
|
None,
|
||||||
|
"Claude 与 DSH 一直是 best-effort,不算整体失败"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1412,7 +1412,7 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
|||||||
let runtime = crate::commands::settings::server_observed_runtime_status(pid, &root);
|
let runtime = crate::commands::settings::server_observed_runtime_status(pid, &root);
|
||||||
let mut report =
|
let mut report =
|
||||||
crate::commands::onboarding::summarize_onboarding(pid, checks, runtime);
|
crate::commands::onboarding::summarize_onboarding(pid, checks, runtime);
|
||||||
report.clients = crate::mcp_inject::client_static_status(pid, &root);
|
report.clients = crate::mcp_inject::client_static_status(pid);
|
||||||
serde_json::to_string_pretty(&report)
|
serde_json::to_string_pretty(&report)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@ -2233,6 +2233,47 @@ pub fn scan_unregistered_projects() -> Result<String, String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inject_report_lists_every_client_and_errors_only_on_codex_failure() {
|
||||||
|
use crate::onboard::clients::{ClientId, ClientOutcome, ClientReport};
|
||||||
|
let ok = [
|
||||||
|
ClientReport::new(ClientId::Claude, ClientOutcome::Converged),
|
||||||
|
ClientReport::new(ClientId::Codex, ClientOutcome::Applied),
|
||||||
|
ClientReport::new(ClientId::Dsh, ClientOutcome::NotInstalled),
|
||||||
|
];
|
||||||
|
let text = render_inject_report("p1", &ok).unwrap();
|
||||||
|
assert!(text.starts_with("MCP 注入完成"));
|
||||||
|
assert!(
|
||||||
|
text.contains("Claude Code:已一致")
|
||||||
|
&& text.contains("Codex:已写入")
|
||||||
|
&& text.contains("DSH:未安装")
|
||||||
|
);
|
||||||
|
// Claude 未就绪:如实列出、标部分完成,但不是工具错误(Claude 一直是 best-effort)
|
||||||
|
let claude_unavailable = [
|
||||||
|
ClientReport::new(
|
||||||
|
ClientId::Claude,
|
||||||
|
ClientOutcome::Unavailable {
|
||||||
|
reason: "claude CLI 未找到".into(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ClientReport::new(ClientId::Codex, ClientOutcome::Converged),
|
||||||
|
];
|
||||||
|
let text = render_inject_report("p1", &claude_unavailable).unwrap();
|
||||||
|
assert!(
|
||||||
|
text.starts_with("MCP 注入部分完成") && text.contains("无法确认:claude CLI 未找到")
|
||||||
|
);
|
||||||
|
// Codex 托管块写入失败:工具报错(沿用旧语义)
|
||||||
|
let codex_failed = [ClientReport::new(
|
||||||
|
ClientId::Codex,
|
||||||
|
ClientOutcome::Failed {
|
||||||
|
reason: "块不完整".into(),
|
||||||
|
},
|
||||||
|
)];
|
||||||
|
assert!(render_inject_report("p1", &codex_failed)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("失败:块不完整"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_all_projects_judges_injection_by_token_not_compat_file() {
|
fn list_all_projects_judges_injection_by_token_not_compat_file() {
|
||||||
let conn = crate::db::conn_with_schema();
|
let conn = crate::db::conn_with_schema();
|
||||||
|
|||||||
@ -17,19 +17,17 @@ pub enum ClientId {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SkipReason {
|
pub enum NotApplicableReason {
|
||||||
/// 控制面项目(mcp_scope=global):不写项目级条目,回退全局地址
|
/// 控制面项目(mcp_scope=global):不写项目级条目,回退全局地址
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
/// WSL 路径项目(2026-07-04 裁决放弃):Claude local 与 DSH 不支持
|
/// WSL 路径项目(2026-07-04 裁决放弃):Claude local 与 DSH 不支持
|
||||||
Wsl,
|
Wsl,
|
||||||
/// 批量重注入:逐项目跳过,由调用方在末尾统一刷新一次
|
|
||||||
Deferred,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 单个客户端在一次接入或静态检查中的结果。
|
/// 单个客户端在一次接入或静态检查中的结果(命名与设计文档 §4 的步骤状态一致)。
|
||||||
/// - 成功态:converged(已一致、未写)、applied(新写入)、refreshed(替换了旧托管条目)、removed(按期望移除);
|
/// - 成功态:converged(已一致、未写)、applied(新写入)、refreshed(替换了旧托管条目)、removed(按期望移除);
|
||||||
/// - 按边界不改动:manual_kept(手写同名配置)、other_project(共享项目键属于他项目);
|
/// - 按边界不改动:manual_kept(手写同名配置)、other_project(共享项目键属于他项目);
|
||||||
/// - 不计失败:not_installed、skipped;
|
/// - 不计失败:not_installed、not_applicable(控制面 / WSL)、deferred(批量操作末尾统一刷新);
|
||||||
/// - 未就绪:missing(静态检查:已安装但本项目未配置或已过期)、unavailable(无法确认现状)、
|
/// - 未就绪:missing(静态检查:已安装但本项目未配置或已过期)、unavailable(无法确认现状)、
|
||||||
/// needs_human(需人工清理)、failed(尝试写入但失败)。
|
/// needs_human(需人工清理)、failed(尝试写入但失败)。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@ -42,7 +40,8 @@ pub enum ClientOutcome {
|
|||||||
ManualKept,
|
ManualKept,
|
||||||
OtherProject { owner: String },
|
OtherProject { owner: String },
|
||||||
NotInstalled,
|
NotInstalled,
|
||||||
Skipped { reason: SkipReason },
|
NotApplicable { reason: NotApplicableReason },
|
||||||
|
Deferred,
|
||||||
Missing,
|
Missing,
|
||||||
Unavailable { reason: String },
|
Unavailable { reason: String },
|
||||||
NeedsHuman { reason: String },
|
NeedsHuman { reason: String },
|
||||||
@ -60,7 +59,8 @@ impl ClientOutcome {
|
|||||||
| Self::Removed
|
| Self::Removed
|
||||||
| Self::ManualKept
|
| Self::ManualKept
|
||||||
| Self::NotInstalled
|
| Self::NotInstalled
|
||||||
| Self::Skipped { .. }
|
| Self::NotApplicable { .. }
|
||||||
|
| Self::Deferred
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,11 +76,11 @@ impl ClientOutcome {
|
|||||||
format!("生效条目属于项目 {owner}(共享项目键),炼境未改动")
|
format!("生效条目属于项目 {owner}(共享项目键),炼境未改动")
|
||||||
}
|
}
|
||||||
Self::NotInstalled => "未安装".into(),
|
Self::NotInstalled => "未安装".into(),
|
||||||
Self::Skipped { reason } => match reason {
|
Self::NotApplicable { reason } => match reason {
|
||||||
SkipReason::ControlPlane => "不适用(控制面项目走全局地址)".into(),
|
NotApplicableReason::ControlPlane => "不适用(控制面项目走全局地址)".into(),
|
||||||
SkipReason::Wsl => "不支持(WSL 路径项目)".into(),
|
NotApplicableReason::Wsl => "不支持(WSL 路径项目)".into(),
|
||||||
SkipReason::Deferred => "本次跳过(批量操作末尾统一刷新)".into(),
|
|
||||||
},
|
},
|
||||||
|
Self::Deferred => "本次跳过(批量操作末尾统一刷新)".into(),
|
||||||
Self::Missing => "未配置或已过期".into(),
|
Self::Missing => "未配置或已过期".into(),
|
||||||
Self::Unavailable { reason } => format!("无法确认:{reason}"),
|
Self::Unavailable { reason } => format!("无法确认:{reason}"),
|
||||||
Self::NeedsHuman { reason } => format!("需人工处理:{reason}"),
|
Self::NeedsHuman { reason } => format!("需人工处理:{reason}"),
|
||||||
@ -132,6 +132,8 @@ pub trait ClientAdapter: Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 三端适配器注册表;顺序即报告顺序。
|
/// 三端适配器注册表;顺序即报告顺序。
|
||||||
|
/// 绞杀者过渡:三个适配器暂实现在 mcp_inject(依赖其私有辅助函数,后者仍直接取连接池),本模块因此
|
||||||
|
/// 传递依赖投影层——适应度测试只拦本模块源码。P8d 起把适配器逐个迁入 onboard,届时改为纯注入。
|
||||||
pub fn registry() -> [&'static dyn ClientAdapter; 3] {
|
pub fn registry() -> [&'static dyn ClientAdapter; 3] {
|
||||||
[
|
[
|
||||||
&crate::mcp_inject::CLAUDE_ADAPTER,
|
&crate::mcp_inject::CLAUDE_ADAPTER,
|
||||||
@ -165,9 +167,10 @@ mod tests {
|
|||||||
ClientOutcome::Removed,
|
ClientOutcome::Removed,
|
||||||
ClientOutcome::ManualKept,
|
ClientOutcome::ManualKept,
|
||||||
ClientOutcome::NotInstalled,
|
ClientOutcome::NotInstalled,
|
||||||
ClientOutcome::Skipped {
|
ClientOutcome::NotApplicable {
|
||||||
reason: SkipReason::ControlPlane,
|
reason: NotApplicableReason::ControlPlane,
|
||||||
},
|
},
|
||||||
|
ClientOutcome::Deferred,
|
||||||
];
|
];
|
||||||
assert!(settled.iter().all(ClientOutcome::is_settled));
|
assert!(settled.iter().all(ClientOutcome::is_settled));
|
||||||
let unsettled = [
|
let unsettled = [
|
||||||
@ -184,13 +187,18 @@ mod tests {
|
|||||||
fn report_serializes_flat_with_snake_case_state() {
|
fn report_serializes_flat_with_snake_case_state() {
|
||||||
let report = ClientReport::new(
|
let report = ClientReport::new(
|
||||||
ClientId::Dsh,
|
ClientId::Dsh,
|
||||||
ClientOutcome::Skipped {
|
ClientOutcome::NotApplicable {
|
||||||
reason: SkipReason::ControlPlane,
|
reason: NotApplicableReason::ControlPlane,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(&report).unwrap(),
|
serde_json::to_value(&report).unwrap(),
|
||||||
serde_json::json!({"client": "dsh", "state": "skipped", "reason": "control_plane"})
|
serde_json::json!({"client": "dsh", "state": "not_applicable", "reason": "control_plane"})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(ClientReport::new(ClientId::Dsh, ClientOutcome::Deferred))
|
||||||
|
.unwrap(),
|
||||||
|
serde_json::json!({"client": "dsh", "state": "deferred"})
|
||||||
);
|
);
|
||||||
let failed = ClientReport::new(
|
let failed = ClientReport::new(
|
||||||
ClientId::Claude,
|
ClientId::Claude,
|
||||||
|
|||||||
@ -17,13 +17,22 @@ mod fitness_tests {
|
|||||||
["db::", "pool("].concat(),
|
["db::", "pool("].concat(),
|
||||||
["#[tauri", "::command"].concat(),
|
["#[tauri", "::command"].concat(),
|
||||||
];
|
];
|
||||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/onboard");
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/onboard");
|
||||||
let mut checked = 0;
|
// 递归:P4c/P4d 计划的 onboard/probe/*.rs 同受约束
|
||||||
|
let mut pending = vec![root];
|
||||||
|
let mut files = Vec::new();
|
||||||
|
while let Some(dir) = pending.pop() {
|
||||||
for entry in std::fs::read_dir(&dir).expect("读取 src/onboard") {
|
for entry in std::fs::read_dir(&dir).expect("读取 src/onboard") {
|
||||||
let path = entry.expect("目录项").path();
|
let path = entry.expect("目录项").path();
|
||||||
if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
|
if path.is_dir() {
|
||||||
continue;
|
pending.push(path);
|
||||||
|
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
|
||||||
|
files.push(path);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut checked = 0;
|
||||||
|
for path in files {
|
||||||
let source = std::fs::read_to_string(&path).expect("读取源码");
|
let source = std::fs::read_to_string(&path).expect("读取源码");
|
||||||
for pattern in &forbidden {
|
for pattern in &forbidden {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user