From ab590ec6bbd07d9e740a4342348a2772d59ab5ab Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 14:09:47 +0800 Subject: [PATCH 01/24] =?UTF-8?q?feat(slot):=20=E6=96=B0=E5=A2=9E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=A7=BD=E6=A0=B8=E5=BF=83=E9=80=BB=E8=BE=91=EF=BC=88?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96/=E6=A7=BD=E5=90=8D/manifest?= =?UTF-8?q?=EF=BC=89=E4=B8=8E=E6=B5=8B=E8=AF=95=E9=A1=B9=E7=9B=AE=20(T1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VersionSlot.Normalize:refs 前缀剥离、v 前缀与四段截三段归一、分支名小写 - SlotManifest record + JSON 读写;10.8.1/10.8.1.0/v10.8.1 等价命中 v10.8.1 - 新增 xunit 测试项目(用户授权 dotnet new xunit);slnx 注册测试项目 - 主项目 csproj 加 Compile Remove 排除嵌套测试目录(否则测试源被 glob 进主项目致编译失败) - brief 两处 SDK 层最小修正(断言与行为语义不变):TrimStart(string) 无该重载,改用 StripPrefix 前缀剥离;LoadManifest 先 ReadAllText 再反序列化(不能把路径传给 Deserialize(string)) - 收编既往中止遗留的脚手架(测试 csproj/VersionSlotTests/slnx 注册,与 brief 逐字一致) - 验证:dotnet test BootstrapBlazor.McpServer.Test/...(全量及 --filter VersionSlot)14/14 全绿 Co-Authored-By: ZCode (GLM-5.3) --- .../BootstrapBlazor.McpServer.Test.csproj | 25 ++++++++ .../VersionSlotTests.cs | 41 +++++++++++++ BootstrapBlazor.McpServer.csproj | 5 ++ BootstrapBlazor.McpServer.slnx | 1 + Services/VersionSlot.cs | 59 +++++++++++++++++++ 5 files changed, 131 insertions(+) create mode 100644 BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj create mode 100644 BootstrapBlazor.McpServer.Test/VersionSlotTests.cs create mode 100644 Services/VersionSlot.cs diff --git a/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj b/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj new file mode 100644 index 0000000..a2aff13 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BootstrapBlazor.McpServer.Test/VersionSlotTests.cs b/BootstrapBlazor.McpServer.Test/VersionSlotTests.cs new file mode 100644 index 0000000..0ec29bb --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/VersionSlotTests.cs @@ -0,0 +1,41 @@ +// BootstrapBlazor.McpServer.Test/VersionSlotTests.cs +using BootstrapBlazor.McpServer.Services; + +public class VersionSlotTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("10.8.1", "v10.8.1")] + [InlineData("10.8.1.0", "v10.8.1")] // 四段截三段 + [InlineData("v10.8.1", "v10.8.1")] + [InlineData("V10.8.1", "v10.8.1")] + [InlineData(" v10.8.1 ", "v10.8.1")] // trim + [InlineData("refs/tags/v10.8.1", "v10.8.1")] + [InlineData("refs/heads/main", "main")] + [InlineData("main", "main")] + [InlineData("Main", "main")] + public void Normalize_Canonicalizes(string? input, string? expected) + => Assert.Equal(expected, VersionSlot.Normalize(input)); + + [Fact] + public void ToGitRef_VersionSlot_GetsTagForm() + { + Assert.Equal("v10.8.1", VersionSlot.ToGitRef("v10.8.1")); + Assert.Equal("main", VersionSlot.ToGitRef("main")); + } + + [Fact] + public void Manifest_RoundTrip() + { + var dir = Path.Combine(Path.GetTempPath(), "mcp-t-" + Guid.NewGuid().ToString("N")); + var m = new SlotManifest("v10.8.1", "abc1234", "10.8.1.0", DateTimeOffset.UtcNow, "ready"); + VersionSlot.SaveManifest(dir, "v10.8.1", m); + var loaded = VersionSlot.LoadManifest(dir, "v10.8.1"); + Assert.NotNull(loaded); + Assert.Equal("v10.8.1", loaded!.Ref); + Assert.Equal("ready", loaded.Status); + Assert.Equal("abc1234", loaded.Commit); + } +} diff --git a/BootstrapBlazor.McpServer.csproj b/BootstrapBlazor.McpServer.csproj index 6153fa0..9372f8a 100644 --- a/BootstrapBlazor.McpServer.csproj +++ b/BootstrapBlazor.McpServer.csproj @@ -7,6 +7,11 @@ enable + + + + + diff --git a/BootstrapBlazor.McpServer.slnx b/BootstrapBlazor.McpServer.slnx index 1bca018..5d82329 100644 --- a/BootstrapBlazor.McpServer.slnx +++ b/BootstrapBlazor.McpServer.slnx @@ -1,3 +1,4 @@ + diff --git a/Services/VersionSlot.cs b/Services/VersionSlot.cs new file mode 100644 index 0000000..123b9b6 --- /dev/null +++ b/Services/VersionSlot.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace BootstrapBlazor.McpServer.Services; + +public sealed record SlotManifest(string Ref, string? Commit, string? BootstrapBlazorVersion, + DateTimeOffset ExtractedAt, string Status, string? LastError = null); + +public static partial class VersionSlot +{ + [GeneratedRegex(@"^\d+(\.\d+){1,3}")] + private static partial Regex VersionRegex(); + + /// 归一化为槽名:null/空白→null;剥离 refs/tags/、refs/heads/ 前缀;trim;小写; + /// 形如 x.y[.z[.r]] 的版本串截为三段并补 v 前缀;其余(分支名)原样小写。 + public static string? Normalize(string? input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + var s = StripPrefix(StripPrefix(input.Trim(), "refs/tags/"), "refs/heads/").ToLowerInvariant(); + var match = VersionRegex().Match(s); + if (!match.Success) return s; // 分支名 + var parts = match.Value.Split('.'); + if (parts.Length > 3) Array.Resize(ref parts, 3); // 四段截三段 + return "v" + string.Join(".", parts); + } + + /// 循环剥离 Ordinal 前缀(BCL 无 string.TrimStart(string) 重载, + /// 链式 TrimStart(string) 会误绑 MemoryExtensions 的字符集重载并返回 span)。 + private static string StripPrefix(string s, string prefix) + { + while (s.StartsWith(prefix, StringComparison.Ordinal)) + { + s = s[prefix.Length..]; + } + return s; + } + + public static string SlotName(string refOrVersion) + => Normalize(refOrVersion) ?? throw new ArgumentException("空 ref", nameof(refOrVersion)); + + /// git 操作用的 ref 形式:版本形槽名即 tag 名(v 三段);分支名原样。 + public static string ToGitRef(string slotName) => slotName; + + public static string ManifestPath(string outputDir, string slotName) + => Path.Combine(outputDir, slotName, "manifest.json"); + + public static SlotManifest? LoadManifest(string outputDir, string slotName) + { + var p = ManifestPath(outputDir, slotName); + // 注意:Deserialize(string) 重载收的是 JSON 文本,须先读文件内容,不能直接传路径 + return File.Exists(p) ? JsonSerializer.Deserialize(File.ReadAllText(p)) : null; + } + + public static void SaveManifest(string outputDir, string slotName, SlotManifest m) + { + Directory.CreateDirectory(Path.Combine(outputDir, slotName)); + File.WriteAllText(ManifestPath(outputDir, slotName), JsonSerializer.Serialize(m, new JsonSerializerOptions { WriteIndented = true })); + } +} From ae88e753c1d3cc15a3de0aa40584aef661184c7b Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 14:23:09 +0800 Subject: [PATCH 02/24] =?UTF-8?q?feat(settings):=20GitSync=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20PinRef=20=E9=85=8D=E7=BD=AE=E8=AF=BB=E5=86=99=20(T2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppSettingsModel 新增 PinRef 属性(缺省空串 = 遗产模式,行为不变) - LoadSettings 的 JsonNode 解析抽为 internal static ParseJson(string),供测试直测模型绑定逻辑(settings 路径绑定 exe 目录、测试无法换路径) - LoadSettings/SaveSettings 补 GitSync.PinRef 读写,data/config.json 持久化 - appsettings.json 示例 GitSync 节同步加 "PinRef": "" - 主 csproj 新增独立 ItemGroup:InternalsVisibleTo BootstrapBlazor.McpServer.Test(internal 成员测试依赖,零包改动) - 验证:dotnet test --filter AppSettingsPinRef 2/2 绿(RED 前态 CS0117 编译失败已确认);全量 16/16 绿;dotnet build BootstrapBlazor.McpServer.csproj 0 错误 Co-Authored-By: ZCode (GLM-5.3) --- .../AppSettingsPinRefTests.cs | 19 +++++++++++++++++++ BootstrapBlazor.McpServer.csproj | 5 +++++ Services/AppSettingsManager.cs | 13 ++++++++++--- appsettings.json | 3 ++- 4 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 BootstrapBlazor.McpServer.Test/AppSettingsPinRefTests.cs diff --git a/BootstrapBlazor.McpServer.Test/AppSettingsPinRefTests.cs b/BootstrapBlazor.McpServer.Test/AppSettingsPinRefTests.cs new file mode 100644 index 0000000..3401990 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/AppSettingsPinRefTests.cs @@ -0,0 +1,19 @@ +using BootstrapBlazor.McpServer.Services; + +public class AppSettingsPinRefTests +{ + [Fact] + public void ParseJson_ReadsPinRef() + { + var json = """{"GitSync":{"RepositoryUrl":"u","CronSchedule":"c","LocalPath":"l","OutputDir":"o","PinRef":"v10.8.1"}}"""; + var m = AppSettingsManager.ParseJson(json); + Assert.Equal("v10.8.1", m.PinRef); + } + + [Fact] + public void ParseJson_MissingPinRef_DefaultsEmpty() + { + var json = """{"GitSync":{"RepositoryUrl":"u"}}"""; + Assert.Equal("", AppSettingsManager.ParseJson(json).PinRef); + } +} diff --git a/BootstrapBlazor.McpServer.csproj b/BootstrapBlazor.McpServer.csproj index 9372f8a..360555e 100644 --- a/BootstrapBlazor.McpServer.csproj +++ b/BootstrapBlazor.McpServer.csproj @@ -12,6 +12,11 @@ + + + + + diff --git a/Services/AppSettingsManager.cs b/Services/AppSettingsManager.cs index 77a4f0c..d873cc5 100644 --- a/Services/AppSettingsManager.cs +++ b/Services/AppSettingsManager.cs @@ -23,10 +23,14 @@ public AppSettingsManager() public AppSettingsModel LoadSettings() { - var model = new AppSettingsModel(); - if (!File.Exists(_settingFilePath)) return model; + if (!File.Exists(_settingFilePath)) return new AppSettingsModel(); + + return ParseJson(File.ReadAllText(_settingFilePath)); + } - var json = File.ReadAllText(_settingFilePath); + internal static AppSettingsModel ParseJson(string json) + { + var model = new AppSettingsModel(); var root = JsonNode.Parse(json); if (root == null) return model; @@ -35,6 +39,7 @@ public AppSettingsModel LoadSettings() if (gitSync?["CronSchedule"] is { } cron) model.CronSchedule = cron.ToString(); if (gitSync?["LocalPath"] is { } localPath) model.LocalPath = localPath.ToString(); if (gitSync?["OutputDir"] is { } outputDir) model.OutputDir = outputDir.ToString(); + if (gitSync?["PinRef"] is { } pin) model.PinRef = pin.ToString(); var ai = root["AI"]; if (ai?["BaseUrl"] is { } baseUrl) model.AiBaseUrl = baseUrl.ToString(); @@ -67,6 +72,7 @@ public void SaveSettings(AppSettingsModel model) root["GitSync"]!["CronSchedule"] = model.CronSchedule; root["GitSync"]!["LocalPath"] = model.LocalPath; root["GitSync"]!["OutputDir"] = model.OutputDir; + root["GitSync"]!["PinRef"] = model.PinRef; if (root["AI"] == null) root["AI"] = new JsonObject(); root["AI"]!["BaseUrl"] = model.AiBaseUrl; @@ -89,6 +95,7 @@ public class AppSettingsModel public string CronSchedule { get; set; } = "0 3 * * *"; public string LocalPath { get; set; } = "/app/data/BootstrapBlazorRepo"; public string OutputDir { get; set; } = "/app/data/OutputRAG"; + public string PinRef { get; set; } = ""; public string AiBaseUrl { get; set; } = "https://api.openai.com/v1"; public string AiApiKey { get; set; } = "YOUR_API_KEY_HERE"; public string AiModel { get; set; } = "gpt-4o"; diff --git a/appsettings.json b/appsettings.json index 2d4ea52..ebf7300 100644 --- a/appsettings.json +++ b/appsettings.json @@ -10,7 +10,8 @@ "RepositoryUrl": "https://gitee.com/LongbowEnterprise/BootstrapBlazor.git", "CronSchedule": "0 3 * * *", "LocalPath": "/app/data/BootstrapBlazorRepo", - "OutputDir": "/app/data/OutputRAG" + "OutputDir": "/app/data/OutputRAG", + "PinRef": "" }, "AI": { "BaseUrl": "https://api.openai.com/v1", From 1c3272fa2705dee1dc99ca30044c6006ea39e56c Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 14:33:46 +0800 Subject: [PATCH 03/24] =?UTF-8?q?feat(infra):=20=E6=96=B0=E5=A2=9E=20GitCl?= =?UTF-8?q?ient/ProcessRunner=20=E5=8F=AF=E6=B3=A8=E5=85=A5=E8=96=84?= =?UTF-8?q?=E5=B0=81=E8=A3=85=20(T3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Services/GitClient.cs:IGitClient 六成员(IsValid/Clone/Fetch/RefExists/Checkout/HeadCommit)+ LibGit2Sharp 真实现;Fetch 用 FetchOptions{TagFetchMode=All, Prune=true} + 空 refspecs 实现 git fetch --tags --prune 等价(brief 的 "--tags" 是 CLI 旗标非合法 refspec);Checkout 用 CheckoutModifiers 枚举(brief 的 Force=true 为笔误,0.31 无该布尔属性) - Services/ProcessRunner.cs:IProcessRunner.Run 同步执行外部进程,stdout/stderr 逐行回调 onOutput,返回退出码;Process 用法沿用 GitSyncInvocable(RedirectOutput+CreateNoWindow) - 测试 GitClientTests:LibGit2Sharp 0.31 bare 仓库 Commit 抛 BareRepositoryException,按 brief 弹性点改为非 bare 源仓库构造,断言项不变(IsValid 假/真、RefExists 存在/不存在 tag、Checkout 成功、HeadCommit 长度 7),并附 Fetch 拉新 tag 验证;全程本地临时仓库离线运行 - 测试脚手架:git 会将 loose object 写为只读文件,清理临时目录前先去只读属性再删(5 连跑 0 残留),best-effort 不影响断言 - 验证:dotnet test 17/17 通过(16 存量 + 1 新增);dotnet build 主项目 Build succeeded(仅存量 NU1903 传递依赖告警,零包改动) - 接口为普通实例方法,未挡 T4 追加 TipOf 的道(YAGNI,T3 不预埋) Co-Authored-By: ZCode (GLM-5.3) --- .../GitClientTests.cs | 57 ++++++++++++++++ Services/GitClient.cs | 65 +++++++++++++++++++ Services/ProcessRunner.cs | 42 ++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 BootstrapBlazor.McpServer.Test/GitClientTests.cs create mode 100644 Services/GitClient.cs create mode 100644 Services/ProcessRunner.cs diff --git a/BootstrapBlazor.McpServer.Test/GitClientTests.cs b/BootstrapBlazor.McpServer.Test/GitClientTests.cs new file mode 100644 index 0000000..7a1f3f3 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/GitClientTests.cs @@ -0,0 +1,57 @@ +// BootstrapBlazor.McpServer.Test/GitClientTests.cs +using BootstrapBlazor.McpServer.Services; +using LibGit2Sharp; + +public class GitClientTests +{ + [Fact] + public void Clone_RefExists_Checkout_WorksOffline() + { + var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N")); + try + { + // LibGit2Sharp 0.31 在 bare 仓库上 Commit 抛 BareRepositoryException(Index 不可用), + // 按 brief 弹性点改为非 bare 源仓库:写文件 + Stage + Commit + 打 tag,断言项不变 + var src = Path.Combine(root, "src"); var work = Path.Combine(root, "work"); + Repository.Init(src); + File.WriteAllText(Path.Combine(src, "a.txt"), "hello"); + using (var repo = new Repository(src)) + { + Commands.Stage(repo, "a.txt"); + repo.Commit("init", new Signature("t", "t", DateTimeOffset.Now), new Signature("t", "t", DateTimeOffset.Now)); + repo.ApplyTag("v10.8.1"); + } + + var git = new GitClient(); + Assert.False(git.IsValid(work)); + git.Clone(src, work); + Assert.True(git.IsValid(work)); + Assert.True(git.RefExists(work, "v10.8.1")); + Assert.False(git.RefExists(work, "v9.9.9")); + git.Checkout(work, "v10.8.1", force: true); + Assert.Equal(7, git.HeadCommit(work).Length); + + // brief 弹性点附带的 Fetch 验证:源仓库追加 v10.8.2 后 Fetch 应拉到新 tag(本地路径 origin,无网络) + using (var repo = new Repository(src)) + { + File.AppendAllText(Path.Combine(src, "a.txt"), " world"); + Commands.Stage(repo, "a.txt"); + repo.Commit("second", new Signature("t", "t", DateTimeOffset.Now), new Signature("t", "t", DateTimeOffset.Now)); + repo.ApplyTag("v10.8.2"); + } + git.Fetch(work); + Assert.True(git.RefExists(work, "v10.8.2")); + } + finally + { + // 测试自有脚手架:git 将 loose object 写为只读文件,须先去掉只读属性才能删除目录(best-effort,断言不受影响) + try + { + foreach (var f in Directory.GetFiles(root, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + Directory.Delete(root, true); + } + catch { /* best-effort */ } + } + } +} diff --git a/Services/GitClient.cs b/Services/GitClient.cs new file mode 100644 index 0000000..36612b7 --- /dev/null +++ b/Services/GitClient.cs @@ -0,0 +1,65 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone + +using LibGit2Sharp; + +namespace BootstrapBlazor.McpServer.Services; + +/// Git 操作薄封装(LibGit2Sharp 实现),供流水线构造函数注入 / 测试 fake 替换。 +public interface IGitClient +{ + bool IsValid(string path); + + void Clone(string sourceUrl, string workDir); + + /// 等价 git fetch --tags --prune。 + void Fetch(string workDir); + + /// tag 或分支存在。 + bool RefExists(string workDir, string refName); + + /// 检出 ref(tag 检出为 detached HEAD 属合法状态)。 + void Checkout(string workDir, string refName, bool force); + + /// HEAD 指向 commit 的短 SHA(7 位)。 + string HeadCommit(string workDir); +} + +public sealed class GitClient : IGitClient +{ + public bool IsValid(string path) => Repository.IsValid(path); + + public void Clone(string sourceUrl, string workDir) => Repository.Clone(sourceUrl, workDir); + + public void Fetch(string workDir) + { + using var repo = new Repository(workDir); + // git fetch --tags --prune 等价:brief 示例里的 "--tags" 是 git CLI 旗标而非合法 refspec, + // 故改用等价选项——空 refspecs 沿用 origin 默认 refspec,TagFetchMode.All 拉全部 tag,Prune 清理远端已删引用 + var options = new FetchOptions { TagFetchMode = TagFetchMode.All, Prune = true }; + Commands.Fetch(repo, "origin", Array.Empty(), options, null); + } + + public bool RefExists(string workDir, string refName) + { + using var repo = new Repository(workDir); + return repo.Tags[refName] != null || repo.Branches[refName] != null; + } + + public void Checkout(string workDir, string refName, bool force) + { + using var repo = new Repository(workDir); + // brief 笔误修正:CheckoutOptions 无 Force 布尔属性,LibGit2Sharp 0.31 API 为 CheckoutModifiers 枚举 + Commands.Checkout(repo, refName, new CheckoutOptions + { + CheckoutModifiers = force ? CheckoutModifiers.Force : CheckoutModifiers.None + }); + } + + public string HeadCommit(string workDir) + { + using var repo = new Repository(workDir); + return repo.Head.Tip.Sha[..7]; + } +} diff --git a/Services/ProcessRunner.cs b/Services/ProcessRunner.cs new file mode 100644 index 0000000..880a1dc --- /dev/null +++ b/Services/ProcessRunner.cs @@ -0,0 +1,42 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone + +using System.Diagnostics; + +namespace BootstrapBlazor.McpServer.Services; + +/// 外部进程执行薄封装,供注入 / 测试 fake 替换(沿用 GitSyncInvocable 的 Process 用法)。 +public interface IProcessRunner +{ + /// 同步执行进程,stdout/stderr 逐行经 onOutput 回调,返回退出码。 + int Run(string fileName, string arguments, string workingDir, Action? onOutput = null); +} + +public sealed class ProcessRunner : IProcessRunner +{ + public int Run(string fileName, string arguments, string workingDir, Action? onOutput = null) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = psi }; + process.OutputDataReceived += (_, e) => { if (!string.IsNullOrEmpty(e.Data)) onOutput?.Invoke(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (!string.IsNullOrEmpty(e.Data)) onOutput?.Invoke(e.Data); }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + + return process.ExitCode; + } +} From 47ede1a9c190fc255c425843d22ce73531d53b3f Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 14:48:58 +0800 Subject: [PATCH 04/24] =?UTF-8?q?test(git):=20GitClientTests=20=E8=A1=A5?= =?UTF-8?q?=20Checkout=20=E8=A1=8C=E4=B8=BA=E7=BA=A7=E6=96=AD=E8=A8=80=20(?= =?UTF-8?q?T3=20=E4=BF=AE=E5=A4=8DR1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 二审 Important:克隆后 HEAD 本就指向 v10.8.1 的 commit,仅检 v10.8.1 时 Checkout 为 no-op/检错 ref/force 映射反转均无法暴露;现于 Fetch 段(源仓库已有 v10.8.2)追加 git.Checkout(work, "v10.8.2", force: true),并断言 HeadCommit 等于源仓库 v10.8.2 tag Target 的短 SHA——覆盖「tag 检出 detached + HEAD 真实移动 + force 路径」 - 变异自证:临时注释该 Checkout 行后测试即红(Expected cb7bc79 / Actual 0b2537a),断言确能抓 no-op - brief 六项断言原样保留;其余 4 条 Minor 按裁定暂缓未动 - 验证:--filter GitClient 1/1 通过;全量 17/17 通过;主项目 Build succeeded;%TEMP% 零残留 Co-Authored-By: ZCode (GLM-5.3) --- BootstrapBlazor.McpServer.Test/GitClientTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/BootstrapBlazor.McpServer.Test/GitClientTests.cs b/BootstrapBlazor.McpServer.Test/GitClientTests.cs index 7a1f3f3..8da67bd 100644 --- a/BootstrapBlazor.McpServer.Test/GitClientTests.cs +++ b/BootstrapBlazor.McpServer.Test/GitClientTests.cs @@ -32,15 +32,22 @@ public void Clone_RefExists_Checkout_WorksOffline() Assert.Equal(7, git.HeadCommit(work).Length); // brief 弹性点附带的 Fetch 验证:源仓库追加 v10.8.2 后 Fetch 应拉到新 tag(本地路径 origin,无网络) + string v2Short; using (var repo = new Repository(src)) { File.AppendAllText(Path.Combine(src, "a.txt"), " world"); Commands.Stage(repo, "a.txt"); repo.Commit("second", new Signature("t", "t", DateTimeOffset.Now), new Signature("t", "t", DateTimeOffset.Now)); repo.ApplyTag("v10.8.2"); + v2Short = repo.Tags["v10.8.2"].Target.Sha[..7]; // lightweight tag 的 Target 即 commit } git.Fetch(work); Assert.True(git.RefExists(work, "v10.8.2")); + + // T3 二审 Important 修复:Checkout 行为级断言——克隆后 HEAD 本就指向 v10.8.1 的 commit, + // 仅检 v10.8.1 无法暴露 no-op 实现;此处检出 v10.8.2 并断言 HEAD 真实移动到其 commit(detached + force 路径) + git.Checkout(work, "v10.8.2", force: true); + Assert.Equal(v2Short, git.HeadCommit(work)); } finally { From 629b1b2fa5898b65a83c369d1b779f2493752ddb Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 15:04:42 +0800 Subject: [PATCH 05/24] =?UTF-8?q?feat(pipeline):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=A7=BD=E6=8F=90=E5=8F=96=E6=B5=81=E6=B0=B4=E7=BA=BF=EF=BC=88?= =?UTF-8?q?fetch/checkout/build/staging=20=E5=8E=9F=E5=AD=90=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2/manifest/=E4=BA=92=E6=96=A5=EF=BC=89+=20GitSync=20?= =?UTF-8?q?=E9=81=97=E4=BA=A7=E5=88=86=E6=94=AF=E5=88=86=E6=B4=BE=20(T4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Services/ExtractionService.cs:RunAsync 互斥内跑完整流水线 (clone-or-fetch → RefExists 二次检查 → Checkout → dotnet build → Extract 到 .staging-{slot} → 同卷原子 Move 为槽目录 → manifest ready); build 失败仅记 failed manifest,槽内旧产物不动 - 跳过语义(nightly 幂等):tag 槽 ready 即跳过(零 git 调用);分支槽 fetch 后经 IGitClient.TipOf(workDir, ref) 取分支/tag tip 与 manifest commit 比对,未变则跳过(注记①:checkout 前 HEAD 不代表目标 ref) - StartExtraction fire-and-forget(对外 MCP/REST 唯一入口,不挂调用方); PinAndExtract 保存 PinRef 后启动提取(T6/T7 共享);DeleteSlot 钉住槽 拒删(T8 用);GetAvailableVersions 遍历含 manifest 的槽目录出摘要 - IGitClient/GitClient 追加 TipOf:Branches/Tags(附注 tag 经 PeeledTarget 剥壳)取 7 位短 SHA,缺失返回 null,与 HeadCommit/manifest.Commit 同格式 - GitSyncInvocable:Invoke 开头加 PinRef 非空分派(早返回等价 if/else), 遗产 clone-or-pull→build→Extract 分支体逐字未动(spec §6.2 零回归); 构造追加注入 ExtractionService;Coravel 注册与 ForceSync 调用点不动 - 台账折入项:manifest 损坏按缺失处理(TryLoadManifest try/catch);槽名 path-safety 守卫(纯点段/含分隔符直接返回错误不抛异常);状态字面量 常量化(internal SlotStatus),新增生产代码无裸状态字面量 - 测试(ExtractionServiceTests,全 fake + 真实临时目录,离线)六剧本: A tag-ready 跳过零 git 调用;B 分支 fetch 后 TipOf 同 commit 跳过; C force 全流程 + staging 原子替换 + manifest ready;D build 失败保旧 产物记 failed;E 互斥第二次立即 in progress;F ref 不存在先 Fetch 后 报 not found;另含守卫/损坏 manifest/列表/删槽/PinAndExtract/GitSync 分派共 13 个用例 - 验证:dotnet test 30/30 passed;dotnet build 0 error(NU1903 为预存 传递依赖告警,与本次无关) Co-Authored-By: ZCode (GLM-5.3) --- .../ExtractionServiceTests.cs | 208 +++++++++++++++++ Services/ExtractionService.cs | 209 ++++++++++++++++++ Services/GitClient.cs | 13 ++ Services/GitSyncInvocable.cs | 16 +- 4 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs create mode 100644 Services/ExtractionService.cs diff --git a/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs new file mode 100644 index 0000000..4a95c33 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs @@ -0,0 +1,208 @@ +// BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs +// 全部用 fake(FakeGit/FakeRunner)+ 真实临时目录,离线,不碰真 git/build/extractor。 +using BootstrapBlazor.McpServer.Services; +using Microsoft.Extensions.Logging.Abstractions; + +public class ExtractionServiceTests +{ + private static (ExtractionService svc, FakeGit git, FakeRunner runner) Make(AppSettingsModel settings) + { + var git = new FakeGit(); var runner = new FakeRunner(); var output = Path.Combine(Path.GetTempPath(), "ext-t-" + Guid.NewGuid().ToString("N")); + settings.OutputDir = output; + var svc = new ExtractionService(git, runner, (b, o) => Directory.CreateDirectory(Path.Combine(o, "API")), + () => settings, NullLogger.Instance); + return (svc, git, runner); + } + + [Fact] // 剧本A:tag 且槽 ready 且非 force → 跳过,零 git 调用 + public async Task TagSlotReady_SkipsNightly() + { + var s = new AppSettingsModel(); var (svc, git, _) = Make(s); + Directory.CreateDirectory(Path.Combine(s.OutputDir, "v10.8.1", "API")); + VersionSlot.SaveManifest(s.OutputDir, "v10.8.1", new("v10.8.1", "abc1234", "10.8.1.0", DateTimeOffset.UtcNow, "ready")); + var r = await svc.RunAsync("v10.8.1", force: false); + Assert.True(r.Skipped); Assert.Equal(0, git.Calls.Count); // brief 笔误修正:Calls 为调用名列表,计数用 .Count + } + + [Fact] // 剧本B:branch 且 commit 未变 → 跳过(注记①:fetch 后经 TipOf 与 manifest commit 比对) + public async Task BranchSameCommit_Skips_AfterFetchCompares() + { + var s = new AppSettingsModel(); var (svc, git, _) = Make(s); + git.HeadCommitResult = "abc1234"; + git.TipOfResult = "abc1234"; // 注记①:跳过比对走 TipOf,fake 提供 TipOfResult + Directory.CreateDirectory(Path.Combine(s.OutputDir, "main", "API")); + VersionSlot.SaveManifest(s.OutputDir, "main", new("main", "abc1234", null, DateTimeOffset.UtcNow, "ready")); + var r = await svc.RunAsync("main", force: false); + Assert.True(r.Skipped); Assert.Contains("Fetch", string.Join(",", git.Calls)); + } + + [Fact] // 剧本C:force → 全流程 + staging 原子替换 + manifest ready + public async Task Force_RunsPipeline_AndWritesReadyManifest() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + git.HeadCommitResult = "def5678"; runner.ExitCode = 0; + var r = await svc.RunAsync("10.8.1", force: true); + Assert.False(r.Skipped); Assert.Equal("v10.8.1", r.SlotName); + Assert.True(File.Exists(VersionSlot.ManifestPath(s.OutputDir, "v10.8.1"))); + Assert.Equal("ready", VersionSlot.LoadManifest(s.OutputDir, "v10.8.1")!.Status); + Assert.True(Directory.Exists(Path.Combine(s.OutputDir, "v10.8.1", "API"))); // staging 已替换为槽 + Assert.False(Directory.Exists(Path.Combine(s.OutputDir, ".staging-v10.8.1"))); + } + + [Fact] // 剧本D:build 失败 → 槽产物保留旧 manifest 不被 ready 覆盖,记 failed + public async Task BuildFailure_KeepsOldSlot_MarksFailed() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + runner.ExitCode = 1; git.HeadCommitResult = "aaa0000"; + var r = await svc.RunAsync("main", force: true); + Assert.Contains("failed", r.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("failed", VersionSlot.LoadManifest(s.OutputDir, "main")!.Status); + } + + [Fact] // 剧本E:互斥——第二个并发请求立即返回"已在进行" + public async Task Concurrent_SecondCallReturnsBusy() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + runner.Delay = TimeSpan.FromSeconds(2); git.HeadCommitResult = "x"; + var first = svc.RunAsync("main", force: true); + var second = await svc.RunAsync("v10.8.1", force: true); + Assert.Contains("in progress", second.Message, StringComparison.OrdinalIgnoreCase); + await first; + } + + [Fact] // 剧本F:ref 本地不存在 → 先 Fetch 再检查,fetch 后仍不存在 → 错误信息含 "not found" + public async Task UnknownRef_TriggersFetch_ThenCheckoutOrError() + { + var s = new AppSettingsModel(); var (svc, git, _) = Make(s); + git.RefExistsResult = false; // fetch 前后都不存在 + var r = await svc.RunAsync("v9.9.9", force: true); + Assert.False(r.Started); + Assert.False(r.Skipped); + Assert.Contains("Fetch", string.Join(",", git.Calls)); // fetch 被调用 + Assert.Contains("not found", r.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] // 台账折入项2:path-safety 守卫——拒绝路径穿越形槽名,返回错误而不抛异常 + public async Task UnsafeSlotName_ReturnsErrorWithoutThrowing() + { + var s = new AppSettingsModel(); var (svc, git, _) = Make(s); + var r = await svc.RunAsync("../evil", force: true); + Assert.False(r.Started); Assert.False(r.Skipped); + Assert.Contains("invalid", r.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, git.Calls.Count); + } + + [Fact] // 台账折入项1:manifest 损坏按缺失处理 → 不跳过,走重新提取路径 + public async Task CorruptManifest_TreatedAsMissing_RunsPipeline() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + git.HeadCommitResult = "def5678"; runner.ExitCode = 0; + Directory.CreateDirectory(Path.Combine(s.OutputDir, "v10.8.1", "API")); + File.WriteAllText(VersionSlot.ManifestPath(s.OutputDir, "v10.8.1"), "{ not valid json !!"); + var r = await svc.RunAsync("v10.8.1", force: false); + Assert.False(r.Skipped); Assert.True(r.Started); + Assert.Equal("ready", VersionSlot.LoadManifest(s.OutputDir, "v10.8.1")!.Status); + } + + [Fact] + public void GetAvailableVersions_ListsManifestSlots_MarksPinned() + { + var s = new AppSettingsModel(); var (svc, _, _) = Make(s); + s.PinRef = "v10.8.1"; + VersionSlot.SaveManifest(s.OutputDir, "v10.8.1", new("v10.8.1", "abc1234", "10.8.1.0", DateTimeOffset.UtcNow, "ready")); + VersionSlot.SaveManifest(s.OutputDir, "dev", new("dev", "def5678", null, DateTimeOffset.UtcNow, "extracting")); + Directory.CreateDirectory(Path.Combine(s.OutputDir, ".staging-junk")); // 无 manifest → 排除 + Directory.CreateDirectory(Path.Combine(s.OutputDir, "plain")); // 无 manifest → 排除 + + var list = svc.GetAvailableVersions(); + Assert.Equal(2, list.Count); + var pinned = Assert.Single(list, v => v.IsPinned); + Assert.Equal("v10.8.1", pinned.SlotName); + Assert.Equal("ready", pinned.Status); + var dev = Assert.Single(list, v => v.SlotName == "dev"); + Assert.Equal("extracting", dev.Status); + } + + [Fact] + public void DeleteSlot_RefusesPinnedSlot() + { + var s = new AppSettingsModel(); var (svc, _, _) = Make(s); + s.PinRef = "v10.8.1"; + VersionSlot.SaveManifest(s.OutputDir, "v10.8.1", new("v10.8.1", "abc1234", null, DateTimeOffset.UtcNow, "ready")); + var msg = svc.DeleteSlot("v10.8.1"); + Assert.Contains("pinned", msg, StringComparison.OrdinalIgnoreCase); + Assert.True(Directory.Exists(Path.Combine(s.OutputDir, "v10.8.1"))); // 槽未被删除 + } + + [Fact] + public void DeleteSlot_RemovesUnpinnedSlot() + { + var s = new AppSettingsModel(); var (svc, _, _) = Make(s); + VersionSlot.SaveManifest(s.OutputDir, "dev", new("dev", "abc1234", null, DateTimeOffset.UtcNow, "ready")); + svc.DeleteSlot("dev"); + Assert.False(Directory.Exists(Path.Combine(s.OutputDir, "dev"))); + } + + [Fact] + public void PinAndExtract_SavesPinRef_AndStartsExtraction() + { + var s = new AppSettingsModel(); var (svc, _, _) = Make(s); + var cfg = new AppSettingsManager(); + var msg = svc.PinAndExtract(cfg, "10.8.1"); + Assert.Contains("Extraction started", msg, StringComparison.OrdinalIgnoreCase); + Assert.Equal("v10.8.1", cfg.LoadSettings().PinRef); + } + + [Fact] // GitSyncInvocable 分派:PinRef 非空 → 走槽流水线,不进遗产 clone-or-pull 分支 + public async Task GitSync_WithPinRef_DispatchesToPipeline_SkipsLegacy() + { + var s = new AppSettingsModel(); var (svc, _, _) = Make(s); + s.PinRef = "v10.8.1"; + s.LocalPath = Path.Combine(Path.GetTempPath(), "ext-repo-" + Guid.NewGuid().ToString("N")); // 不存在 + var cfg = new AppSettingsManager(); + cfg.SaveSettings(s); + var invocable = new GitSyncInvocable(NullLogger.Instance, + new DocsExtractorService(NullLogger.Instance), cfg, svc); + + await invocable.Invoke(); + + Assert.False(Directory.Exists(s.LocalPath)); // 遗产分支会 Directory.CreateDirectory → 未进遗产 + // 分派确已发生:轮询等待后台流水线写出 manifest(fire-and-forget) + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (!File.Exists(VersionSlot.ManifestPath(s.OutputDir, "v10.8.1")) && DateTime.UtcNow < deadline) + await Task.Delay(50); + Assert.True(File.Exists(VersionSlot.ManifestPath(s.OutputDir, "v10.8.1"))); + } + + /// 记录全部调用名;可用 *Result 属性控制返回值。 + private sealed class FakeGit : IGitClient + { + public List Calls { get; } = new(); + public bool IsValidResult { get; set; } = true; + public bool RefExistsResult { get; set; } = true; + public string HeadCommitResult { get; set; } = "0000000"; + public string? TipOfResult { get; set; } + + public bool IsValid(string path) { Calls.Add("IsValid"); return IsValidResult; } + public void Clone(string sourceUrl, string workDir) { Calls.Add("Clone"); } + public void Fetch(string workDir) { Calls.Add("Fetch"); } + public bool RefExists(string workDir, string refName) { Calls.Add("RefExists"); return RefExistsResult; } + public void Checkout(string workDir, string refName, bool force) { Calls.Add("Checkout"); } + public string HeadCommit(string workDir) { Calls.Add("HeadCommit"); return HeadCommitResult; } + public string? TipOf(string workDir, string refName) { Calls.Add("TipOf"); return TipOfResult; } + } + + private sealed class FakeRunner : IProcessRunner + { + public int ExitCode { get; set; } = 0; + public TimeSpan Delay { get; set; } = TimeSpan.Zero; + public List Calls { get; } = new(); + + public int Run(string fileName, string arguments, string workingDir, Action? onOutput = null) + { + Calls.Add($"{fileName} {arguments}"); + if (Delay > TimeSpan.Zero) Thread.Sleep(Delay); + return ExitCode; + } + } +} diff --git a/Services/ExtractionService.cs b/Services/ExtractionService.cs new file mode 100644 index 0000000..ee5cffd --- /dev/null +++ b/Services/ExtractionService.cs @@ -0,0 +1,209 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace BootstrapBlazor.McpServer.Services; + +/// 槽状态字面量(台账折入项:新增代码不得出现裸状态字面量)。 +internal static class SlotStatus +{ + public const string Extracting = "extracting"; + public const string Ready = "ready"; + public const string Failed = "failed"; +} + +public sealed record ExtractionResult(bool Started, bool Skipped, string SlotName, string Message); + +public sealed record SlotSummary(string SlotName, string Ref, string? Commit, string? BootstrapBlazorVersion, + DateTimeOffset ExtractedAt, string Status, bool IsPinned); + +/// 版本槽提取流水线(fetch/checkout/build/staging 原子替换/manifest/互斥)。单例,DI 注册于 T7。 +public sealed class ExtractionService +{ + private const string BuildArguments = "build src/BootstrapBlazor/BootstrapBlazor.csproj -c Release -f net10.0 /p:LangVersion=preview /p:RunTargetFramework=bypass"; + + private readonly IGitClient _git; + private readonly IProcessRunner _runner; + private readonly Action _extract; + private readonly Func _settings; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + + public ExtractionService(IGitClient git, IProcessRunner runner, Action extract, + Func settings, ILogger logger) + { + _git = git; + _runner = runner; + _extract = extract; + _settings = settings; + _logger = logger; + } + + /// 互斥内跑完整提取流水线;槽 ready 且内容未变时跳过(nightly 幂等)。 + public async Task RunAsync(string refOrVersion, bool force) + { + var slot = VersionSlot.SlotName(refOrVersion); + // 台账折入项:槽名 path-safety 守卫——路径穿越/分隔符形槽名直接返回错误,不抛异常、不碰文件系统 + if (IsUnsafeSlotName(slot)) + return new ExtractionResult(false, false, slot, $"Invalid slot name '{slot}'."); + + if (!await _lock.WaitAsync(0)) + return new ExtractionResult(false, false, slot, "Extraction already in progress."); + + try + { + // 注记③:流水线主体放 Task.Run——互斥持有期间不阻塞调用方线程,并发测试(剧本E)真实成立 + return await Task.Run(() => RunPipeline(slot, force)); + } + finally + { + _lock.Release(); + } + } + + private ExtractionResult RunPipeline(string slot, bool force) + { + var st = _settings(); + var gitRef = VersionSlot.ToGitRef(slot); + var isVersionLike = slot.StartsWith('v') && slot.Length > 1 && char.IsDigit(slot[1]); + var existing = TryLoadManifest(st.OutputDir, slot); + + if (!force && existing is { Status: SlotStatus.Ready } && Directory.Exists(Path.Combine(st.OutputDir, slot, "API"))) + { + if (isVersionLike) + { + return new ExtractionResult(false, true, slot, $"Slot '{slot}' ready (tag immutable)."); + } + // 分支:fetch 后取远端分支 tip 与 manifest commit 比对(注记①:checkout 前的 HEAD 不代表目标 ref) + _git.Fetch(st.LocalPath); + var tip = _git.TipOf(st.LocalPath, gitRef); + if (tip is not null && tip == existing.Commit) + { + return new ExtractionResult(false, true, slot, $"Slot '{slot}' up to date."); + } + } + + // 走到这里 = 需要真正提取 + if (!_git.IsValid(st.LocalPath)) + { + _git.Clone(st.RepositoryUrl, st.LocalPath); + } + if (!_git.RefExists(st.LocalPath, gitRef)) + { + _git.Fetch(st.LocalPath); + if (!_git.RefExists(st.LocalPath, gitRef)) + { + return new ExtractionResult(false, false, slot, $"Ref '{gitRef}' not found after fetch."); + } + } + _git.Checkout(st.LocalPath, gitRef, force: true); + var commit = _git.HeadCommit(st.LocalPath); // Clone 之后必有 HEAD,unborn 不可达 + var staging = Path.Combine(st.OutputDir, $".staging-{slot}"); + if (Directory.Exists(staging)) Directory.Delete(staging, true); + VersionSlot.SaveManifest(st.OutputDir, slot, + (existing ?? new SlotManifest(gitRef, commit, null, DateTimeOffset.UtcNow, SlotStatus.Extracting)) + with { Status = SlotStatus.Extracting, Commit = commit }); + var exit = _runner.Run("dotnet", BuildArguments, st.LocalPath); + if (exit != 0) + { + // 失败:槽目录旧产物不动(仅 manifest 改记 failed),staging 留待下轮清理 + VersionSlot.SaveManifest(st.OutputDir, slot, + new SlotManifest(gitRef, commit, null, DateTimeOffset.UtcNow, SlotStatus.Failed, $"dotnet build exit {exit}")); + return new ExtractionResult(false, false, slot, $"Extraction failed: dotnet build exit {exit}. Slot '{slot}' keeps previous artifacts."); + } + _extract(st.LocalPath, staging); + var final = Path.Combine(st.OutputDir, slot); + if (Directory.Exists(final)) Directory.Delete(final, true); + Directory.Move(staging, final); // staging → 槽:同卷原子替换 + VersionSlot.SaveManifest(st.OutputDir, slot, + new SlotManifest(gitRef, commit, ReadBbVersion(st.LocalPath), DateTimeOffset.UtcNow, SlotStatus.Ready)); + _logger.LogInformation("Slot {Slot} extracted at commit {Commit}.", slot, commit); + return new ExtractionResult(true, false, slot, $"Slot '{slot}' extracted (commit {commit})."); + } + + /// 【fire-and-forget】后台启动提取,立即返回提示串。对外 MCP/REST 一律经此进入,绝不 await RunAsync 挂住调用方。 + public string StartExtraction(string slot, bool force, string extraNote = "") + { + _ = Task.Run(() => RunAsync(slot, force)); + var message = $"Extraction started for slot '{slot}'. Poll GetAvailableVersions for progress."; + return string.IsNullOrWhiteSpace(extraNote) ? message : $"{extraNote} {message}"; + } + + /// 钉住并提取:保存 PinRef=槽名后启动提取(T6/T7 共享)。 + public string PinAndExtract(AppSettingsManager cfg, string refOrVersion) + { + var slot = VersionSlot.SlotName(refOrVersion); + var st = _settings(); + st.PinRef = slot; + cfg.SaveSettings(st); + return StartExtraction(slot, force: false); + } + + /// 删除槽目录;钉住槽拒绝删除(T8 用)。 + public string DeleteSlot(string slotName) + { + var slot = VersionSlot.SlotName(slotName); + if (IsUnsafeSlotName(slot)) return $"Invalid slot name '{slot}'."; + var st = _settings(); + if (slot == VersionSlot.Normalize(st.PinRef)) + return $"Slot '{slot}' is pinned and cannot be deleted. Unset PinRef first."; + var dir = Path.Combine(st.OutputDir, slot); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + return $"Slot '{slot}' deleted."; + } + + /// 列出 OutputDir 下所有含 manifest.json 的一级目录为槽摘要。 + public List GetAvailableVersions() + { + var st = _settings(); + var pinned = VersionSlot.Normalize(st.PinRef); + var list = new List(); + if (!Directory.Exists(st.OutputDir)) return list; + foreach (var dir in Directory.EnumerateDirectories(st.OutputDir)) + { + var name = Path.GetFileName(dir); + var m = TryLoadManifest(st.OutputDir, name); + if (m == null) continue; // 无 manifest / manifest 损坏 → 非槽目录(含 .staging-*),排除 + list.Add(new SlotSummary(name, m.Ref, m.Commit, m.BootstrapBlazorVersion, m.ExtractedAt, m.Status, name == pinned)); + } + return list; + } + + /// 台账折入项:manifest 损坏(非法 JSON)按缺失(null)处理,走重新提取路径。 + private static SlotManifest? TryLoadManifest(string outputDir, string slotName) + { + try + { + return VersionSlot.LoadManifest(outputDir, slotName); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + return null; + } + } + + /// 拒绝路径穿越形槽名:纯点段("."、".." 等)或含任意路径分隔符("/"、"\")。 + private static bool IsUnsafeSlotName(string slot) + => slot.Length == 0 + || slot.Trim('.').Length == 0 + || slot.Contains('/') + || slot.Contains('\\'); + + /// 注记②:读构建产物 DLL 的 FileVersion;文件缺失/读取失败返回 null。 + private static string? ReadBbVersion(string localPath) + { + try + { + var dll = Path.Combine(localPath, "src/BootstrapBlazor/bin/Release/net10.0/BootstrapBlazor.dll"); + return File.Exists(dll) ? FileVersionInfo.GetVersionInfo(dll).FileVersion : null; + } + catch + { + return null; + } + } +} diff --git a/Services/GitClient.cs b/Services/GitClient.cs index 36612b7..a203ab6 100644 --- a/Services/GitClient.cs +++ b/Services/GitClient.cs @@ -24,6 +24,9 @@ public interface IGitClient /// HEAD 指向 commit 的短 SHA(7 位)。 string HeadCommit(string workDir); + + /// 分支或 tag 指向 commit 的短 SHA(7 位);两者皆不存在返回 null。跳过判定用(T4 注记①)。 + string? TipOf(string workDir, string refName); } public sealed class GitClient : IGitClient @@ -62,4 +65,14 @@ public string HeadCommit(string workDir) using var repo = new Repository(workDir); return repo.Head.Tip.Sha[..7]; } + + public string? TipOf(string workDir, string refName) + { + using var repo = new Repository(workDir); + // brief 注记①笔误修正:取 7 位短 SHA 与 HeadCommit/manifest.Commit 同格式,可直接比对。 + // Tag : ReferenceWrapper 并非 GitObject 子类,附注 tag 经 PeeledTarget 剥到 commit + var tag = repo.Tags[refName]; + var sha = repo.Branches[refName]?.Tip.Sha ?? tag?.PeeledTarget.Sha ?? tag?.Target.Sha; + return sha?[..7]; + } } diff --git a/Services/GitSyncInvocable.cs b/Services/GitSyncInvocable.cs index c63b3b7..2013284 100644 --- a/Services/GitSyncInvocable.cs +++ b/Services/GitSyncInvocable.cs @@ -12,19 +12,33 @@ public class GitSyncInvocable : IInvocable private readonly ILogger _logger; private readonly DocsExtractorService _extractorService; private readonly AppSettingsManager _settingsManager; + private readonly ExtractionService _extractionService; public GitSyncInvocable( ILogger logger, DocsExtractorService extractorService, - AppSettingsManager settingsManager) + AppSettingsManager settingsManager, + ExtractionService extractionService) { _logger = logger; _extractorService = extractorService; _settingsManager = settingsManager; + _extractionService = extractionService; } public Task Invoke() { + // T4 分派:PinRef 非空 → 版本槽提取流水线(fire-and-forget),夜间 cron 不再走全量遗产路径 + var pinRef = _settingsManager.LoadSettings().PinRef; + if (!string.IsNullOrWhiteSpace(pinRef)) + { + var slot = VersionSlot.SlotName(pinRef); + _logger.LogInformation("PinRef '{PinRef}' configured; dispatching to slot extraction pipeline (slot '{Slot}').", pinRef, slot); + _extractionService.StartExtraction(slot, force: false); + return Task.CompletedTask; + } + + // ↓↓↓ 遗产分支:与上游原实现逐字等价(spec §6.2 零回归承诺),勿改动任何语句 ↓↓↓ _logger.LogInformation("Starting Git Sync & Extraction Job..."); try From c23289d3d22c56e03cb776c57d5327a1abc89ef8 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 15:37:17 +0800 Subject: [PATCH 06/24] =?UTF-8?q?fix(pipeline):=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=E6=A7=BD=20origin=20=E4=BC=98=E5=85=88=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20+=20fire-and-forget=20=E5=B4=A9=E6=BA=83?= =?UTF-8?q?=E8=90=BD=E7=97=95=20(T4=20=E4=BF=AE=E5=A4=8DR1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 二审 Important 两项修复: - Imp-1 分支槽「跟随上游」在真实 git 语义下结构性失效:Commands.Fetch 空 refspec 只推进 refs/remotes/origin/* 不动本地分支,Branches 短名解析本地 优先且远端回退缺 origin/ 前缀,Checkout 按名只检本地 tip 不 fast-forward ——默认分支槽恒「up to date」谎报静默过期,非默认分支槽恒「not found」。 GitClient 改 origin 优先:RefExists 补认 origin/{name};TipOf 分支先取 Branches["origin/"+name]?.Tip 再回退本地(tag 路径不动,PeeledTarget 剥壳 保留);Checkout 分支 detached 检出 origin tip 的 commit SHA(tag 按名不变) - Imp-2 StartExtraction fire-and-forget 完全吞故障:RunPipeline 未捕获 Clone/Fetch 网络失败、extract 抛出、目录替换/manifest IO 失败等,成为 unobserved task exception 零日志零痕迹。Task.Run lambda 内包 try/catch → LogError + 尽力补写 failed manifest(内层再套 catch 防二次抛;崩溃点未知 故 Commit 记 null,LastError 记异常消息) - 顺带:Directory.Move 处「同卷原子替换」注释改为如实描述(删旧目录+改名 两步,窗口内槽缺失由下轮重提自愈) 新增测试(真实 git 离线,仿 T3 本地源仓库手法): - GitClientTests.BranchSemantics_OriginFirst_FollowsUpstreamAfterFetch:源仓库 建 dev 分支+默认分支前进 → clone 后本地 dev 不存在但 RefExists/TipOf 经 origin/dev 可见;Fetch 后 TipOf 返回默认分支新 commit、Checkout 后 HeadCommit 为新 commit - ExtractionServiceTests.BranchSlot_StaleReadyManifest_ReextractsAfterUpstreamAdvance: 旧 ready manifest(克隆时 commit)+ 上游前进 → RunAsync 非 force 不谎报 up to date、重提取落新 commit - ExtractionServiceTests.StartExtraction_PipelineCrash_WritesFailedManifest_DoesNotThrow: extract 回调抛异常 → failed manifest 落盘(LastError 含异常消息) 验证:RED 三测分别失败于 RefExists=false / Skipped=true / manifest 停 extracting(预期原因);修复后 filter(GitClient|ExtractionService)17/17、 全量 33/33 passed;主项目 build 0 error(NU1903 预存) Co-Authored-By: ZCode (GLM-5.3) --- .../ExtractionServiceTests.cs | 81 ++++++++++++++++++- .../GitClientTests.cs | 61 ++++++++++++++ Services/ExtractionService.cs | 36 ++++++++- Services/GitClient.cs | 30 +++++-- 4 files changed, 197 insertions(+), 11 deletions(-) diff --git a/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs index 4a95c33..7f29157 100644 --- a/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs +++ b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs @@ -1,6 +1,8 @@ // BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs -// 全部用 fake(FakeGit/FakeRunner)+ 真实临时目录,离线,不碰真 git/build/extractor。 +// 全部用 fake(FakeGit/FakeRunner)+ 真实临时目录,离线,不碰真 git/build/extractor; +// 标注「真实 git」的用例例外:本地源仓库路径 clone/fetch,同样离线。 using BootstrapBlazor.McpServer.Services; +using LibGit2Sharp; using Microsoft.Extensions.Logging.Abstractions; public class ExtractionServiceTests @@ -174,6 +176,83 @@ public async Task GitSync_WithPinRef_DispatchesToPipeline_SkipsLegacy() Assert.True(File.Exists(VersionSlot.ManifestPath(s.OutputDir, "v10.8.1"))); } + [Fact] // T4 修复R1 回归(真实 git 离线):源仓库默认分支前进后,旧 ready manifest 不得谎报 up to date,须重提取到新 commit + public async Task BranchSlot_StaleReadyManifest_ReextractsAfterUpstreamAdvance() + { + var root = Path.Combine(Path.GetTempPath(), "ext-g-" + Guid.NewGuid().ToString("N")); + try + { + var src = Path.Combine(root, "src"); var work = Path.Combine(root, "work"); var output = Path.Combine(root, "out"); + var sig = new Signature("t", "t", DateTimeOffset.Now); + Repository.Init(src); + File.WriteAllText(Path.Combine(src, "a.txt"), "hello"); + string branch; string c1Short; string c2Short; + using (var repo = new Repository(src)) + { + Commands.Stage(repo, "a.txt"); + c1Short = repo.Commit("init", sig, sig).Sha[..7]; + branch = repo.Head.FriendlyName; // 动态取默认分支名,不假设 master/main + } + Repository.Clone(src, work); // work 本地分支停在 c1(真实场景的克隆时点) + Directory.CreateDirectory(Path.Combine(output, branch, "API")); + VersionSlot.SaveManifest(output, branch, new SlotManifest(branch, c1Short, null, DateTimeOffset.UtcNow, "ready")); + using (var repo = new Repository(src)) // 上游前进:c1 → c2 + { + File.AppendAllText(Path.Combine(src, "a.txt"), " world"); + Commands.Stage(repo, "a.txt"); + c2Short = repo.Commit("advance", sig, sig).Sha[..7]; + } + var settings = new AppSettingsModel { RepositoryUrl = src, LocalPath = work, OutputDir = output }; + var svc = new ExtractionService(new GitClient(), new FakeRunner(), + (b, o) => Directory.CreateDirectory(Path.Combine(o, "API")), + () => settings, NullLogger.Instance); + + var r = await svc.RunAsync(branch, force: false); + + Assert.False(r.Skipped); // 核心:上游已前进,不得再报 up to date + Assert.True(r.Started); + var m = VersionSlot.LoadManifest(output, branch); + Assert.Equal("ready", m!.Status); + Assert.Equal(c2Short, m.Commit); // 重提取落在源仓库新 commit + } + finally + { + // 测试自有脚手架:git loose object 为只读,先去只读再删目录(best-effort,断言不受影响) + try + { + foreach (var f in Directory.GetFiles(root, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + Directory.Delete(root, true); + } + catch { /* best-effort */ } + } + } + + [Fact] // T4 修复R2:fire-and-forget 管线崩溃要留下痕迹(日志 + failed manifest),而非 unobserved 静默吞掉 + public async Task StartExtraction_PipelineCrash_WritesFailedManifest_DoesNotThrow() + { + var s = new AppSettingsModel(); + s.OutputDir = Path.Combine(Path.GetTempPath(), "ext-t-" + Guid.NewGuid().ToString("N")); + var svc = new ExtractionService(new FakeGit(), new FakeRunner(), + (b, o) => throw new InvalidOperationException("extract boom"), // 模拟 DocsExtractor 崩溃 + () => s, NullLogger.Instance); + + var msg = svc.StartExtraction("main", force: true); + Assert.Contains("Extraction started", msg, StringComparison.OrdinalIgnoreCase); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + SlotManifest? m = null; + while (DateTime.UtcNow < deadline) + { + m = VersionSlot.LoadManifest(s.OutputDir, "main"); + if (m?.Status == "failed") break; + await Task.Delay(50); + } + Assert.NotNull(m); + Assert.Equal("failed", m!.Status); + Assert.Contains("extract boom", m.LastError); + } + /// 记录全部调用名;可用 *Result 属性控制返回值。 private sealed class FakeGit : IGitClient { diff --git a/BootstrapBlazor.McpServer.Test/GitClientTests.cs b/BootstrapBlazor.McpServer.Test/GitClientTests.cs index 8da67bd..7fe17a3 100644 --- a/BootstrapBlazor.McpServer.Test/GitClientTests.cs +++ b/BootstrapBlazor.McpServer.Test/GitClientTests.cs @@ -61,4 +61,65 @@ public void Clone_RefExists_Checkout_WorksOffline() catch { /* best-effort */ } } } + + [Fact] // T4 修复R1:分支 origin 优先——Fetch 只推进 refs/remotes/origin/*,本地分支停在克隆时 commit; + // 按名读本地分支/只检本地 tip 会导致分支槽恒「up to date」谎报或非默认分支恒「not found」 + public void BranchSemantics_OriginFirst_FollowsUpstreamAfterFetch() + { + var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N")); + try + { + var src = Path.Combine(root, "src"); var work = Path.Combine(root, "work"); + var sig = new Signature("t", "t", DateTimeOffset.Now); + Repository.Init(src); + File.WriteAllText(Path.Combine(src, "a.txt"), "hello"); + string branch; string c1Short; string devShort; + using (var repo = new Repository(src)) + { + Commands.Stage(repo, "a.txt"); + c1Short = repo.Commit("init", sig, sig).Sha[..7]; + branch = repo.Head.FriendlyName; // 动态取默认分支名,不假设 master/main + repo.CreateBranch("dev"); // 非默认分支:克隆后 work 只会有 origin/dev,无本地 dev + Commands.Checkout(repo, "dev"); + File.AppendAllText(Path.Combine(src, "a.txt"), " dev"); + Commands.Stage(repo, "a.txt"); + devShort = repo.Commit("dev work", sig, sig).Sha[..7]; + Commands.Checkout(repo, branch); // 克隆前切回默认分支:clone 只为 HEAD 目标建本地引用,dev 保持仅远端 + } + + var git = new GitClient(); + Repository.Clone(src, work); + using (var w = new Repository(work)) + { + Assert.Null(w.Branches["dev"]); // 前置事实:克隆不建本地 dev 引用 + } + Assert.True(git.RefExists(work, "dev")); // origin/dev 回退:非默认分支不再恒 not found + Assert.Equal(devShort, git.TipOf(work, "dev")); // TipOf 读到的是 origin/dev 的 tip + + // 源仓库默认分支前进(新 commit)→ work Fetch → TipOf/Checkout 应跟随 origin tip 而非停摆的本地 tip + string c3Short; + using (var repo = new Repository(src)) + { + File.AppendAllText(Path.Combine(src, "a.txt"), " v3"); + Commands.Stage(repo, "a.txt"); + c3Short = repo.Commit("advance", sig, sig).Sha[..7]; + } + Assert.Equal(c1Short, git.TipOf(work, branch)); // Fetch 前仍是克隆时旧 tip + git.Fetch(work); // 只推进 origin/{branch},不动本地分支 + Assert.Equal(c3Short, git.TipOf(work, branch)); // origin 优先:读到上游新 commit + git.Checkout(work, branch, force: true); + Assert.Equal(c3Short, git.HeadCommit(work)); // 检出 origin tip,而非停在克隆时的本地 tip + } + finally + { + // 测试自有脚手架:git 将 loose object 写为只读文件,须先去掉只读属性才能删除目录(best-effort,断言不受影响) + try + { + foreach (var f in Directory.GetFiles(root, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + Directory.Delete(root, true); + } + catch { /* best-effort */ } + } + } } diff --git a/Services/ExtractionService.cs b/Services/ExtractionService.cs index ee5cffd..4e9a47e 100644 --- a/Services/ExtractionService.cs +++ b/Services/ExtractionService.cs @@ -117,8 +117,9 @@ private ExtractionResult RunPipeline(string slot, bool force) } _extract(st.LocalPath, staging); var final = Path.Combine(st.OutputDir, slot); + // 两步替换(删旧槽目录 + staging 改名)非单步原子:窗口内槽短暂缺失,由下轮重提自愈 if (Directory.Exists(final)) Directory.Delete(final, true); - Directory.Move(staging, final); // staging → 槽:同卷原子替换 + Directory.Move(staging, final); VersionSlot.SaveManifest(st.OutputDir, slot, new SlotManifest(gitRef, commit, ReadBbVersion(st.LocalPath), DateTimeOffset.UtcNow, SlotStatus.Ready)); _logger.LogInformation("Slot {Slot} extracted at commit {Commit}.", slot, commit); @@ -128,11 +129,42 @@ private ExtractionResult RunPipeline(string slot, bool force) /// 【fire-and-forget】后台启动提取,立即返回提示串。对外 MCP/REST 一律经此进入,绝不 await RunAsync 挂住调用方。 public string StartExtraction(string slot, bool force, string extraNote = "") { - _ = Task.Run(() => RunAsync(slot, force)); + // T4 修复R2:fire-and-forget 不得吞故障——Clone/Fetch 网络失败、extract 抛出、目录替换/manifest IO 失败等 + // 若不捕获将成为 unobserved task exception(零日志零痕迹,首次 Clone 失败时槽连 manifest 都没有,列表不可见; + // 夜间 cron 全经此进入)。捕获后记日志并尽力补写 failed manifest(写 manifest 再套 catch 防二次抛) + _ = Task.Run(async () => + { + try + { + await RunAsync(slot, force); + } + catch (Exception ex) + { + _logger.LogError(ex, "Extraction pipeline crashed for '{Slot}'.", slot); + TryWriteFailedManifest(slot, ex); + } + }); var message = $"Extraction started for slot '{slot}'. Poll GetAvailableVersions for progress."; return string.IsNullOrWhiteSpace(extraNote) ? message : $"{extraNote} {message}"; } + /// 尽力补写 failed manifest(崩溃点未知故 Commit 为 null);槽名非法/设置不可读/IO 失败时放弃,仅保留调用方日志。 + private void TryWriteFailedManifest(string slotInput, Exception ex) + { + try + { + var slot = VersionSlot.SlotName(slotInput); + if (IsUnsafeSlotName(slot)) return; + var st = _settings(); + VersionSlot.SaveManifest(st.OutputDir, slot, + new SlotManifest(VersionSlot.ToGitRef(slot), null, null, DateTimeOffset.UtcNow, SlotStatus.Failed, ex.Message)); + } + catch + { + // best-effort:失败仅意味着少写一份 manifest,不再抛出 + } + } + /// 钉住并提取:保存 PinRef=槽名后启动提取(T6/T7 共享)。 public string PinAndExtract(AppSettingsManager cfg, string refOrVersion) { diff --git a/Services/GitClient.cs b/Services/GitClient.cs index a203ab6..e39842f 100644 --- a/Services/GitClient.cs +++ b/Services/GitClient.cs @@ -16,16 +16,16 @@ public interface IGitClient /// 等价 git fetch --tags --prune。 void Fetch(string workDir); - /// tag 或分支存在。 + /// tag、本地分支或 refs/remotes/origin/{name} 存在(分支 origin 优先语义,T4 修复R1)。 bool RefExists(string workDir, string refName); - /// 检出 ref(tag 检出为 detached HEAD 属合法状态)。 + /// 检出 ref:分支检出 origin tip 的 commit(detached);tag 按名检出(detached 属合法状态)。 void Checkout(string workDir, string refName, bool force); /// HEAD 指向 commit 的短 SHA(7 位)。 string HeadCommit(string workDir); - /// 分支或 tag 指向 commit 的短 SHA(7 位);两者皆不存在返回 null。跳过判定用(T4 注记①)。 + /// 分支(origin 优先,回退本地)或 tag 指向 commit 的短 SHA(7 位);皆不存在返回 null。跳过判定用(T4 注记①/修复R1)。 string? TipOf(string workDir, string refName); } @@ -47,14 +47,23 @@ public void Fetch(string workDir) public bool RefExists(string workDir, string refName) { using var repo = new Repository(workDir); - return repo.Tags[refName] != null || repo.Branches[refName] != null; + // T4 修复R1:Fetch 只推进 refs/remotes/origin/* 从不移动本地分支;非默认分支克隆后更是连本地引用都没有。 + // 而 Branches 短名解析本地优先、远端回退拼 refs/remotes/{name} 缺 origin/ 前缀——短名永远落不到远端分支, + // 故这里显式补认 origin/{name} + return repo.Tags[refName] != null + || repo.Branches[refName] != null + || repo.Branches["origin/" + refName] != null; } public void Checkout(string workDir, string refName, bool force) { using var repo = new Repository(workDir); - // brief 笔误修正:CheckoutOptions 无 Force 布尔属性,LibGit2Sharp 0.31 API 为 CheckoutModifiers 枚举 - Commands.Checkout(repo, refName, new CheckoutOptions + // brief 笔误修正:CheckoutOptions 无 Force 布尔属性,LibGit2Sharp 0.31 API 为 CheckoutModifiers 枚举。 + // T4 修复R1:按名 Checkout("main") 只检出本地分支当前 tip、不做 fast-forward(本地分支停在克隆时 commit), + // 分支改为 detached 检出 origin tip 的 commit SHA 才真正跟随上游;tag 无本地/远端之分,按名检出不变 + var originTip = repo.Branches["origin/" + refName]?.Tip ?? repo.Branches[refName]?.Tip; + var target = originTip?.Sha ?? refName; + Commands.Checkout(repo, target, new CheckoutOptions { CheckoutModifiers = force ? CheckoutModifiers.Force : CheckoutModifiers.None }); @@ -70,9 +79,14 @@ public string HeadCommit(string workDir) { using var repo = new Repository(workDir); // brief 注记①笔误修正:取 7 位短 SHA 与 HeadCommit/manifest.Commit 同格式,可直接比对。 - // Tag : ReferenceWrapper 并非 GitObject 子类,附注 tag 经 PeeledTarget 剥到 commit + // T4 修复R1:分支 origin 优先——本地分支停在克隆时 commit,读本地 tip 会让分支槽恒「up to date」谎报; + // Branches["origin/"+name] 经短名远端回退解析到 canonical refs/remotes/origin/*。 + // tag 路径不动:附注 tag 经 PeeledTarget 剥壳(Tag : ReferenceWrapper 并非 GitObject 子类) var tag = repo.Tags[refName]; - var sha = repo.Branches[refName]?.Tip.Sha ?? tag?.PeeledTarget.Sha ?? tag?.Target.Sha; + var sha = repo.Branches["origin/" + refName]?.Tip.Sha + ?? repo.Branches[refName]?.Tip.Sha + ?? tag?.PeeledTarget.Sha + ?? tag?.Target.Sha; return sha?[..7]; } } From 95a4e0c94fae5c233845a2a4854f1c64970d7b9a Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 15:51:03 +0800 Subject: [PATCH 07/24] =?UTF-8?q?feat(extractor):=20=E5=8F=8D=E5=B0=84?= =?UTF-8?q?=E6=BA=90=E6=94=B9=20MetadataLoadContext=20=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=20DLL=EF=BC=8C=E4=B8=BB=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E5=8C=85=E7=89=88=E6=9C=AC=E4=B8=8E=E6=9F=A5=E8=AF=A2=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E8=A7=A3=E8=80=A6=20(T5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Services/MlcAssemblyLoader.cs:MetadataLoadContext 元数据专用 ALC 加载目标目录 BootstrapBlazor.dll,与进程内主程序包版本彻底隔离(多版本钉住坑①「主程序包版本污染 查询口径」的根治) - 三部分 resolver:① 目标目录(含其 NuGet 依赖)② AspNetCore.App 共享框架最新版本目录 (IComponent/ParameterAttribute 所在;按 Version.TryParse 语义排序取最大——字典序会把 10.0.10 排在 10.0.9 前)③ NETCore 运行时目录(RuntimeEnvironment.GetRuntimeDirectory) - brief 笔误修正:路径字典改用 TryAdd 落实「先目标目录后框架、后加入者不覆盖」——原代码块 索引器赋值会令框架同名 DLL 覆盖目标目录依赖,重新引入版本污染 - DocsExtractorService.Extract 反射块:MLC 优先,回退链保留(MLC → 进程内已加载 → LoadFrom),目标 DLL 缺失时旧场景零回归;Extract 签名与输出结构不变(T9 夹具测试消费) - IComponent/ParameterAttribute 两处改为经 assembly 所在上下文按全名解析:IComponent 由 组件类型接口链反查(框架类型不在 BootstrapBlazor.dll 自身);ParameterAttribute 统一走 CustomAttributeData 按全名比对(MLC 不支持实例化 Attribute);in-proc 分支解析结果即 typeof(...)(运行时 Type 实例规范化唯一),原语义不变 - 主项目加包 System.Reflection.MetadataLoadContext 10.0.11(用户已授权的唯一包变更) - 测试:新增 MlcAssemblyLoaderTests 2 例(目标 bin 目录真实 BootstrapBlazor.dll 的 组件解析 + 缺目录返回 null);全量 dotnet test 35/35 通过,dotnet build 0 错误 Co-Authored-By: ZCode (GLM-5.3) --- .../MlcAssemblyLoaderTests.cs | 31 ++++++++++++ BootstrapBlazor.McpServer.csproj | 1 + Services/DocsExtractorService.cs | 47 ++++++++++++++---- Services/MlcAssemblyLoader.cs | 49 +++++++++++++++++++ 4 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 BootstrapBlazor.McpServer.Test/MlcAssemblyLoaderTests.cs create mode 100644 Services/MlcAssemblyLoader.cs diff --git a/BootstrapBlazor.McpServer.Test/MlcAssemblyLoaderTests.cs b/BootstrapBlazor.McpServer.Test/MlcAssemblyLoaderTests.cs new file mode 100644 index 0000000..176677f --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/MlcAssemblyLoaderTests.cs @@ -0,0 +1,31 @@ +// BootstrapBlazor.McpServer.Test/MlcAssemblyLoaderTests.cs +// 目标 = 测试 bin 目录里的 BootstrapBlazor.dll(主项目 NuGet 依赖随 ProjectReference 复制, +// 自带 IComponent 派生类型,满足 spec §8「带框架依赖基类」) +using BootstrapBlazor.McpServer.Services; + +public class MlcAssemblyLoaderTests +{ + private static string BinDir => Path.GetDirectoryName(typeof(MlcAssemblyLoaderTests).Assembly.Location)!; + + [Fact] + public void LoadBootstrapBlazor_ResolvesComponentTypes() + { + var asm = MlcAssemblyLoader.LoadBootstrapBlazor(BinDir); + Assert.NotNull(asm); + Assert.Equal("BootstrapBlazor", asm!.GetName().Name); + // GetTypes() 本身能成功 = resolver 三部分齐备(缺 AspNetCore.App 时在枚举类型处即抛解析异常) + var anchor = asm.GetTypes().First(t => t.Name == "Divider"); // 非泛型公开组件(N1:Table 是泛型 Table`1,裸名不存在;Divider 已实证存在且 public 非抽象) + // IComponent 定义在框架 Microsoft.AspNetCore.Components.dll——经组件类型的接口链反查, + // 不能在 BootstrapBlazor.dll 的 GetTypes() 里找(那里只有本程序集类型) + var iComponent = anchor.GetInterfaces().Concat(anchor.GetInterfaces().SelectMany(i => i.GetInterfaces())) + .FirstOrDefault(i => i.FullName == "Microsoft.AspNetCore.Components.IComponent"); + Assert.NotNull(iComponent); + var components = asm.GetTypes().Where(t => t.IsPublic && !t.IsAbstract && iComponent!.IsAssignableFrom(t)).ToList(); + Assert.True(components.Count > 50); + Assert.Contains(components, t => t.Name == "Divider"); + } + + [Fact] + public void LoadBootstrapBlazor_MissingDir_ReturnsNull() + => Assert.Null(MlcAssemblyLoader.LoadBootstrapBlazor(Path.Combine(Path.GetTempPath(), "no-such-" + Guid.NewGuid().ToString("N")))); +} diff --git a/BootstrapBlazor.McpServer.csproj b/BootstrapBlazor.McpServer.csproj index 360555e..364a31a 100644 --- a/BootstrapBlazor.McpServer.csproj +++ b/BootstrapBlazor.McpServer.csproj @@ -26,6 +26,7 @@ + diff --git a/Services/DocsExtractorService.cs b/Services/DocsExtractorService.cs index ab12b11..c2ace5e 100644 --- a/Services/DocsExtractorService.cs +++ b/Services/DocsExtractorService.cs @@ -12,6 +12,10 @@ namespace BootstrapBlazor.McpServer.Services; public class DocsExtractorService { + // 经 assembly 所在上下文按全名解析的两个框架类型(MLC 下 Type 实例不与进程内 typeof 互通) + private const string IComponentFullName = "Microsoft.AspNetCore.Components.IComponent"; + private const string ParameterAttributeFullName = "Microsoft.AspNetCore.Components.ParameterAttribute"; + private readonly ILogger _logger; private Dictionary _localizerDict = new(); @@ -101,19 +105,33 @@ public void Extract(string basePath, string outputDir) // Use Reflection to get components try { - // Use the already loaded assembly instead of trying to load it from a physical file - // to prevent AssemblyLoadContext mismatched versions. - var assembly = AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => a.GetName().Name == "BootstrapBlazor") - ?? Assembly.LoadFrom(dllPath); - - var componentTypes = assembly.GetTypes() - .Where(t => t.IsPublic && !t.IsAbstract && typeof(IComponent).IsAssignableFrom(t)); + // 反射源 MLC 优先:按目标目录经 MetadataLoadContext 加载 BootstrapBlazor.dll, + // 令查询口径跟随被提取版本而非主程序包版本(多版本钉住坑①根治); + // 目标 DLL 缺失时回退现状(进程内已加载程序集 → LoadFrom),旧场景零回归 + var targetDir = Path.GetDirectoryName(dllPath); + // 三级回退均落空时抛出,由下方既有 catch 记日志后继续 Samples 段—— + // 与旧码 LoadFrom 抛异常被 catch 的控制流一致(实际不可达:dllPath 存在性已前置校验) + var assembly = MlcAssemblyLoader.LoadBootstrapBlazor(targetDir) + ?? AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "BootstrapBlazor") + ?? (dllPath != null && File.Exists(dllPath) ? Assembly.LoadFrom(dllPath) : null) + ?? throw new InvalidOperationException($"Failed to load BootstrapBlazor assembly from {targetDir}."); + + var allTypes = assembly.GetTypes(); + + // IComponent 经 assembly 所在上下文按全名解析:MLC 下定义于框架 Microsoft.AspNetCore.Components.dll, + // 由组件类型的接口链反查(引用程序集已随锚点解析进同一 MLC,不能在 BootstrapBlazor.dll 自身找); + // in-proc 分支反查结果即 typeof(IComponent)(运行时 Type 实例规范化唯一),保持原语义 + var iComponent = ResolveContextType(allTypes, assembly, IComponentFullName, typeof(IComponent)); + + var componentTypes = allTypes + .Where(t => t.IsPublic && !t.IsAbstract && iComponent.IsAssignableFrom(t)); foreach (var type in componentTypes) { + // MLC 不支持实例化 Attribute,统一走 CustomAttributeData 按全名比对; + // in-proc 下 AttributeType 即 typeof(ParameterAttribute),行为等价 var parameters = type.GetProperties() - .Where(p => p.GetCustomAttributes(typeof(ParameterAttribute), true).Any()) + .Where(p => p.GetCustomAttributesData().Any(d => d.AttributeType.FullName == ParameterAttributeFullName)) .ToList(); if (parameters.Any()) @@ -221,6 +239,17 @@ public void Extract(string basePath, string outputDir) _logger.LogInformation("Done! RAG Datasets created in {OutputDir}.", outputDir); } + /// + /// 经 assembly 所在上下文按全名解析框架类型:先查 assembly 自身,再经组件类型的接口链反查 + /// (GetInterfaces 已含基类链上实现的全部接口)。MLC 与 in-proc 两上下文统一走全名查找; + /// in-proc 下解析结果即 typeof(...)(运行时 Type 实例规范化唯一),保持原语义; + /// 均未命中时回退 runtimeFallback,与旧码「无匹配 → 零组件产出」行为一致。 + /// + private static Type ResolveContextType(Type[] allTypes, Assembly assembly, string fullTypeName, Type runtimeFallback) + => assembly.GetType(fullTypeName, throwOnError: false) + ?? allTypes.SelectMany(t => t.GetInterfaces()).FirstOrDefault(i => i.FullName == fullTypeName) + ?? runtimeFallback; + private string CleanLocalization(string content, string className) { var replaced = Regex.Replace(content, @"@?Localizer\[""(.*?)""\](\.Value)?", match => diff --git a/Services/MlcAssemblyLoader.cs b/Services/MlcAssemblyLoader.cs new file mode 100644 index 0000000..5c512f7 --- /dev/null +++ b/Services/MlcAssemblyLoader.cs @@ -0,0 +1,49 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone + +using System.Reflection; +using System.Runtime.InteropServices; + +namespace BootstrapBlazor.McpServer.Services; + +/// +/// MetadataLoadContext 加载器:以元数据专用 ALC 从目标目录加载 BootstrapBlazor.dll, +/// 与主程序进程内加载的 BootstrapBlazor 包版本完全隔离(多版本钉住改造坑①的根治)。 +/// +public static class MlcAssemblyLoader +{ + /// 加载目录下的 BootstrapBlazor.dll(三部分 resolver:目标目录 + AspNetCore.App 共享框架 + NETCore 运行时目录)。 + public static Assembly? LoadBootstrapBlazor(string? dllDirectory) + { + var dll = dllDirectory is null ? null : Path.Combine(dllDirectory, "BootstrapBlazor.dll"); + if (dll is null || !File.Exists(dll)) return null; + + var paths = new Dictionary(StringComparer.OrdinalIgnoreCase); + // brief 笔误修正:原代码块用索引器赋值(后者会覆盖前者,与注释「后加入者不覆盖」相反), + // 改用 TryAdd 落实先到先得——目标目录依赖优先于框架同名 DLL,避免版本口径被框架副本污染 + void AddDir(string dir) + { + if (Directory.Exists(dir)) + foreach (var p in Directory.GetFiles(dir, "*.dll")) + paths.TryAdd(Path.GetFileName(p), p); + } + + AddDir(dllDirectory); // ① 目标目录(含其 NuGet 依赖) + var coreDir = RuntimeEnvironment.GetRuntimeDirectory(); // ③ NETCore 运行时目录 + var sharedRoot = Path.GetFullPath(Path.Combine(coreDir, "..", "..")); // …/shared/ + var aspNetDir = Path.Combine(sharedRoot, "Microsoft.AspNetCore.App"); // ② AspNetCore.App(IComponent/ParameterAttribute 所在) + if (Directory.Exists(aspNetDir)) + { + // brief 注记:版本目录必须按 Version 语义取最大——字典序会把 10.0.10 排在 10.0.9 之前 + var latest = Directory.GetDirectories(aspNetDir) + .OrderByDescending(v => Version.TryParse(Path.GetFileName(v), out var ver) ? ver : new Version(0, 0)) + .First(); + AddDir(latest); + } + AddDir(coreDir); + + var mlc = new MetadataLoadContext(new PathAssemblyResolver(paths.Values.Distinct())); + return mlc.LoadFromAssemblyPath(dll); + } +} From a4d524fb502af60d6c72c67fcabae56aef066a5a Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 16:08:23 +0800 Subject: [PATCH 08/24] =?UTF-8?q?feat(mcp):=20=E6=9F=A5=E8=AF=A2=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=8A=A0=E5=8F=AF=E9=80=89=20version=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=EF=BC=88=E5=BD=92=E4=B8=80=E5=8C=96/=E6=A7=BD?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=EF=BC=89=E4=B8=8E=20GetAvailableVersions/Pin?= =?UTF-8?q?Version/ExtractVersion=20(T6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Services/McpService.cs:新增 internal static 纯函数 ResolveDocsRoot(settings, version, out notice),规则按优先级:version 非空→归一化→命中槽→槽;PinRef 空→根目录+legacy notice;未命中→null+可用槽清单;version 空→钉住槽存在→槽,否则根目录(兼容未提取,notice=null) - 4 个既有查询工具(GetComponentList/SearchComponentKeyword/GetComponentDocs/AskComponentExpert)args 增可选 string? Version 并改走 ResolveDocsRoot;不传 Version 且遗产模式下解析结果与返回文案与旧版一致;notice 非空时在返回文案前附一行前缀;LoadComponentDocs 首参由 settings 改为 root 路径;XML doc 补「建议传入当前项目实际引用的 BootstrapBlazor 版本(如 10.8.1)」提示 - GetComponentList 换签名为 GetComponentList(GetComponentListArgs{Version}),Program.cs 对应 REST 调用点同步传空 args(行为不变,编译必需) - 新增 3 工具:GetAvailableVersions()(槽台账列表+legacy 附注)、PinVersion(PinVersionArgs{Ref})(PinAndExtract:保存 PinRef+fire-and-forget 提取,立即返回)、ExtractVersion(ExtractVersionArgs{Ref})(StartExtraction force:true,立即返回);PinVersion/ExtractVersion XML doc 首句标【状态变更操作】;绝不 await RunAsync,分钟级提取不挂 MCP 调用 - 构造签名追加 ExtractionService 参数(DI 自动满足,注册由 T7 落地);Services/ExtractionService.cs 仅把 IsUnsafeSlotName private→internal 供 ResolveDocsRoot 复用同一路径穿越守卫(台账折入项单一来源) - brief 笔误级修正:PinVersion 中 SlotName 结果未使用的局部变量收敛为纯校验调用(空 ref 仍抛 ArgumentException,语义不变);测试断言 notice 前补 Assert.NotNull(编译期 nullable 清洁,行为不变) - 验证:dotnet test 全量 40/40 通过(新增 McpServiceVersionTests 5 用例:槽命中/裸版本归一化/未命中清单/遗产忽略 version+notice/钉住槽缺失回落根);dotnet build 主项目 0 error(仅存量 NU1903 警告);零包改动,未启动服务 Co-Authored-By: ZCode (GLM-5.3) --- .../McpServiceVersionTests.cs | 67 +++++++ Program.cs | 2 +- Services/ExtractionService.cs | 5 +- Services/McpService.cs | 177 +++++++++++++++--- 4 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs diff --git a/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs b/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs new file mode 100644 index 0000000..7bbff37 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs @@ -0,0 +1,67 @@ +// BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs +// T6:ResolveDocsRoot 纯函数五用例(槽解析/归一化/未命中清单/遗产模式/钉住槽缺失回落)。 +// 离线,仅真实临时目录,不构造 McpService(其 AI/extractor 依赖不做行为断言)。 +using BootstrapBlazor.McpServer.Services; + +public class McpServiceVersionTests +{ + private static AppSettingsModel Settings(string pinRef, string outputDir) => new() { PinRef = pinRef, OutputDir = outputDir }; + + private static string MakeSlot(string outputDir, string slot) + { + var d = Path.Combine(outputDir, slot); + Directory.CreateDirectory(Path.Combine(d, "API")); + File.WriteAllText(Path.Combine(d, "API", "Table.md"), "# Table"); + return d; + } + + [Fact] + public void Version_Null_UsesPinnedSlot() + { + var o = Path.Combine(Path.GetTempPath(), "mcp6-" + Guid.NewGuid().ToString("N")); + MakeSlot(o, "v10.8.1"); + var root = McpService.ResolveDocsRoot(Settings("v10.8.1", o), null, out _); + Assert.Equal(Path.Combine(o, "v10.8.1"), root); + } + + [Fact] + public void Version_BareNumber_Normalizes() + { + var o = Path.Combine(Path.GetTempPath(), "mcp6-" + Guid.NewGuid().ToString("N")); + MakeSlot(o, "v10.8.1"); + var root = McpService.ResolveDocsRoot(Settings("v10.8.1", o), "10.8.1.0", out _); + Assert.Equal(Path.Combine(o, "v10.8.1"), root); + } + + [Fact] + public void UnknownVersion_ReturnsNullWithErrorListingSlots() + { + var o = Path.Combine(Path.GetTempPath(), "mcp6-" + Guid.NewGuid().ToString("N")); + MakeSlot(o, "v10.8.1"); MakeSlot(o, "main"); + var root = McpService.ResolveDocsRoot(Settings("v10.8.1", o), "7.0.0", out var notice); + Assert.Null(root); + Assert.NotNull(notice); + Assert.Contains("v10.8.1", notice); + Assert.Contains("main", notice); + } + + [Fact] + public void LegacyMode_IgnoresVersion_WithNotice() + { + var o = Path.Combine(Path.GetTempPath(), "mcp6-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(o, "API")); + var root = McpService.ResolveDocsRoot(Settings("", o), "10.8.1", out var notice); + Assert.Equal(o, root); + Assert.NotNull(notice); + Assert.Contains("legacy", notice, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void NoPinRefSlot_FallsBackToRootArtifacts() + { + var o = Path.Combine(Path.GetTempPath(), "mcp6-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(o, "API")); + var root = McpService.ResolveDocsRoot(Settings("v10.9.0", o), null, out _); // v10.9.0 槽不存在 + Assert.Equal(o, root); + } +} diff --git a/Program.cs b/Program.cs index b107bec..0e66fcb 100644 --- a/Program.cs +++ b/Program.cs @@ -74,7 +74,7 @@ // REST HTTP API Endpoints for external agents/LLMs (Dify, FastGPT, etc.) app.MapGet("/api/components", (McpService mcp) => { - return Results.Ok(mcp.GetComponentList()); + return Results.Ok(mcp.GetComponentList(new McpService.GetComponentListArgs())); }); app.MapGet("/api/components/search", (string keyword, McpService mcp) => diff --git a/Services/ExtractionService.cs b/Services/ExtractionService.cs index 4e9a47e..cf37687 100644 --- a/Services/ExtractionService.cs +++ b/Services/ExtractionService.cs @@ -218,8 +218,9 @@ public List GetAvailableVersions() } } - /// 拒绝路径穿越形槽名:纯点段("."、".." 等)或含任意路径分隔符("/"、"\")。 - private static bool IsUnsafeSlotName(string slot) + /// 拒绝路径穿越形槽名:纯点段("."、".." 等)或含任意路径分隔符("/"、"\")。 + /// T6 起对 McpService 共享(internal):ResolveDocsRoot 命中检查复用同一守卫。 + internal static bool IsUnsafeSlotName(string slot) => slot.Length == 0 || slot.Trim('.').Length == 0 || slot.Contains('/') diff --git a/Services/McpService.cs b/Services/McpService.cs index ced00ab..7999cc0 100644 --- a/Services/McpService.cs +++ b/Services/McpService.cs @@ -13,38 +13,60 @@ public class McpService private readonly AiIntegrationService _aiService; private readonly ILogger _logger; private readonly AppSettingsManager _settingsManager; + private readonly ExtractionService _extraction; - public McpService(DocsExtractorService extractor, AiIntegrationService aiService, ILogger logger, AppSettingsManager settingsManager) + public McpService(DocsExtractorService extractor, AiIntegrationService aiService, ILogger logger, AppSettingsManager settingsManager, ExtractionService extraction) { _extractor = extractor; _aiService = aiService; _logger = logger; _settingsManager = settingsManager; + _extraction = extraction; } + public class GetComponentListArgs + { + /// 可选的目标版本(如 10.8.1);不传则解析钉住槽(PinRef)。 + public string? Version { get; set; } + } + + /// + /// 获取指定版本文档集中的组件清单。 + /// 建议传入当前项目实际引用的 BootstrapBlazor 版本(如 10.8.1);不传时使用钉住槽(PinRef),遗产模式(未配置 PinRef)使用根目录产物。 + /// [McpServerTool] - public string GetComponentList() + public string GetComponentList(GetComponentListArgs args) { var settings = _settingsManager.LoadSettings(); - var apiPath = Path.Combine(settings.OutputDir, "API"); - if (!Directory.Exists(apiPath)) return "Documentation not found."; - + var root = ResolveDocsRoot(settings, args.Version, out var notice); + if (root == null) return notice!; + var apiPath = Path.Combine(root, "API"); + if (!Directory.Exists(apiPath)) return WithNotice(notice, "Documentation not found."); var files = Directory.GetFiles(apiPath, "*.md"); var componentNames = files.Select(Path.GetFileNameWithoutExtension).ToArray(); - return string.Join("\n", componentNames); + return WithNotice(notice, string.Join("\n", componentNames)); } public class SearchComponentArgs { public string Keyword { get; set; } = string.Empty; + + /// 可选的目标版本(如 10.8.1);不传则解析钉住槽(PinRef)。 + public string? Version { get; set; } } + /// + /// 按关键字搜索指定版本文档集中的组件。 + /// 建议传入当前项目实际引用的 BootstrapBlazor 版本(如 10.8.1);不传时使用钉住槽(PinRef),遗产模式(未配置 PinRef)使用根目录产物。 + /// [McpServerTool] public string SearchComponentKeyword(SearchComponentArgs args) { var settings = _settingsManager.LoadSettings(); - var apiPath = Path.Combine(settings.OutputDir, "API"); - if (!Directory.Exists(apiPath)) return "Documentation not found at " + apiPath; + var root = ResolveDocsRoot(settings, args.Version, out var notice); + if (root == null) return notice!; + var apiPath = Path.Combine(root, "API"); + if (!Directory.Exists(apiPath)) return WithNotice(notice, "Documentation not found at " + apiPath); var files = Directory.GetFiles(apiPath, "*.md"); var results = new System.Collections.Generic.List(); @@ -58,46 +80,61 @@ public string SearchComponentKeyword(SearchComponentArgs args) } } - if (results.Count == 0) return $"No components found matching the keyword '{args.Keyword}'."; - return string.Join("\n", results); + if (results.Count == 0) return WithNotice(notice, $"No components found matching the keyword '{args.Keyword}'."); + return WithNotice(notice, string.Join("\n", results)); } public class GetComponentDocsArgs { public string ComponentName { get; set; } = string.Empty; + + /// 可选的目标版本(如 10.8.1);不传则解析钉住槽(PinRef)。 + public string? Version { get; set; } } /// - /// 根据组件名直接返回原始文档信息(API 参数表 + 代码示例),不经过 AI 处理。 + /// 根据组件名直接返回指定版本的原始文档信息(API 参数表 + 代码示例),不经过 AI 处理。 + /// 建议传入当前项目实际引用的 BootstrapBlazor 版本(如 10.8.1);不传时使用钉住槽(PinRef),遗产模式(未配置 PinRef)使用根目录产物。 /// [McpServerTool] public string GetComponentDocs(GetComponentDocsArgs args) { var settings = _settingsManager.LoadSettings(); - var (apiContent, sampleContent, error) = LoadComponentDocs(settings, args.ComponentName); - if (error != null) return error; + var root = ResolveDocsRoot(settings, args.Version, out var notice); + if (root == null) return notice!; + var (apiContent, sampleContent, error) = LoadComponentDocs(root, args.ComponentName); + if (error != null) return WithNotice(notice, error); - return $"# Component: {args.ComponentName}\n\n## API\n{apiContent}\n\n## Samples\n{sampleContent}"; + return WithNotice(notice, $"# Component: {args.ComponentName}\n\n## API\n{apiContent}\n\n## Samples\n{sampleContent}"); } public class AskComponentExpertArgs { public string ComponentName { get; set; } = string.Empty; public string Question { get; set; } = string.Empty; + + /// 可选的目标版本(如 10.8.1);不传则解析钉住槽(PinRef)。 + public string? Version { get; set; } } + /// + /// 向 BootstrapBlazor 专家 AI 提问指定版本的组件问题(基于该版本文档回答)。 + /// 建议传入当前项目实际引用的 BootstrapBlazor 版本(如 10.8.1);不传时使用钉住槽(PinRef),遗产模式(未配置 PinRef)使用根目录产物。 + /// [McpServerTool] public async Task AskComponentExpert(AskComponentExpertArgs args) { var settings = _settingsManager.LoadSettings(); - var (apiContent, sampleContent, error) = LoadComponentDocs(settings, args.ComponentName); - if (error != null) return error; + var root = ResolveDocsRoot(settings, args.Version, out var notice); + if (root == null) return notice!; + var (apiContent, sampleContent, error) = LoadComponentDocs(root, args.ComponentName); + if (error != null) return WithNotice(notice, error); // 如果 AI 未启用,直接返回原始文档 if (!settings.AiEnabled) { _logger.LogInformation("AI is disabled. Returning raw documentation for '{Component}'.", args.ComponentName); - return $"# Component: {args.ComponentName}\n\n## API\n{apiContent}\n\n## Samples\n{sampleContent}"; + return WithNotice(notice, $"# Component: {args.ComponentName}\n\n## API\n{apiContent}\n\n## Samples\n{sampleContent}"); } var systemPrompt = $@"You are a senior BootstrapBlazor expert. The user is asking a question about the component '{args.ComponentName}'. @@ -115,17 +152,115 @@ Based strictly on the API and examples provided above (and your general Blazor s If writing code, ensure you use the exact parameter names listed in the API table."; var answer = await _aiService.AskExpertAsync(systemPrompt, args.Question); - return answer; + return WithNotice(notice, answer); + } + + /// + /// 列出本地已提取的文档版本槽(槽名 | BootstrapBlazor 版本 | commit | 状态 | 是否钉住);遗产模式时附注提示。 + /// + [McpServerTool] + public string GetAvailableVersions() + { + var legacy = string.IsNullOrWhiteSpace(_settingsManager.LoadSettings().PinRef) ? "\n(legacy mode: no PinRef configured)" : ""; + return string.Join("\n", _extraction.GetAvailableVersions().Select(s => + $"{s.SlotName} | BB {s.BootstrapBlazorVersion ?? "?"} | {s.Commit ?? "?"} | {s.Status} | {(s.IsPinned ? "[PINNED]" : "")}")) + legacy; + } + + public class PinVersionArgs + { + /// 要钉住的 ref 或版本号(如 10.8.1、v10.8.1、main)。 + public string Ref { get; set; } = ""; + } + + /// + /// 【状态变更操作】钉住指定 BootstrapBlazor 版本(写入 PinRef)并触发后台提取,立即返回;进度请轮询 GetAvailableVersions。 + /// 建议传入当前项目实际引用的 BootstrapBlazor 版本(如 10.8.1)。 + /// + [McpServerTool] + public string PinVersion(PinVersionArgs args) + { + VersionSlot.SlotName(args.Ref); // 空 ref 防御(SlotName 对空白输入抛 ArgumentException);brief 中槽变量未使用,此处收敛为纯校验调用 + return _extraction.PinAndExtract(_settingsManager, args.Ref); // 保存 PinRef + StartExtraction(force:false → 槽已 ready 时后台任务自行跳过) } + public class ExtractVersionArgs + { + /// 要提取的 ref 或版本号(如 10.8.1、v10.8.1、main)。 + public string Ref { get; set; } = ""; + } + + /// + /// 【状态变更操作】强制重新提取指定版本到独立槽(不改动 PinRef),立即返回、提取后台进行;进度请轮询 GetAvailableVersions。 + /// + [McpServerTool] + public string ExtractVersion(ExtractVersionArgs args) + => _extraction.StartExtraction(VersionSlot.SlotName(args.Ref), force: true); + + /// + /// 解析文档根目录(纯函数,可测)。规则(顺序即优先级): + /// ① version 非空 → 归一化 → 命中槽(目录存在)→ 返回槽路径; + /// ② PinRef 空(遗产模式)→ 忽略 version,返回 OutputDir 根目录并附 legacy notice; + /// ③ 未命中 → 返回 null,notice 附可用槽清单; + /// ④ version 空 → PinRef 非空且其槽存在 → 返回槽;否则返回 OutputDir 根目录(兼容未提取场景,notice=null)。 + /// + internal static string? ResolveDocsRoot(AppSettingsModel settings, string? version, out string? notice) + { + notice = null; + var outputDir = settings.OutputDir; + + if (!string.IsNullOrWhiteSpace(version)) + { + var slot = VersionSlot.Normalize(version); + if (slot == null || ExtractionService.IsUnsafeSlotName(slot)) + { + // 与 T4 同一守卫(台账折入项):路径穿越形输入不当槽名用,按未命中处理 + notice = $"Invalid version '{version}'. Available slots: {ListSlots(outputDir)}."; + return null; + } + if (Directory.Exists(Path.Combine(outputDir, slot))) + return Path.Combine(outputDir, slot); + + if (string.IsNullOrWhiteSpace(settings.PinRef)) + { + notice = "(legacy mode: no PinRef configured; version parameter ignored, using root artifacts)"; + return outputDir; + } + + notice = $"Version '{version}' (slot '{slot}') not found. Available slots: {ListSlots(outputDir)}. Use ExtractVersion to extract it first."; + return null; + } + + var pinned = VersionSlot.Normalize(settings.PinRef); + if (pinned != null && !ExtractionService.IsUnsafeSlotName(pinned) && Directory.Exists(Path.Combine(outputDir, pinned))) + return Path.Combine(outputDir, pinned); + + // 兼容未提取场景(含钉住槽尚未提取完成):回落到根目录产物 + return outputDir; + } + + /// 列出 OutputDir 下的候选槽目录名(排除 .staging-* 等点前缀临时目录);无候选时返回 "(none)"。 + private static string ListSlots(string outputDir) + { + if (!Directory.Exists(outputDir)) return "(none)"; + var names = Directory.GetDirectories(outputDir) + .Select(Path.GetFileName) + .Where(n => !string.IsNullOrEmpty(n) && !n.StartsWith('.')) + .ToList(); + return names.Count > 0 ? string.Join(", ", names) : "(none)"; + } + + /// notice 非空时在返回文案前附一行前缀注记(遗产模式提示等),否则原样返回。 + private static string WithNotice(string? notice, string content) + => string.IsNullOrWhiteSpace(notice) ? content : $"{notice}\n{content}"; + /// /// 通用方法:根据组件名加载 API 文档和示例代码。 /// 返回 (apiContent, sampleContent, error),error 不为 null 时表示找不到组件。 /// - private (string apiContent, string sampleContent, string? error) LoadComponentDocs(AppSettingsModel settings, string componentName) + private (string apiContent, string sampleContent, string? error) LoadComponentDocs(string root, string componentName) { - var apiDir = Path.Combine(settings.OutputDir, "API"); - var samplesDir = Path.Combine(settings.OutputDir, "Samples"); + var apiDir = Path.Combine(root, "API"); + var samplesDir = Path.Combine(root, "Samples"); var apiFile = Path.Combine(apiDir, $"{componentName}.md"); var sampleFile = Path.Combine(samplesDir, $"{componentName}.md"); From 40b6e50f0746b3063987c7fb2bddbcc4d992e345 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 16:20:39 +0800 Subject: [PATCH 09/24] =?UTF-8?q?fix(mcp):=20IsUnsafeSlotName=20=E8=A1=A5?= =?UTF-8?q?=E7=9B=98=E7=AC=A6=E5=BD=A2=EF=BC=88":"=EF=BC=89=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=EF=BC=8C=E5=A0=B5=E4=BD=8F=20ResolveDocsRoot=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E9=80=83=E9=80=B8=20(T6=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?R1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Services/ExtractionService.cs:IsUnsafeSlotName 增 `slot.Contains(Path.VolumeSeparatorChar)` 一项。原守卫只查空白/纯点段/"/"/"\",漏掉盘符形——"c:" 经 Normalize 原样放行后,Path.Combine(outputDir, "c:") 因驱动器相对根被 IsPathRooted 判 rooted 而**直接返回 "c:" 不拼接**,Directory.Exists("c:") 在 CWD 位于 C 盘时恒 true,root 逃出 OutputDir 不变量;"c:xxx" 同类 - 守卫为 RunAsync/ResolveDocsRoot/DeleteSlot 共用,一处加固三个面同时收口;既有测试槽名均不含 ":",零破坏 - BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs:新增 Theory 守卫测试 UnsafeVersion_ReturnsNullWithInvalidNotice——version 传 "c:"/".."/"a/b" 时 ResolveDocsRoot 均返回 null 且 notice 为 Invalid 提示(含 available slots 清单);此前安全分支零覆盖 - 验证:TDD 红(仅 "c:" 用例失败,复现审核者实证链)→ 修复后 filter 8/8、全量 43/43 通过;dotnet build 主项目 0 error;仅动守卫与测试两文件,4 条 Minor 台账项未触碰 Co-Authored-By: ZCode (GLM-5.3) --- .../McpServiceVersionTests.cs | 15 +++++++++++++++ Services/ExtractionService.cs | 9 ++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs b/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs index 7bbff37..e0a03ef 100644 --- a/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs +++ b/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs @@ -64,4 +64,19 @@ public void NoPinRefSlot_FallsBackToRootArtifacts() var root = McpService.ResolveDocsRoot(Settings("v10.9.0", o), null, out _); // v10.9.0 槽不存在 Assert.Equal(o, root); } + + [Theory] // T6 修复R1:路径穿越/盘符形 version 一律按非法处理,root 不得逃出 OutputDir + [InlineData("c:")] // 盘符形:Path.Combine(outputDir, "c:") 直接返回 "c:"(驱动器相对根),旧守卫漏放 + [InlineData("..")] // 纯点段 + [InlineData("a/b")] // 含路径分隔符 + public void UnsafeVersion_ReturnsNullWithInvalidNotice(string version) + { + var o = Path.Combine(Path.GetTempPath(), "mcp6-" + Guid.NewGuid().ToString("N")); + MakeSlot(o, "v10.8.1"); + var root = McpService.ResolveDocsRoot(Settings("v10.8.1", o), version, out var notice); + Assert.Null(root); + Assert.NotNull(notice); + Assert.Contains("Invalid", notice, StringComparison.OrdinalIgnoreCase); + Assert.Contains("v10.8.1", notice); // notice 附可用槽清单 + } } diff --git a/Services/ExtractionService.cs b/Services/ExtractionService.cs index cf37687..0ff874c 100644 --- a/Services/ExtractionService.cs +++ b/Services/ExtractionService.cs @@ -218,13 +218,16 @@ public List GetAvailableVersions() } } - /// 拒绝路径穿越形槽名:纯点段("."、".." 等)或含任意路径分隔符("/"、"\")。 - /// T6 起对 McpService 共享(internal):ResolveDocsRoot 命中检查复用同一守卫。 + /// 拒绝路径穿越形槽名:纯点段("."、".." 等)、含任意路径分隔符("/"、"\")或卷分隔符(":")。 + /// T6 起对 McpService 共享(internal):ResolveDocsRoot 命中检查复用同一守卫。 + /// T6 修复R1:补 VolumeSeparatorChar——盘符形("c:"、"c:xxx")被 Path.Combine 视为驱动器相对根直接原样返回, + /// 可令文档根目录逃出 OutputDir 不变量。 internal static bool IsUnsafeSlotName(string slot) => slot.Length == 0 || slot.Trim('.').Length == 0 || slot.Contains('/') - || slot.Contains('\\'); + || slot.Contains('\\') + || slot.Contains(Path.VolumeSeparatorChar); /// 注记②:读构建产物 DLL 的 FileVersion;文件缺失/读取失败返回 null。 private static string? ReadBbVersion(string localPath) From 4e85b56c5f244ecef5f8d255466b61368bd6c0fb Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 16:31:25 +0800 Subject: [PATCH 10/24] =?UTF-8?q?feat(api):=20/api/versions=20=E4=B8=89?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=EF=BC=88pin/extract=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=EF=BC=89=E4=B8=8E=20DI=20=E8=A3=85=E9=85=8D=20(T7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Program.cs 注册 IGitClient/IProcessRunner,并以工厂注册解 ExtractionService 委托式五参构造(Action/Func 非服务类型,DI 无法直接构造;工厂内延迟解析 DocsExtractorService/AppSettingsManager)——补上 GitSyncInvocable/McpService 此前运行时解析必失败的缺口 - 新增端点:GET /api/versions(公开,列槽摘要);POST /api/versions/pin?ref=(鉴权,PinAndExtract 保存 PinRef+启动提取,与 McpService.PinVersion 共享实现);POST /api/versions/extract?ref=&force=(鉴权,force 可选默认 false;ref 省略回退 PinRef,两者皆空→400 绝不 500;StartExtraction 立即返回不 await) - N2:C# 关键字 ref 不能作参数名且 minimal API 不做下划线匹配 → [FromQuery(Name="ref")];认证失败保持上游 Cookie 默认 302→/login,未改全局 401 策略;既有 /api/components* 端点与无鉴权现状零改动;主项目零包改动 - Program.cs 末尾补 public partial class Program {}(WebApplicationFactory 可见性,上游无害) - 测试 VersionsApiTests:WebApplicationFactory 内存宿主三用例(GET 200 公开 / 未认证 302→/login / 登录后 ?ref= 绑定 200 哨兵);测试项目加 Microsoft.AspNetCore.Mvc.Testing 10.0.9(仅测试依赖);与 ExtractionServiceTests 同 Collection 串行(共享 data/config.json 落盘,防并行写互踩) - brief 笔误级最小修正(断言与行为语义不变):补 using System.Net;CreateDefaultClient(HttpClientHandler) 编译不过改等价 CreateClient(ClientOptions){AllowAutoRedirect=false};Windows 路径嵌入 JSON 转义反斜杠;$$→$$$ 原始串(JSON 尾部连续 }});extract 端点可选参数 force 移到必选参数后(CS1737) - 验证:dotnet test 46/46 通过(43 旧+3 新,两轮稳定);dotnet build BootstrapBlazor.McpServer.csproj 0 错误(2 warning 为改动前既有) Co-Authored-By: ZCode (GLM-5.3) --- .../BootstrapBlazor.McpServer.Test.csproj | 1 + .../ExtractionServiceTests.cs | 1 + .../VersionsApiTests.cs | 52 +++++++++++++++++++ Program.cs | 32 ++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 BootstrapBlazor.McpServer.Test/VersionsApiTests.cs diff --git a/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj b/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj index a2aff13..c15e162 100644 --- a/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj +++ b/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj @@ -9,6 +9,7 @@ + diff --git a/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs index 7f29157..3258058 100644 --- a/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs +++ b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs @@ -5,6 +5,7 @@ using LibGit2Sharp; using Microsoft.Extensions.Logging.Abstractions; +[Collection("AppSettingsFile")] // 与 VersionsApiTests 共享 data/config.json 落盘路径,串行避免并行写互踩 public class ExtractionServiceTests { private static (ExtractionService svc, FakeGit git, FakeRunner runner) Make(AppSettingsModel settings) diff --git a/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs b/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs new file mode 100644 index 0000000..9253a0f --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs @@ -0,0 +1,52 @@ +// BootstrapBlazor.McpServer.Test/VersionsApiTests.cs +// REST 端点集成测试:WebApplicationFactory 内存宿主(TestServer,不绑定真实端口、不启动真实服务)。 +// 与 ExtractionServiceTests 同 Collection 串行——两类都读写 {BaseDirectory}/data/config.json +// (AppSettingsManager 的落盘路径固定),xUnit 默认并行跑 Collection,串行化避免互相覆盖导致偶发红。 +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; + +[Collection("AppSettingsFile")] +public class VersionsApiTests +{ + // brief 注:跳过 stdio transport(测试宿主无 stdin,Program.cs 以此变量判定,避免 Kestrel 启动崩溃)。 + // 类型初始化先于任何实例成员访问,静态 ctor 里设环境变量可保证建宿主(首次 CreateClient)前已生效。 + static VersionsApiTests() => Environment.SetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER", "true"); + + private readonly WebApplicationFactory _factory = new(); + + [Fact] + public async Task GetVersions_IsPublic_AndReturns200() + { + var resp = await _factory.CreateClient().GetAsync("/api/versions"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } + + [Fact] // Cookie 认证默认行为 302→/login(保持上游登录 UX,不得改全局 401) + public async Task PostPin_WithoutAuth_RedirectsToLogin() + { + // brief 笔误修正:CreateDefaultClient 只收 DelegatingHandler,HttpClientHandler 不是; + // 等价且正确的写法是 ClientOptions.AllowAutoRedirect = false + using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var resp = await client.PostAsync("/api/versions/pin?ref=v10.8.1", null); + Assert.Equal(HttpStatusCode.Redirect, resp.StatusCode); + Assert.Contains("/login", resp.Headers.Location?.ToString() ?? "", StringComparison.OrdinalIgnoreCase); + } + + [Fact] // N2 哨兵:若 ?ref= 绑定失败则 ref_=null+PinRef 空 → 400;200 即绑定成功 + public async Task PostExtract_WithLogin_BindsRefQuery_Returns200() + { + var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); // WAF 宿主与测试同进程,AppSettingsManager 读同一 BaseDirectory + Directory.CreateDirectory(dataDir); + var outDir = Path.Combine(Path.GetTempPath(), "api7-" + Guid.NewGuid().ToString("N")); + // brief 笔误修正:Windows 临时目录含反斜杠,嵌入 JSON 字符串须转义(JsonNode.Parse 严格校验 \U 等非法转义) + static string J(string p) => p.Replace("\\", "\\\\"); + File.WriteAllText(Path.Combine(dataDir, "config.json"), + // $$$(三个 $):JSON 尾部连续两个 "}" 是内容、不是插值闭括号——$ 数必须大于内容中最大连续括号数 + $$$"""{"Auth":{"AdminUsername":"u1","AdminPassword":"p1"},"GitSync":{"RepositoryUrl":"nowhere","LocalPath":"{{{J(Path.Combine(Path.GetTempPath(), "none"))}}}","OutputDir":"{{{J(outDir)}}}","PinRef":""}}"""); + var client = _factory.CreateClient(); + await client.PostAsync("/api/login?username=u1&password=p1", null); // 签发 Cookie + var resp = await client.PostAsync("/api/versions/extract?ref=v10.8.1&force=false", null); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + // 后台 fire-and-forget 提取任务对 "nowhere" 的失败不影响本断言(StartExtraction 立即返回,异常被 T4 修复R2 捕获落痕) + } +} diff --git a/Program.cs b/Program.cs index 0e66fcb..704836e 100644 --- a/Program.cs +++ b/Program.cs @@ -6,6 +6,7 @@ using Coravel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; // [FromQuery(Name = "ref")] 所在命名空间(N2:ref 是 C# 关键字,minimal API 不做下划线匹配) using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); @@ -18,6 +19,16 @@ builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); +// T7 DI 装配:GitClient/ProcessRunner(ExtractionService 的两依赖,此前未注册,GitSyncInvocable/McpService 运行时解析会失败) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// ExtractionService 为委托式五参构造(Action/Func 非服务类型,DI 无法直接构造)→ 工厂注册: +// 延迟解析 DocsExtractorService/AppSettingsManager,避免构造顺序耦合 +builder.Services.AddSingleton(sp => new ExtractionService( + sp.GetRequiredService(), sp.GetRequiredService(), + (b, o) => sp.GetRequiredService().Extract(b, o), + () => sp.GetRequiredService().LoadSettings(), + sp.GetRequiredService>())); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => @@ -98,6 +109,24 @@ return Results.Text(docs, "text/markdown"); }); +// Version Slots API(spec §6.5;extract 加可选 force)。pin/extract 变更端点要求登录; +// 认证失败保持上游 Cookie 默认 302→/login(不改全局 401 策略) +app.MapGet("/api/versions", (ExtractionService ext) => Results.Ok(ext.GetAvailableVersions())); + +// N2(审核实测):C# 关键字 ref 不能作参数名,而 minimal API 不做 ref_→ref 的下划线匹配 → +// 必须 [FromQuery(Name = "ref")];bool force 必须带默认值,否则省略即 400 +app.MapPost("/api/versions/pin", ([FromQuery(Name = "ref")] string ref_, ExtractionService ext, AppSettingsManager cfg) => +{ + return Results.Ok(ext.PinAndExtract(cfg, ref_)); // 保存 PinRef + StartExtraction 立即返回(与 McpService.PinVersion 共享同一实现) +}).RequireAuthorization(); + +app.MapPost("/api/versions/extract", ([FromQuery(Name = "ref")] string? ref_, ExtractionService ext, AppSettingsManager cfg, bool force = false) => // brief 笔误修正:可选参数 force 须置于必选参数之后(CS1737),仅重排顺序、绑定语义不变 +{ + var slot = VersionSlot.Normalize(ref_) ?? VersionSlot.Normalize(cfg.LoadSettings().PinRef); // ref 省略→钉住版 + if (slot == null) return Results.BadRequest("ref is required when PinRef is not configured."); // 遗产模式裸调 extract:明确 400,绝不 500 + return Results.Ok(ext.StartExtraction(slot, force)); // 立即返回,提取后台进行(不 await RunAsync) +}).RequireAuthorization(); + // Authentication Endpoint for Ajax Login app.MapPost("/api/login", async (HttpContext context, AppSettingsManager config, string username, string password) => { @@ -127,3 +156,6 @@ // Avoid port conflicts, run via Stdio but also spin up Kestrel quietly // In some environments, starting both might cause issues, but builder.Run() operates on console await app.RunAsync(); + +// 供测试项目 WebApplicationFactory 引用(top-level statements 生成的 Program 默认 internal) +public partial class Program { } From ccb2c2ce77580d70039173dae2950e49c6b5a925 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 16:52:37 +0800 Subject: [PATCH 11/24] =?UTF-8?q?feat(ui):=20=E7=AE=A1=E7=90=86=E9=A1=B5?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=A7=BD=E7=AE=A1=E7=90=86=EF=BC=88PinRef/?= =?UTF-8?q?=E6=A7=BD=E5=8D=A1=E7=89=87/=E9=87=8D=E6=8F=90=E5=8F=96/?= =?UTF-8?q?=E5=88=A0=E9=99=A4=EF=BC=89=E4=B8=8E=E5=8F=8C=E8=AF=AD=E6=96=87?= =?UTF-8?q?=E6=A1=88=20(T8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Config.razor:GitSync 配置区新增 PinRef 输入框(BootstrapInput 同款模式,标签经 AppSettingsModel 资源段自动渲染),PlaceHolder 提示留空 = 跟随最新;保存链路复用现 SaveSettings(T1 起 PinRef 已持久化) - Index.razor:新增 @inject ExtractionService Ext / SwalService Swal;ForceSync 的 OnSyncClick 按 PinRef 分派——空 → 原路径 await GitSync.Invoke()(遗产模式零回归,提示串逐字保留);非空 → _syncResult = Ext.StartExtraction(VersionSlot.SlotName(pin), force: true)(B1 红线:UI 进程内直调,禁 HttpClient 出站打自身 REST) - Index.razor:ForceSync 下方新增「版本槽」卡片——刷新按钮 + OnInitialized 预载 GetAvailableVersions() 渲染槽卡片(槽名/BB 版本/commit/提取时间/状态徽标 ready 绿 extracting 黄 failed 红/PINNED 标记);每槽「重新提取」(force: true)与「删除」(Swal 确认弹窗后调 DeleteSlot,钉住槽置灰禁用且服务端二次拒绝);「提取新版本」输入框 + 提交(SlotName 归一化,空值/非法槽名前置校验——fire-and-forget 不回传 RunAsync 拒绝结果) - Locales:zh-CN/en-US 键集完全一致(Index 段新增 16 键 + AppSettingsModel 段 PinRef),PowerShell ConvertFrom-Json 校验通过 - 验证:dotnet build 0 error(3 告警均为存量 NU1903×2/CS8604,与本任务无关);dotnet test 全量 46/46 通过不回归;T7 REST 端点零改动 - 影响面:仅 UI 层与本地化资源,UI 无单测(按计划验证 = 编译 + 测试不回归,运行时表现留 T9 走查/用户实测) Co-Authored-By: ZCode (GLM-5.3) --- Components/Pages/Config.razor | 5 +- Components/Pages/Index.razor | 143 ++++++++++++++++++++++++++++++++-- Locales/en-US.json | 19 ++++- Locales/zh-CN.json | 19 ++++- 4 files changed, 177 insertions(+), 9 deletions(-) diff --git a/Components/Pages/Config.razor b/Components/Pages/Config.razor index d4e2bca..20c94bf 100644 --- a/Components/Pages/Config.razor +++ b/Components/Pages/Config.razor @@ -28,7 +28,10 @@
- +
+ +
+
diff --git a/Components/Pages/Index.razor b/Components/Pages/Index.razor index 85150a8..0831728 100644 --- a/Components/Pages/Index.razor +++ b/Components/Pages/Index.razor @@ -5,6 +5,8 @@ @attribute [Authorize] @inject GitSyncInvocable GitSync @inject AppSettingsManager SettingsManager +@inject ExtractionService Ext +@inject SwalService Swal @inject IJSRuntime JSRuntime @inject IStringLocalizer Localizer @@ -49,22 +51,151 @@
+
+
+ + +
@Localizer["VersionSlots"]
+
+ +

@Localizer["VersionSlotsDesc"]

+
+
+ + + } + + + +
+
+ +
+
+
+
+ + @if (!string.IsNullOrEmpty(_slotResult)) + { + + } + + + + + @code { private string _syncResult = ""; + private string _slotResult = ""; + private string _newRef = ""; + private List _slots = []; private AppSettingsModel _settings = new(); protected override void OnInitialized() { _settings = SettingsManager.LoadSettings(); + _slots = Ext.GetAvailableVersions(); } private async Task OnSyncClick() { - _syncResult = "Syncing in background... Check the Docker logs for detailed extraction progress."; - await InvokeAsync(StateHasChanged); - - await GitSync.Invoke(); - - _syncResult = "Sync Finished! RAG datasets have been updated."; + // 按模式分派(遗产模式零回归):PinRef 空 → 原路径原语义(T4 后 Invoke 内部空→遗产分支); + // 非空 → 进程内直调 ExtractionService(B1 红线:UI 一律进程内直调,禁止出站 HttpClient 打自身 REST) + var pin = SettingsManager.LoadSettings().PinRef; + if (string.IsNullOrWhiteSpace(pin)) + { + _syncResult = "Syncing in background... Check the Docker logs for detailed extraction progress."; + await InvokeAsync(StateHasChanged); + + await GitSync.Invoke(); + + _syncResult = "Sync Finished! RAG datasets have been updated."; + } + else + { + _syncResult = Ext.StartExtraction(VersionSlot.SlotName(pin), force: true); + } + } + + private void OnRefreshSlots() + { + _slots = Ext.GetAvailableVersions(); } + + private void OnReExtract(string slotName) + { + _slotResult = Ext.StartExtraction(slotName, force: true); + OnRefreshSlots(); + } + + private async Task OnDeleteSlot(string slotName) + { + var confirmed = await Swal.ShowModal(new SwalOption + { + Title = Localizer["ConfirmDelete"], + BodyTemplate = builder => builder.AddContent(0, string.Format(Localizer["ConfirmDeleteDesc"], slotName)), + Category = SwalCategory.Question + }); + if (!confirmed) return; + _slotResult = Ext.DeleteSlot(slotName); + OnRefreshSlots(); + } + + private void OnExtractNew() + { + if (string.IsNullOrWhiteSpace(_newRef)) + { + _slotResult = Localizer["NewRefRequired"]; + return; + } + var slot = VersionSlot.SlotName(_newRef); + // 槽名守卫前置到 UI:StartExtraction 为 fire-and-forget,RunAsync 内的拒绝结果不回传调用方 + if (ExtractionService.IsUnsafeSlotName(slot)) + { + _slotResult = string.Format(Localizer["InvalidSlotName"], slot); + return; + } + _slotResult = Ext.StartExtraction(slot, force: true); + OnRefreshSlots(); + } + + private static string GetStatusBadgeClass(string status) => status switch + { + SlotStatus.Ready => "bg-success", + SlotStatus.Extracting => "bg-warning text-dark", + SlotStatus.Failed => "bg-danger", + _ => "bg-secondary" + }; } diff --git a/Locales/en-US.json b/Locales/en-US.json index a985824..e0f9f9d 100644 --- a/Locales/en-US.json +++ b/Locales/en-US.json @@ -15,7 +15,23 @@ "ManualSyncDesc": "Click to forcefully trigger Git pull and document extraction process.", "Target": "Target", "LocalPath": "Local Path", - "CronSchedule": "Cron Schedule" + "CronSchedule": "Cron Schedule", + "VersionSlots": "Version Slots", + "VersionSlotsDesc": "Extraction status of all version slots. Re-extract and delete take effect immediately; extraction runs in the background — click refresh to see progress.", + "RefreshSlots": "Refresh", + "NoSlots": "No version slots yet. Set PinRef or submit a new version below to start extraction.", + "SlotPinned": "PINNED", + "SlotCommit": "Commit", + "SlotBBVersion": "BB Version", + "SlotExtractedAt": "Extracted At", + "ReExtract": "Re-extract", + "DeleteSlot": "Delete", + "ConfirmDelete": "Confirm Delete", + "ConfirmDeleteDesc": "Are you sure you want to delete slot '{0}'? This cannot be undone.", + "ExtractNewVersion": "Extract New Version", + "NewRefPlaceHolder": "v10.8.1 / main", + "NewRefRequired": "Please enter a version or branch name.", + "InvalidSlotName": "Invalid slot name '{0}'." }, "BootstrapBlazor.McpServer.Components.Pages.Config": { "PageTitle": "Configuration - MCP Server", @@ -33,6 +49,7 @@ "CronSchedule": "Sync Cron Schedule", "LocalPath": "Local Clone Path", "OutputDir": "Documentation Output Directory", + "PinRef": "Pinned Version", "AiBaseUrl": "AI API Base URL", "AiApiKey": "AI API Key", "AiModel": "AI Model Name", diff --git a/Locales/zh-CN.json b/Locales/zh-CN.json index d5f17e9..6e97a13 100644 --- a/Locales/zh-CN.json +++ b/Locales/zh-CN.json @@ -15,7 +15,23 @@ "ManualSyncDesc": "点击立刻强制触发 Git 代码仓库拉取与 Markdown 文档构建解析操作。", "Target": "监听目标", "LocalPath": "本地路径", - "CronSchedule": "Cron 表达式" + "CronSchedule": "Cron 表达式", + "VersionSlots": "版本槽", + "VersionSlotsDesc": "各版本槽的提取状态。重新提取与删除即时生效;提取在后台进行,点击刷新查看进度。", + "RefreshSlots": "刷新", + "NoSlots": "暂无版本槽。配置 PinRef 或在下方提交新版本以开始提取。", + "SlotPinned": "已钉住", + "SlotCommit": "Commit", + "SlotBBVersion": "BB 版本", + "SlotExtractedAt": "提取时间", + "ReExtract": "重新提取", + "DeleteSlot": "删除", + "ConfirmDelete": "确认删除", + "ConfirmDeleteDesc": "确定要删除版本槽「{0}」吗?该操作不可撤销。", + "ExtractNewVersion": "提取新版本", + "NewRefPlaceHolder": "v10.8.1 / main", + "NewRefRequired": "请输入版本号或分支名。", + "InvalidSlotName": "非法的槽名「{0}」。" }, "BootstrapBlazor.McpServer.Components.Pages.Config": { "PageTitle": "全局配置 - MCP Server", @@ -33,6 +49,7 @@ "CronSchedule": "定时同步 Cron 表达式", "LocalPath": "仓库本地克隆路径", "OutputDir": "文档输出目录", + "PinRef": "钉住版本", "AiBaseUrl": "AI 接口 Base URL", "AiApiKey": "AI 接口 API Key", "AiModel": "AI 模型名称", From 5ee848ce2122bfb53c911fe8f7164847448666fd Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 17:12:43 +0800 Subject: [PATCH 12/24] =?UTF-8?q?test(pinning):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E6=80=A7=E7=A6=BB=E7=BA=BF=E9=9B=86=E6=88=90?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88=E5=A4=B9=E5=85=B7=E5=8F=8C=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=9A=E4=B8=8D=E6=BC=8F/=E4=B8=8D=E4=B8=B2?= =?UTF-8?q?=E7=89=88=E6=9C=AC/=E4=B8=8D=E4=B8=B2=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=EF=BC=89=20(T9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Fixtures/BBFake.V1、BBFake.V2 两夹具假 BootstrapBlazor 程序集(AssemblyName=BootstrapBlazor,Version 1.0.0/2.0.0,net10.0 + FrameworkReference Microsoft.AspNetCore.App 免 NuGet 离线可编;不入 sln,仅测试 setup 现场 dotnet build) - V1 仅 ComponentA;V2 同名重编一份 + ComponentB(ComponentBase 派生 + [Parameter] string? Title,命名空间 BootstrapBlazor.Fakes) - 测试 csproj:Compile Remove Fixtures/**/*.cs(防 SDK 通编进测试程序集)+ None 整目录复制到输出;brief 笔误修正——.csproj 受 SDK DefaultItemExcludes(**/*.*proj)排除须显式包含,否则 setup 找不到夹具工程 - VersionPinningIntegrationTests 两测试端到端压真实链路(现场 build 夹具 → LayOutBbRepo 摆 repo 结构 → T5 MLC 化的 DocsExtractorService.Extract → McpService.ResolveDocsRoot);两测经 Lazy 复用同一批真实提取产物(xunit 同类串行) - 断言锁定属性:v1 槽含 ComponentA 不含 ComponentB(不串版本)、两槽均无 Table/Divider(不串宿主 10.3.2,坑①哨兵)、v2 双组件齐(不漏)+ ComponentA.md 参数表来自本夹具反射;ResolveDocsRoot 对真实槽四路解析(缺省→钉住槽/2.0.0/v2.0.0→v2 槽/7.0.0→null+清单) - docs.json 夹具内容为 {"src":{}}(非 {}):Extract 无 try/catch 地 GetProperty("src"),{} 必抛 KeyNotFoundException(第三轮 plan 审核实证);BuildFixture 重定向 stderr 附失败诊断 - 验证:RED(夹具缺失时 BuildFixture exit 1,两测均红)→ GREEN(本类 2/2);全量 dotnet test 48/48 passed(46 既有 + 2 新增);主项目 dotnet build 0 error - 影响面:纯测试新增,零包改动(FrameworkReference 非 PackageReference),既有 46 用例零改动 Co-Authored-By: ZCode (GLM-5.3) --- .../BootstrapBlazor.McpServer.Test.csproj | 8 ++ .../Fixtures/BBFake.V1/BBFake.V1.csproj | 14 +++ .../Fixtures/BBFake.V1/ComponentA.cs | 10 ++ .../Fixtures/BBFake.V2/BBFake.V2.csproj | 14 +++ .../Fixtures/BBFake.V2/ComponentA.cs | 10 ++ .../Fixtures/BBFake.V2/ComponentB.cs | 10 ++ .../VersionPinningIntegrationTests.cs | 102 ++++++++++++++++++ 7 files changed, 168 insertions(+) create mode 100644 BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/BBFake.V1.csproj create mode 100644 BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/ComponentA.cs create mode 100644 BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/BBFake.V2.csproj create mode 100644 BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentA.cs create mode 100644 BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentB.cs create mode 100644 BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs diff --git a/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj b/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj index c15e162..ba75962 100644 --- a/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj +++ b/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj @@ -23,4 +23,12 @@
+ + + + + + + \ No newline at end of file diff --git a/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/BBFake.V1.csproj b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/BBFake.V1.csproj new file mode 100644 index 0000000..8892bc0 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/BBFake.V1.csproj @@ -0,0 +1,14 @@ + + + + BootstrapBlazor + 1.0.0 + net10.0 + enable + + + + + diff --git a/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/ComponentA.cs b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/ComponentA.cs new file mode 100644 index 0000000..47aaed0 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/ComponentA.cs @@ -0,0 +1,10 @@ +// T9 夹具 v1.0.0 组件:ComponentBase 派生 + [Parameter] string? Title(命名空间 BootstrapBlazor.Fakes) +using Microsoft.AspNetCore.Components; + +namespace BootstrapBlazor.Fakes; + +public class ComponentA : ComponentBase +{ + [Parameter] + public string? Title { get; set; } +} diff --git a/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/BBFake.V2.csproj b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/BBFake.V2.csproj new file mode 100644 index 0000000..b6fb9ec --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/BBFake.V2.csproj @@ -0,0 +1,14 @@ + + + + BootstrapBlazor + 2.0.0 + net10.0 + enable + + + + + diff --git a/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentA.cs b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentA.cs new file mode 100644 index 0000000..4059647 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentA.cs @@ -0,0 +1,10 @@ +// T9 夹具 v2.0.0 组件:与 v1 同名重编一份(同签名,程序集版本不同——验证提取口径跟随目标 DLL 而非宿主) +using Microsoft.AspNetCore.Components; + +namespace BootstrapBlazor.Fakes; + +public class ComponentA : ComponentBase +{ + [Parameter] + public string? Title { get; set; } +} diff --git a/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentB.cs b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentB.cs new file mode 100644 index 0000000..1d87608 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentB.cs @@ -0,0 +1,10 @@ +// T9 夹具 v2.0.0 独有组件:v1 槽绝不应出现(不串版本断言的靶子) +using Microsoft.AspNetCore.Components; + +namespace BootstrapBlazor.Fakes; + +public class ComponentB : ComponentBase +{ + [Parameter] + public string? Title { get; set; } +} diff --git a/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs b/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs new file mode 100644 index 0000000..ff66f22 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs @@ -0,0 +1,102 @@ +// BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs +// T9 版本固定性端到端集成测试(离线双版本夹具)。锁定属性:某版本的提取产物与该版本目标程序集的组件集精确一致—— +// 不漏(确实存在的都在)、不串版本(更新版本的组件不出现)、不串宿主(坑①哨兵:宿主 10.3.2 的真实组件绝不出现)。 +// 链路全真实:现场 dotnet build 夹具(FrameworkReference 免 NuGet)→ LayOutBbRepo 摆 repo 结构 → +// DocsExtractorService.Extract(T5 MLC 化,正是本测试要压的链路)→ McpService.ResolveDocsRoot(internal,InternalsVisibleTo)。 +// 第二测用 AppSettingsModel 直构、不经 {BaseDirectory}/data/config.json → 无需 [Collection("AppSettingsFile")]。 +using System.Diagnostics; +using BootstrapBlazor.McpServer.Services; +using Microsoft.Extensions.Logging.Abstractions; + +public class VersionPinningIntegrationTests +{ + /// 两测试复用同一批真实提取产物(xunit 同类内串行执行;Lazy 使夹具编译+提取只发生一次)。 + private static readonly Lazy<(string Output, string V1Slot, string V2Slot)> Slots = new(PrepareSlots); + + private static (string Output, string V1Slot, string V2Slot) PrepareSlots() + { + var v1 = LayOutBbRepo(BuildFixture("BBFake.V1"), new[] { "ComponentA" }); + var v2 = LayOutBbRepo(BuildFixture("BBFake.V2"), new[] { "ComponentA", "ComponentB" }); + var output = Path.Combine(Path.GetTempPath(), "out-" + Guid.NewGuid().ToString("N")); + var extractor = new DocsExtractorService(NullLogger.Instance); + + var v1Slot = Path.Combine(output, "v1.0.0"); + var v2Slot = Path.Combine(output, "v2.0.0"); + extractor.Extract(v1, v1Slot); + extractor.Extract(v2, v2Slot); + return (output, v1Slot, v2Slot); + } + + /// 现场编译夹具假 BootstrapBlazor 程序集(AssemblyName=BootstrapBlazor,仅 FrameworkReference,离线零 NuGet)。 + private static string BuildFixture(string fixtureName) + { + var src = Path.Combine(AppContext.BaseDirectory, "Fixtures", fixtureName); + var outDir = Path.Combine(Path.GetTempPath(), "fx-" + Guid.NewGuid().ToString("N")); + var psi = new ProcessStartInfo("dotnet", $"build \"{src}\" -c Release -o \"{outDir}\"") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true // brief 增强:仅重定向 stderr(stdout 未重定向,无管道死锁),失败时附可诊断信息 + }; + using var p = Process.Start(psi)!; + var stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(); + Assert.True(p.ExitCode == 0, $"fixture dotnet build failed (exit {p.ExitCode}): {stderr}"); + return outDir; + } + + /// 摆成提取器期望的 basePath(repo 检出且构建完成后)结构。 + private static string LayOutBbRepo(string dllDir, string[] sampleNames) + { + var baseDir = Path.Combine(Path.GetTempPath(), "bb-" + Guid.NewGuid().ToString("N")); + var bin = Path.Combine(baseDir, "src/BootstrapBlazor/bin/Release/net10.0"); + Directory.CreateDirectory(bin); // 递归创建全部父目录(src/BootstrapBlazor 随之就位,brief 后置的显式 Create 冗余故省去) + foreach (var f in Directory.GetFiles(dllDir)) File.Copy(f, Path.Combine(bin, Path.GetFileName(f))); + // XML 成员名须过 Extract 的硬过滤 StartsWith("P:BootstrapBlazor.Components."),故前缀保持 Components.Fakes + // (夹具真实命名空间是 BootstrapBlazor.Fakes,summary key 对不上仅致说明列回退 "-",不影响文件名断言) + File.WriteAllText(Path.Combine(baseDir, "src/BootstrapBlazor/BootstrapBlazor.xml"), + "" + + string.Join("", sampleNames.Select(n => $"fake {n}")) + + ""); + Directory.CreateDirectory(Path.Combine(baseDir, "src/BootstrapBlazor.Server/Components/Samples")); + Directory.CreateDirectory(Path.Combine(baseDir, "src/BootstrapBlazor.Server/Locales")); + File.WriteAllText(Path.Combine(baseDir, "src/BootstrapBlazor.Server/Locales/zh-CN.json"), "{}"); + // 注意:{"src":{}} 而非 {}——Extract 无 try/catch 地 GetProperty("src"),{} 必抛 KeyNotFoundException(第三轮 plan 审核实证) + File.WriteAllText(Path.Combine(baseDir, "src/BootstrapBlazor.Server/docs.json"), """{"src":{}}"""); + return baseDir; + } + + [Fact] + public void Extraction_Reflects_Exactly_The_TargetAssembly_Version() + { + var (_, v1Slot, v2Slot) = Slots.Value; + + var v1Api = Directory.GetFiles(Path.Combine(v1Slot, "API"), "*.md").Select(Path.GetFileNameWithoutExtension).ToList(); + var v2Api = Directory.GetFiles(Path.Combine(v2Slot, "API"), "*.md").Select(Path.GetFileNameWithoutExtension).ToList(); + + Assert.Contains("ComponentA", v1Api); // 不漏 + Assert.DoesNotContain("ComponentB", v1Api); // 不串版本(更新版本的组件不出现) + Assert.Contains("ComponentA", v2Api); + Assert.Contains("ComponentB", v2Api); // v2 双组件齐 + Assert.DoesNotContain("Table", v1Api); + Assert.DoesNotContain("Divider", v1Api); // 不串宿主 10.3.2(坑①哨兵) + // md 参数表确实来自本夹具的程序集反射([Parameter] Title 行),非陈旧/空文件 + Assert.Contains("| Title |", File.ReadAllText(Path.Combine(v1Slot, "API", "ComponentA.md"))); + // 本夹具 bin 无任何 NuGet 依赖 → 同时实证 MlcAssemblyLoader 三部分 resolver(目标目录 + AspNetCore.App + 运行时目录;缺框架目录时本测试必红) + } + + [Fact] + public void ResolveDocsRoot_Routes_RealSlots_EndToEnd() + { + var (output, v1Slot, v2Slot) = Slots.Value; // 产物来自真实提取链路(非 T6 单测的手摆目录) + var settings = new AppSettingsModel { PinRef = "v1.0.0", OutputDir = output }; // 直构,不经文件 + + Assert.Equal(v1Slot, McpService.ResolveDocsRoot(settings, null, out _)); // version 缺省 → 钉住 v1.0.0 槽 + Assert.Equal(v2Slot, McpService.ResolveDocsRoot(settings, "2.0.0", out _)); // 裸版本号归一化命中 + Assert.Equal(v2Slot, McpService.ResolveDocsRoot(settings, "v2.0.0", out _)); // v 前缀直命中 + Assert.Null(McpService.ResolveDocsRoot(settings, "7.0.0", out var notice)); // 未命中 → null + 可用槽清单 + Assert.NotNull(notice); + Assert.Contains("v1.0.0", notice); + Assert.Contains("v2.0.0", notice); + } +} From c7daef2b927499f9c5584e7ac10c4eddf5202aa6 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 17:28:20 +0800 Subject: [PATCH 13/24] =?UTF-8?q?test(pinning):=20=E5=9D=91=E2=91=A0?= =?UTF-8?q?=E5=93=A8=E5=85=B5=E7=A1=AE=E5=AE=9A=E6=80=A7=E6=AD=A6=E8=A3=85?= =?UTF-8?q?=E2=80=94=E2=80=94PrepareSlots=20=E6=98=BE=E5=BC=8F=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E5=AE=BF=E4=B8=BB=20BB=20=E8=BF=9B=E7=A8=8B=E5=86=85?= =?UTF-8?q?=E5=89=8D=E6=8F=90=20(T9=20=E4=BF=AE=E5=A4=8DR1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 二审 Important 修复:原哨兵(DoesNotContain Table/Divider)触发依赖环境状态——本测试链路(Extract 只触框架类型、Program.Main 不执行)从不加载宿主 BB,隔离跑本类时若 T5 被回退为旧 in-proc 代码(GetAssemblies.FirstOrDefault ?? LoadFrom(夹具 DLL)),GetAssemblies 落空 → 加载夹具 DLL → 产物仍正确 → 假阴性;全量跑亦依赖 WAF 时序竞态,检测概率化 - 修法(审核者建议):PrepareSlots 在 Extract 前 Assembly.LoadFrom(AppContext.BaseDirectory/BootstrapBlazor.dll)(测试宿主 bin 的宿主包 10.3.2 副本,已核实存在),显式注入生产恒真前提(Program.cs:45 AddBootstrapBlazor() 使真实 MCP 进程启动即加载宿主 BB)——哨兵随即确定性武装 - 对现有绿路径零影响(MLC 分支不查询进程内状态);绿测含金量提升:隔离跑本类即活体证明 MLC 压过进程内宿主副本 - 验证:本类 filter 2/2 passed(注入进程内宿主副本后 MLC 仍胜出,产物仅夹具组件);全量 dotnet test 48/48 passed;仅动测试文件一行 + using + 注释,5 条 Minor 台账项未动 Co-Authored-By: ZCode (GLM-5.3) --- .../VersionPinningIntegrationTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs b/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs index ff66f22..7f904fb 100644 --- a/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs +++ b/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs @@ -5,6 +5,7 @@ // DocsExtractorService.Extract(T5 MLC 化,正是本测试要压的链路)→ McpService.ResolveDocsRoot(internal,InternalsVisibleTo)。 // 第二测用 AppSettingsModel 直构、不经 {BaseDirectory}/data/config.json → 无需 [Collection("AppSettingsFile")]。 using System.Diagnostics; +using System.Reflection; using BootstrapBlazor.McpServer.Services; using Microsoft.Extensions.Logging.Abstractions; @@ -15,6 +16,13 @@ public class VersionPinningIntegrationTests private static (string Output, string V1Slot, string V2Slot) PrepareSlots() { + // 哨兵确定性武装(T9 修复R1):生产恒真前提是宿主 BB 已在进程内(Program.cs AddBootstrapBlazor()), + // 而本测试链路(Extract 只触框架类型、Program.Main 不执行)从不加载宿主 BB——若 T5 被回退为旧 + // in-proc 代码(GetAssemblies.FirstOrDefault ?? LoadFrom(夹具 DLL)),隔离跑本类会假阴性。 + // 此处显式 LoadFrom 测试宿主 bin 的宿主包副本,注入该前提:绿路径零影响(MLC 分支不查进程内), + // 且绿测即活体证明 MLC 压过进程内宿主副本;T5 回退时哨兵必红(宿主 Table/Divider 串入产物)。 + Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BootstrapBlazor.dll")); + var v1 = LayOutBbRepo(BuildFixture("BBFake.V1"), new[] { "ComponentA" }); var v2 = LayOutBbRepo(BuildFixture("BBFake.V2"), new[] { "ComponentA", "ComponentB" }); var output = Path.Combine(Path.GetTempPath(), "out-" + Guid.NewGuid().ToString("N")); From cd479fbcd37e7354600d9babcdbceb9d036c7a21 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 17:34:12 +0800 Subject: [PATCH 14/24] =?UTF-8?q?docs(readme):=20=E5=A4=9A=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E9=92=89=E4=BD=8F=E5=8A=9F=E8=83=BD=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=20(T10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md / README.zh-CN.md 各新增「Version Pinning / 多版本钉住」一节: PinRef 配置示例(data/config.json 的 GitSync 节 + Config 页热改)、 三个新 MCP 工具(GetAvailableVersions/PinVersion/ExtractVersion)与 三个新 REST 端点(GET /api/versions、POST pin、POST extract)速查表、 既有四工具可选 version 参数说明(归一化:10.8.1/10.8.1.0/v10.8.1 等价)、 「建议 MCP 客户端传入当前项目实际引用的 BootstrapBlazor 版本」提示、 遗产模式说明(PinRef 空 = 上游原行为,零迁移)。 - 验收剧本静态走查(spec §6.7 四条 + 遗产两处 + 互斥)逐条链路确认,结论: ① 日常查询:GetAvailableVersions(McpService.cs L162)→ GetComponentDocs(L100) → ResolveDocsRoot(L206):version 经 VersionSlot.Normalize(VersionSlot.cs L16-25, v 前缀可选/四段截三段)→ 槽命中 L220 / 钉住缺省 L233 / 根目录回退 L238,链路完整; ② 切钉住:PinVersion(McpService.cs L180)→ PinAndExtract(ExtractionService.cs L169, 持久化 PinRef 后 StartExtraction)→ 立即返回 "Extraction started … Poll GetAvailableVersions"(L147);进度经 manifest 状态机(extracting L107 → failed L114 / ready L123)由 GetAvailableVersions(L192)可查; ③ 对照临时查:ExtractVersion(McpService.cs L196)→ StartExtraction(force:true), 调用链上无任何 PinRef 写操作,建槽不动钉住; ④ 管理页兜底:Index.razor 槽列表 L64-96、重提取 L157-161、删除 L163-174 (ExtractionService.DeleteSlot L184 拒删钉住槽)、提取新版本 L176-192; Config.razor PinRef 输入 L31-33 经 SaveSettings 持久化(AppSettingsManager.cs L75); ⑤ 遗产模式两处:GitSyncInvocable.cs L31-39 按 PinRef 分派(空 → L41 起上游 逐字保留的 clone-or-pull 遗产分支);ResolveDocsRoot L223-227 忽略 version 并附 "(legacy mode…)" notice; ⑥ 互斥:SemaphoreSlim(1,1)(ExtractionService.cs L34),RunAsync L54-55 占用时立即返回 "Extraction already in progress",不排队。 - 验证结论:dotnet build BootstrapBlazor.McpServer.csproj -c Release 通过(0 Error, 3 条既有警告:NU1903 上游传递依赖 ×2 + MlcAssemblyLoader CS8604 ×1,均为 T5 前后 既有状态、非本次引入);dotnet test 全绿 48/48(Failed 0 / Skipped 0)。 - 影响面:纯文档改动,零代码、零包变更;走查未发现任何缺口,无需返工。 Co-Authored-By: ZCode (GLM-5.3) --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ README.zh-CN.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/README.md b/README.md index 447540d..c4c0a6d 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,61 @@ For non-MCP integrations (e.g., Dify, FastGPT): --- +## 📌 Version Pinning + +Pin the documentation to the BootstrapBlazor version your project actually references. Extracted docs live in per-ref **version slots** (`OutputDir/{slot}/`, e.g. `v10.8.1/`, `main/`, each with a `manifest.json`), so multiple versions co-exist and can be queried side by side. + +### Enable via `PinRef` + +Set `GitSync.PinRef` in `data/config.json` (or edit it on the admin **Config** page at runtime): + +```json +{ + "GitSync": { + "RepositoryUrl": "https://gitee.com/LongbowEnterprise/BootstrapBlazor.git", + "CronSchedule": "0 3 * * *", + "LocalPath": "/app/data/BootstrapBlazorRepo", + "OutputDir": "/app/data/OutputRAG", + "PinRef": "v10.8.1" + } +} +``` + +`PinRef` accepts a tag (`v10.8.1`) or a branch (`main`). When non-empty, the nightly sync and **ForceSync** extract into the pinned slot (instead of pulling the default branch into the output root), and the pinned slot becomes the **default** documentation source for all queries. Switching the pin never moves or deletes other slots. + +### New MCP tools + +| Tool | Description | +|------|-------------| +| `GetAvailableVersions` | List extracted version slots (slot name, BB version, commit, status, pinned flag) | +| `PinVersion` | *(state-changing)* Set `PinRef` and trigger a background extraction; returns immediately | +| `ExtractVersion` | *(state-changing)* Extract any ref into its own slot **without** changing `PinRef` | + +`PinVersion` / `ExtractVersion` are fire-and-forget: they reply immediately with "Extraction started … Poll GetAvailableVersions for progress"; poll `GetAvailableVersions` until the slot status is `ready`. Extractions are serialized by a single lock — a request arriving while one is running returns "Extraction already in progress." + +### New REST endpoints + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| `GET` | `/api/versions` | public | Same data as `GetAvailableVersions` | +| `POST` | `/api/versions/pin?ref={ref}` | login | Same as `PinVersion` | +| `POST` | `/api/versions/extract?ref={ref}&force={bool}` | login | Same as `ExtractVersion`; `ref` may be omitted to re-extract the pinned version | + +### Optional `version` parameter on existing tools + +The four existing tools (`GetComponentList`, `SearchComponentKeyword`, `GetComponentDocs`, `AskComponentExpert`) accept an optional `version` argument: + +- **Omitted** → the pinned slot (falls back to the output root while the pinned slot has not been extracted yet). +- **Provided** → resolved to that slot. Version strings are normalized — case-insensitive, optional `v` prefix, 4-part versions truncated to 3 parts — so `10.8.1`, `10.8.1.0` and `v10.8.1` all resolve to the same slot. An unknown version returns a hint listing the available slots (with a `ExtractVersion` suggestion) instead of an error. + +> **Tip for MCP clients:** pass the BootstrapBlazor version your project actually references (detect it from the `PackageReference` in your `.csproj`, or the file version of `BootstrapBlazor.dll` in your `bin` output). This keeps answers aligned with your code; the pinned default is only a fallback for when the version is unknown. + +### Legacy mode (zero migration) + +Leave `PinRef` empty/absent and the server behaves exactly as before: clone-or-pull of the default branch, extraction into the output root, nightly cron unchanged. In legacy mode the `version` parameter is ignored and query replies carry a "(legacy mode: no PinRef configured)" note. Existing deployments need no config change. + +--- + ## 🖥️ Admin Dashboard Navigate to `http://localhost:5251` to access the Blazor admin UI. Log in with the credentials configured in `appsettings.json` (default: see your settings). diff --git a/README.zh-CN.md b/README.zh-CN.md index 86335a2..dc42e5c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -167,6 +167,61 @@ docker run -d \ --- +## 📌 多版本钉住(Version Pinning) + +将文档口径钉在你项目实际引用的 BootstrapBlazor 版本上。提取产物按 ref 组织为**版本槽**(`OutputDir/{槽名}/`,如 `v10.8.1/`、`main/`,每个槽附 `manifest.json`),多版本并存、可随时对照查询。 + +### 通过 `PinRef` 开启 + +在 `data/config.json` 中设置 `GitSync.PinRef`(也可在管理页 **Config** 页运行时修改): + +```json +{ + "GitSync": { + "RepositoryUrl": "https://gitee.com/LongbowEnterprise/BootstrapBlazor.git", + "CronSchedule": "0 3 * * *", + "LocalPath": "/app/data/BootstrapBlazorRepo", + "OutputDir": "/app/data/OutputRAG", + "PinRef": "v10.8.1" + } +} +``` + +`PinRef` 可填 tag(`v10.8.1`)或分支(`main`)。非空时:每夜同步与 **ForceSync** 均提取进钉住槽(不再拉默认分支到输出根目录),且钉住槽成为所有查询的**缺省**文档源。切换钉住不会移动或删除任何其他槽。 + +### 新增 MCP 工具 + +| 工具名 | 描述 | +|--------|------| +| `GetAvailableVersions` | 列出已提取的版本槽(槽名 / BB 版本 / commit / 状态 / 是否钉住) | +| `PinVersion` | 【状态变更操作】写入 `PinRef` 并触发后台提取,立即返回 | +| `ExtractVersion` | 【状态变更操作】为任意 ref 建独立槽,**不改动** `PinRef` | + +`PinVersion` / `ExtractVersion` 均为 fire-and-forget:立即返回「Extraction started … Poll GetAvailableVersions for progress」,轮询 `GetAvailableVersions` 至槽状态为 `ready` 即可。提取由单锁互斥串行——已有提取进行中时新请求返回「Extraction already in progress」。 + +### 新增 REST 端点 + +| 方法 | 接口地址 | 鉴权 | 说明 | +|------|----------|------|------| +| `GET` | `/api/versions` | 公开 | 与 `GetAvailableVersions` 同数据 | +| `POST` | `/api/versions/pin?ref={ref}` | 需登录 | 与 `PinVersion` 相同 | +| `POST` | `/api/versions/extract?ref={ref}&force={bool}` | 需登录 | 与 `ExtractVersion` 相同;`ref` 可省略表示重提取钉住版 | + +### 既有工具的可选 `version` 参数 + +四个既有工具(`GetComponentList`、`SearchComponentKeyword`、`GetComponentDocs`、`AskComponentExpert`)均接受可选 `version` 参数: + +- **不传** → 钉住槽(钉住槽尚未提取完成时回退输出根目录产物)。 +- **传** → 解析到对应槽。版本串会做归一化——大小写不敏感、`v` 前缀可选、四段版本号截断为三段——`10.8.1`、`10.8.1.0`、`v10.8.1` 等价命中同一槽。版本未命中时返回附可用槽清单的提示文案(并建议用 `ExtractVersion` 先提取),不抛错。 + +> **给 MCP 客户端的建议:** 请传入当前项目实际引用的 BootstrapBlazor 版本(从 `.csproj` 的 `PackageReference` 探测,或读 `bin` 产物中 `BootstrapBlazor.dll` 的文件版本),使回答与你的代码版本对齐;钉住缺省仅作为版本未知时的兜底。 + +### 遗产模式(零迁移) + +`PinRef` 留空/缺失时,服务行为与旧版完全一致:默认分支 clone-or-pull、提取到输出根目录、每夜 cron 不变。遗产模式下 `version` 参数被忽略,查询回复附带「(legacy mode: no PinRef configured)」注记。既有部署零配置改动即可平滑升级。 + +--- + ## 🖥️ 管理后台 访问 `http://localhost:5251` 进入 Blazor 管理界面,使用 `appsettings.json` 中配置的账号密码登录。 From d0f7a8e251d5e2ee5ce0296826a1c1e787f690de Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 18:28:03 +0800 Subject: [PATCH 15/24] =?UTF-8?q?fix(mcp):=20GetComponentList=20=E5=8F=AF?= =?UTF-8?q?=E7=A9=BA=E5=8F=82=E6=95=B0=E4=BF=AE=E5=A4=8D=E7=A9=BA=E5=8F=82?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=85=BC=E5=AE=B9=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=A7=BD=E6=B8=85=E5=8D=95=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GetComponentList 参数改为 GetComponentListArgs? args = null(Services/McpService.cs:38):可选参数不进 AIFunctionFactory 生成的 schema required,旧客户端空参(arguments:{})绑定为 null 而非抛 ArgumentException;方法体改用 args?.Version(终审 Imp-1,wire-schema 兼容) - ListSlots 加 manifest.json 存在性过滤(Services/McpService.cs:246):目录含 manifest 才算槽,与 GetAvailableVersions 口径统一;迁移期遗产根产物 API/Samples 不再列入 notice 的可用槽清单误导 LLM(终审 Imp-3) - GetAvailableVersions 空输出返回 "(no versions extracted yet)"(legacy 附注保留);[PINNED] 改条件拼接,未钉住行不再有尾部 " | " 悬挂分隔符(终审顺手项3) - PinVersion 移除入口 SlotName 纯校验调用、收敛为直调 PinAndExtract:为 Imp-2b 的 MCP 侧入口,校验前置随下一提交(pin 校验链)落地 - 测试适配:McpServiceVersionTests.MakeSlot 补写 manifest 对齐新槽口径,新增 NoticeListing_ExcludesLegacyDirs_WithoutManifest;VersionPinningIntegrationTests 夹具(直调 Extract 绕过流水线)补 SaveManifest 还原真实槽不变量 - 验证:dotnet test 全量 65 用例通过 / 0 失败;dotnet build BootstrapBlazor.McpServer.csproj 0 error(既有 3 警告为上游 NU1903/CS8604,非本次引入) - 影响面:REST /api/components 显式传 new GetComponentListArgs(),行为不变;空参兼容仅放宽不收紧,嵌套 {args:{version}} 形态保留(绑定层断言随下一提交) Co-Authored-By: ZCode (GLM-5.3) --- .../McpServiceVersionTests.cs | 18 +++++++++++++++++ .../VersionPinningIntegrationTests.cs | 4 ++++ Services/McpService.cs | 20 +++++++++++-------- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs b/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs index e0a03ef..d8e0f2c 100644 --- a/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs +++ b/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs @@ -12,6 +12,8 @@ private static string MakeSlot(string outputDir, string slot) var d = Path.Combine(outputDir, slot); Directory.CreateDirectory(Path.Combine(d, "API")); File.WriteAllText(Path.Combine(d, "API", "Table.md"), "# Table"); + // Imp-3:ListSlots 改为「目录内含 manifest.json 才算槽」——notice 清单测试须以真实槽口径造夹具 + VersionSlot.SaveManifest(outputDir, slot, new(slot, "abc1234", null, DateTimeOffset.UtcNow, "ready")); return d; } @@ -79,4 +81,20 @@ public void UnsafeVersion_ReturnsNullWithInvalidNotice(string version) Assert.Contains("Invalid", notice, StringComparison.OrdinalIgnoreCase); Assert.Contains("v10.8.1", notice); // notice 附可用槽清单 } + + [Fact] // Imp-3:notice 清单与 GetAvailableVersions 口径统一——迁移期遗产根产物(无 manifest 的 API/Samples)不得列为可用槽误导 LLM + public void NoticeListing_ExcludesLegacyDirs_WithoutManifest() + { + var o = Path.Combine(Path.GetTempPath(), "mcp6-" + Guid.NewGuid().ToString("N")); + MakeSlot(o, "v10.8.1"); + Directory.CreateDirectory(Path.Combine(o, "API")); // 遗产根产物(旧版 OutputDir 根目录结构) + Directory.CreateDirectory(Path.Combine(o, "Samples")); + + var root = McpService.ResolveDocsRoot(Settings("v10.8.1", o), "7.0.0", out var notice); + + Assert.Null(root); + Assert.Contains("v10.8.1", notice); + Assert.DoesNotContain("API", notice); + Assert.DoesNotContain("Samples", notice); + } } diff --git a/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs b/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs index 7f904fb..28e3263 100644 --- a/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs +++ b/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs @@ -32,6 +32,10 @@ private static (string Output, string V1Slot, string V2Slot) PrepareSlots() var v2Slot = Path.Combine(output, "v2.0.0"); extractor.Extract(v1, v1Slot); extractor.Extract(v2, v2Slot); + // Imp-3 口径对齐:生产槽必含 manifest.json(流水线 RunPipeline 落盘);本夹具直调 Extract 绕过流水线, + // 补齐以还原槽不变量(ListSlots/GetAvailableVersions 只认含 manifest 的目录) + VersionSlot.SaveManifest(output, "v1.0.0", new("v1.0.0", "fxv1c0m", "1.0.0", DateTimeOffset.UtcNow, "ready")); + VersionSlot.SaveManifest(output, "v2.0.0", new("v2.0.0", "fxv2c0m", "2.0.0", DateTimeOffset.UtcNow, "ready")); return (output, v1Slot, v2Slot); } diff --git a/Services/McpService.cs b/Services/McpService.cs index 7999cc0..e627264 100644 --- a/Services/McpService.cs +++ b/Services/McpService.cs @@ -35,10 +35,10 @@ public class GetComponentListArgs /// 建议传入当前项目实际引用的 BootstrapBlazor 版本(如 10.8.1);不传时使用钉住槽(PinRef),遗产模式(未配置 PinRef)使用根目录产物。 /// [McpServerTool] - public string GetComponentList(GetComponentListArgs args) + public string GetComponentList(GetComponentListArgs? args = null) // Imp-1:参数可空——AIFunctionFactory 不把可选参数标 required,旧客户端空参调用绑定为 null 而非抛 ArgumentException { var settings = _settingsManager.LoadSettings(); - var root = ResolveDocsRoot(settings, args.Version, out var notice); + var root = ResolveDocsRoot(settings, args?.Version, out var notice); if (root == null) return notice!; var apiPath = Path.Combine(root, "API"); if (!Directory.Exists(apiPath)) return WithNotice(notice, "Documentation not found."); @@ -161,9 +161,12 @@ Based strictly on the API and examples provided above (and your general Blazor s [McpServerTool] public string GetAvailableVersions() { + var slots = _extraction.GetAvailableVersions(); var legacy = string.IsNullOrWhiteSpace(_settingsManager.LoadSettings().PinRef) ? "\n(legacy mode: no PinRef configured)" : ""; - return string.Join("\n", _extraction.GetAvailableVersions().Select(s => - $"{s.SlotName} | BB {s.BootstrapBlazorVersion ?? "?"} | {s.Commit ?? "?"} | {s.Status} | {(s.IsPinned ? "[PINNED]" : "")}")) + legacy; + if (slots.Count == 0) return "(no versions extracted yet)" + legacy; + // 终审顺手项:未钉住槽不再产生尾部 " | " 悬挂分隔符 + return string.Join("\n", slots.Select(s => + $"{s.SlotName} | BB {s.BootstrapBlazorVersion ?? "?"} | {s.Commit ?? "?"} | {s.Status}{(s.IsPinned ? " | [PINNED]" : "")}")) + legacy; } public class PinVersionArgs @@ -179,8 +182,8 @@ public class PinVersionArgs [McpServerTool] public string PinVersion(PinVersionArgs args) { - VersionSlot.SlotName(args.Ref); // 空 ref 防御(SlotName 对空白输入抛 ArgumentException);brief 中槽变量未使用,此处收敛为纯校验调用 - return _extraction.PinAndExtract(_settingsManager, args.Ref); // 保存 PinRef + StartExtraction(force:false → 槽已 ready 时后台任务自行跳过) + // Imp-2b:校验前置到 PinAndExtract 入口(空/非法 ref 返回错误文案且不落盘),MCP 侧不再以 SlotName 抛异常 + return _extraction.PinAndExtract(_settingsManager, args.Ref); } public class ExtractVersionArgs @@ -238,13 +241,14 @@ public string ExtractVersion(ExtractVersionArgs args) return outputDir; } - /// 列出 OutputDir 下的候选槽目录名(排除 .staging-* 等点前缀临时目录);无候选时返回 "(none)"。 + /// 列出 OutputDir 下的候选槽目录名(Imp-3:目录内含 manifest.json 才算槽,与 GetAvailableVersions 口径统一; + /// 迁移期遗产根产物 API/Samples 不再列为可用槽误导 LLM;仍排除 .staging-* 等点前缀临时目录);无候选时返回 "(none)"。 private static string ListSlots(string outputDir) { if (!Directory.Exists(outputDir)) return "(none)"; var names = Directory.GetDirectories(outputDir) .Select(Path.GetFileName) - .Where(n => !string.IsNullOrEmpty(n) && !n.StartsWith('.')) + .Where(n => !string.IsNullOrEmpty(n) && !n.StartsWith('.') && File.Exists(VersionSlot.ManifestPath(outputDir, n!))) .ToList(); return names.Count > 0 ? string.Join(", ", names) : "(none)"; } From 7f97065115e3956ffc81f836a75854bf22732b42 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 18:28:22 +0800 Subject: [PATCH 16/24] =?UTF-8?q?fix(pinning):=20pin=20=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E9=93=BE=E5=89=8D=E7=BD=AE=E2=80=94=E2=80=94=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=20400=E3=80=81=E9=9D=9E=E6=B3=95=E4=B8=8D=E8=90=BD=E7=9B=98?= =?UTF-8?q?=E3=80=81=E6=8B=92=E7=BB=9D=E7=95=99=E7=97=95=E3=80=81busy=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Program.cs pin 端点:ref 参数改 string? 并入口校验,缺失/空白返回 400 "ref is required."(与 extract 端点对称),不再让 SlotName 抛 ArgumentException 变 500(Imp-2a) - ExtractionService.PinAndExtract(Services/ExtractionService.cs:175):入口先 Normalize + IsUnsafeSlotName 校验,空/路径形 ref 返回 "Invalid ref '…'. PinRef not saved." 且不写 PinRef、不分发后台任务——此前路径形 ref 先把垃圾 PinRef 写进 config 再被拒,假 200 后垃圾状态永久驻留(Imp-2b);McpService.PinVersion 空 ref 由此返回错误文案而非抛异常 - StartExtraction 分发前 _lock.CurrentCount==0 直接同步返回 "Extraction already in progress for slot '…'."(spec §6.2:占用时新请求立即返回提取已在进行中,第二个请求不再谎报 Extraction started)(Imp-2d) - StartExtraction 的 Task.Run 内对非 Started 结果记 LogWarning("Extraction not started for slot '{Slot}': {Message}")——not-found/invalid/busy 拒绝结果此前被直接丢弃(零日志零 manifest),三渠道只拿启动文案而轮询等不到解释(Imp-2c) - 新增 McpToolBindingTests(Imp-1 绑定层契约,此前 48 测全停在方法层才漏掉该缺陷):经 McpServerTool.Create 断言 GetComponentList wire-schema(args 保留在 properties、不进 required);空参/嵌套 {args:{version}} 绑定行为经与 SDK 0.9.0-preview.2 内部装配完全同构的 AIFunctionFactory + McpJsonUtilities.DefaultOptions 驱动(该版本 McpServerTool.InvokeAsync 需 RequestContext/真实服务器,不经此暴露) - VersionsApiTests:登录态哨兵 PostExtract 改 AllowAutoRedirect=false 客户端并先断言 /api/login 200(终审顺手项2:防登录 302 被跟随成登录页 200 的哨兵空转恒绿);新增 pin 空白 400(Theory×3:缺 ref/ref=/全空白)与 ref=c:evil 返回 Invalid 文案且 config PinRef 保持原值用例 - ExtractionServiceTests 新增:PinAndExtract 非法 ref 不落盘(Theory×4:""/空白/../evil/c:evil,断言 PinRef 未写、零 git 调用、输出目录无槽目录)、拒绝结果记 Warning(注入 CapturingLogger 行为断言)、busy 同步文案(先等 runner 进入即互斥确已持有,消除调度竞态) - 验证:dotnet test 全量 65 用例通过 / 0 失败(含本簇新增 14 例);dotnet build 0 error - 影响面:pin/extract 合法路径零变化;既有用例除夹具口径适配外零删改 Co-Authored-By: ZCode (GLM-5.3) --- .../ExtractionServiceTests.cs | 70 ++++++++ .../McpToolBindingTests.cs | 155 ++++++++++++++++++ .../VersionsApiTests.cs | 49 +++++- Program.cs | 6 +- Services/ExtractionService.cs | 16 +- 5 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 BootstrapBlazor.McpServer.Test/McpToolBindingTests.cs diff --git a/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs index 3258058..3e8f35b 100644 --- a/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs +++ b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs @@ -3,6 +3,7 @@ // 标注「真实 git」的用例例外:本地源仓库路径 clone/fetch,同样离线。 using BootstrapBlazor.McpServer.Services; using LibGit2Sharp; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; [Collection("AppSettingsFile")] // 与 VersionsApiTests 共享 data/config.json 落盘路径,串行避免并行写互踩 @@ -156,6 +157,75 @@ public void PinAndExtract_SavesPinRef_AndStartsExtraction() Assert.Equal("v10.8.1", cfg.LoadSettings().PinRef); } + [Theory] // Imp-2b:空/路径形 ref → 错误文案且不落盘(此前垃圾 PinRef 先写进 config 才被拒,假 200 后垃圾状态永久驻留) + [InlineData("")] + [InlineData(" ")] + [InlineData("../evil")] + [InlineData("c:evil")] + public void PinAndExtract_InvalidRef_ReturnsError_DoesNotPersist(string badRef) + { + var s = new AppSettingsModel(); var (svc, git, _) = Make(s); + var cfg = new AppSettingsManager(); + cfg.SaveSettings(s); // 预置已知 PinRef(空),断言不被垃圾值覆盖 + + var msg = svc.PinAndExtract(cfg, badRef); + + Assert.Contains("Invalid", msg, StringComparison.OrdinalIgnoreCase); + Assert.Equal("", cfg.LoadSettings().PinRef); // PinRef 未落盘 + Assert.Equal(0, git.Calls.Count); // 未分发任何 git 操作 + if (Directory.Exists(s.OutputDir)) + Assert.Empty(Directory.GetDirectories(s.OutputDir)); // 未写任何槽 manifest/目录 + } + + [Fact] // Imp-2c:非 Started 拒绝结果(此处为非法槽名)须记 LogWarning——此前结果直接丢弃,零日志零 manifest + public async Task StartExtraction_RejectedResult_LogsWarning() + { + var logger = new CapturingLogger(); + var s = new AppSettingsModel { OutputDir = Path.Combine(Path.GetTempPath(), "ext-t-" + Guid.NewGuid().ToString("N")) }; + var svc = new ExtractionService(new FakeGit(), new FakeRunner(), (b, o) => { }, () => s, logger); + + var msg = svc.StartExtraction("../evil", force: true); + Assert.Contains("Extraction started", msg, StringComparison.OrdinalIgnoreCase); // 同步仍返回启动文案(fire-and-forget 契约) + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (logger.Warnings.Count == 0 && DateTime.UtcNow < deadline) + await Task.Delay(50); + Assert.Contains(logger.Warnings, w => w.Contains("not started", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(logger.Warnings, w => w.Contains("../evil")); // 痕迹须指向被拒槽名 + } + + [Fact] // Imp-2d(spec §6.2):占用时新请求同步返回「已在进行中」文案,不再谎报 Extraction started + public async Task StartExtraction_WhileBusy_ReturnsBusyMessageImmediately() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + runner.Delay = TimeSpan.FromSeconds(2); git.HeadCommitResult = "x"; + svc.StartExtraction("main", force: true); + + // 等第一个任务确定持有互斥(runner.Run 进入即已在锁内),消除调度竞态 + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (runner.Calls.Count == 0 && DateTime.UtcNow < deadline) + await Task.Delay(20); + + var second = svc.StartExtraction("v10.8.1", force: true); + Assert.Contains("already in progress", second, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Extraction started", second, StringComparison.OrdinalIgnoreCase); + } + + /// 捕获 Warning 级日志文本(Imp-2c 行为断言用)。 + private sealed class CapturingLogger : ILogger + { + public List Warnings { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning) Warnings.Add(formatter(state, exception)); + } + } + [Fact] // GitSyncInvocable 分派:PinRef 非空 → 走槽流水线,不进遗产 clone-or-pull 分支 public async Task GitSync_WithPinRef_DispatchesToPipeline_SkipsLegacy() { diff --git a/BootstrapBlazor.McpServer.Test/McpToolBindingTests.cs b/BootstrapBlazor.McpServer.Test/McpToolBindingTests.cs new file mode 100644 index 0000000..38f7485 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/McpToolBindingTests.cs @@ -0,0 +1,155 @@ +// BootstrapBlazor.McpServer.Test/McpToolBindingTests.cs +// Imp-1 绑定层契约测试:锁住 GetComponentList 的 wire-schema 兼容性——可选参数(args 默认 null)不进 schema required, +// 旧客户端空参调用不得抛 ArgumentException;嵌套 { args: { version } } 形态正常绑定。 +// 此前 48 测全停在方法层,正是漏掉协议层绑定才放走该缺陷。 +// 路径说明(SDK 0.9.0-preview.2):McpServerTool.Create 内部即 AIFunctionFactory.Create(method, target, +// options{SerializerOptions = McpJsonUtilities.DefaultOptions})——schema 经 ProtocolTool.InputSchema 公开断言, +// 绑定行为经同一工厂+同一序列化选项的 AIFunction 装配驱动(McpServerTool.InvokeAsync 需 RequestContext/真实服务器,不经此暴露)。 +// 另含:McpService.PinVersion 空 ref 不抛(Imp-2b)、GetAvailableVersions 空输出/尾部分隔符(终审顺手项3)。 +using System.Text.Json; +using BootstrapBlazor.McpServer.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +[Collection("AppSettingsFile")] // 共享 data/config.json 落盘路径,与其他文件写测试串行 +public class McpToolBindingTests +{ + private static (McpService mcp, AppSettingsManager cfg) Make(AppSettingsModel st) + { + var cfg = new AppSettingsManager(); + cfg.SaveSettings(st); // ExtractionService 经 cfg.LoadSettings() 读回(与生产 DI 工厂一致的读取链) + var ext = new ExtractionService(new NoopGit(), new NoopRunner(), + (b, o) => { }, () => cfg.LoadSettings(), NullLogger.Instance); + var mcp = new McpService( + new DocsExtractorService(NullLogger.Instance), + new AiIntegrationService(new HttpClient(), cfg, NullLogger.Instance), + NullLogger.Instance, cfg, ext); + return (mcp, cfg); + } + + private static string MakeSlotDocs(string outputDir, string slot, string componentName) + { + var d = Path.Combine(outputDir, slot, "API"); + Directory.CreateDirectory(d); + File.WriteAllText(Path.Combine(d, componentName + ".md"), "# " + componentName); + return d; + } + + /// 与 McpServerTool.Create(Delegate) 内部装配完全一致的绑定工厂(同一 AIFunctionFactory + McpJsonUtilities.DefaultOptions)。 + private static AIFunction CreateBoundFunction(Delegate method) + => AIFunctionFactory.Create(method, new AIFunctionFactoryOptions { SerializerOptions = McpJsonUtilities.DefaultOptions }); + + private static async Task InvokeTextAsync(AIFunction function, Dictionary? args) + { + var result = await function.InvokeAsync(args == null ? null : new AIFunctionArguments(args)); + var text = result as string ?? result?.ToString(); + Assert.NotNull(text); + return text!; + } + + [Fact] // Imp-1 核心:wire-schema 上 args 不得列为 required(旧客户端空参调用即在此抛 ArgumentException) + public void GetComponentList_Schema_DoesNotRequireArgs() + { + var o = Path.Combine(Path.GetTempPath(), "bind-" + Guid.NewGuid().ToString("N")); + MakeSlotDocs(o, "v10.8.1", "Table"); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var tool = McpServerTool.Create(mcp.GetComponentList); + var schema = tool.ProtocolTool.InputSchema; + Assert.Equal("object", schema.GetProperty("type").GetString()); + Assert.True(schema.GetProperty("properties").TryGetProperty("args", out _)); // 参数形态保留(嵌套 {args:{version}}) + Assert.False(schema.TryGetProperty("required", out var required) && + required.EnumerateArray().Any(p => p.GetString() == "args"), + "args 不得进 required:可选参数(默认 null)是空参兼容的根"); + } + + [Fact] // Imp-1 核心:旧客户端空参(arguments:{})调用不抛异常,按钉住槽返回清单 + public async Task GetComponentList_EmptyArguments_BindsNull_DoesNotThrow() + { + var o = Path.Combine(Path.GetTempPath(), "bind-" + Guid.NewGuid().ToString("N")); + MakeSlotDocs(o, "v10.8.1", "Table"); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var f = CreateBoundFunction(mcp.GetComponentList); + var text = await InvokeTextAsync(f, null); // 修复前 args 为必填 → 绑定抛 ArgumentException + Assert.Contains("Table", text); + } + + [Fact] // Imp-1:嵌套 { args: { version } } 形态(协议层真实 wire 形式,JsonElement)正常绑定目标版本 + public async Task GetComponentList_NestedArgs_BindsVersion() + { + var o = Path.Combine(Path.GetTempPath(), "bind-" + Guid.NewGuid().ToString("N")); + MakeSlotDocs(o, "v10.8.1", "Table"); + MakeSlotDocs(o, "v9.0.0", "OldButton"); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var f = CreateBoundFunction(mcp.GetComponentList); + var text = await InvokeTextAsync(f, new Dictionary + { + ["args"] = JsonSerializer.SerializeToElement(new { version = "v9.0.0" }), + }); + Assert.Contains("OldButton", text); + Assert.DoesNotContain("Table", text); // 命中 v9.0.0 槽而非钉住槽 + } + + [Fact] // Imp-2b(MCP 侧):PinVersion 空 ref 经绑定层调用返回错误文案而非抛异常,且 PinRef 不落盘 + public async Task PinVersion_EmptyRef_ReturnsErrorText_DoesNotThrow() + { + var o = Path.Combine(Path.GetTempPath(), "bind-" + Guid.NewGuid().ToString("N")); + var (mcp, cfg) = Make(new AppSettingsModel { PinRef = "keepme", OutputDir = o }); + + var f = CreateBoundFunction(mcp.PinVersion); + var text = await InvokeTextAsync(f, new Dictionary + { + ["args"] = JsonSerializer.SerializeToElement(new { @ref = "" }), + }); + Assert.Contains("Invalid", text, StringComparison.OrdinalIgnoreCase); + Assert.Equal("keepme", cfg.LoadSettings().PinRef); // 垃圾 PinRef 未写入 config + } + + [Fact] // 终审顺手项3:空输出给自解释文案(含 legacy 附注) + public void GetAvailableVersions_NoSlots_ReturnsPlaceholder() + { + var o = Path.Combine(Path.GetTempPath(), "bind-" + Guid.NewGuid().ToString("N")); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "", OutputDir = o }); + var text = mcp.GetAvailableVersions(); + Assert.Contains("no versions extracted yet", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("legacy mode", text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] // 终审顺手项3:未钉住槽的行不得以 " | " 悬挂结尾;钉住槽带 [PINNED] 标记 + public void GetAvailableVersions_NoTrailingSeparator_PinsMarked() + { + var o = Path.Combine(Path.GetTempPath(), "bind-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(o); + VersionSlot.SaveManifest(o, "v10.8.1", new("v10.8.1", "abc1234", "10.8.1.0", DateTimeOffset.UtcNow, "ready")); + VersionSlot.SaveManifest(o, "main", new("main", "def5678", null, DateTimeOffset.UtcNow, "ready")); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var text = mcp.GetAvailableVersions(); + Assert.Contains("[PINNED]", text); + Assert.Contains("v10.8.1", text); + Assert.Contains("main", text); + foreach (var line in text.Split('\n')) + Assert.False(line.TrimEnd().EndsWith("|"), $"行尾悬挂分隔符: '{line}'"); + } + + /// 绑定层测试用的静默 git 替身(本文件所有用例均不触达流水线)。 + private sealed class NoopGit : IGitClient + { + public bool IsValid(string path) => false; + public void Clone(string sourceUrl, string workDir) { } + public void Fetch(string workDir) { } + public bool RefExists(string workDir, string refName) => false; + public void Checkout(string workDir, string refName, bool force) { } + public string HeadCommit(string workDir) => "0000000"; + public string? TipOf(string workDir, string refName) => null; + } + + private sealed class NoopRunner : IProcessRunner + { + public int Run(string fileName, string arguments, string workingDir, Action? onOutput = null) => 0; + } +} diff --git a/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs b/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs index 9253a0f..4af9333 100644 --- a/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs +++ b/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs @@ -2,6 +2,7 @@ // REST 端点集成测试:WebApplicationFactory 内存宿主(TestServer,不绑定真实端口、不启动真实服务)。 // 与 ExtractionServiceTests 同 Collection 串行——两类都读写 {BaseDirectory}/data/config.json // (AppSettingsManager 的落盘路径固定),xUnit 默认并行跑 Collection,串行化避免互相覆盖导致偶发红。 +using BootstrapBlazor.McpServer.Services; using Microsoft.AspNetCore.Mvc.Testing; using System.Net; @@ -32,8 +33,16 @@ public async Task PostPin_WithoutAuth_RedirectsToLogin() Assert.Contains("/login", resp.Headers.Location?.ToString() ?? "", StringComparison.OrdinalIgnoreCase); } - [Fact] // N2 哨兵:若 ?ref= 绑定失败则 ref_=null+PinRef 空 → 400;200 即绑定成功 - public async Task PostExtract_WithLogin_BindsRefQuery_Returns200() + private static async Task LoginClientAsync(WebApplicationFactory factory) + { + // 终审顺手项2:AllowAutoRedirect=false——登录/受保护端点的 302 不被跟随成登录页 200(防哨兵空转恒绿) + var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var login = await client.PostAsync("/api/login?username=u1&password=p1", null); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); // 登录必须真实成功,后续 200 才有意义 + return client; + } + + private static void WriteConfig(string pinRef) { var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); // WAF 宿主与测试同进程,AppSettingsManager 读同一 BaseDirectory Directory.CreateDirectory(dataDir); @@ -42,11 +51,41 @@ public async Task PostExtract_WithLogin_BindsRefQuery_Returns200() static string J(string p) => p.Replace("\\", "\\\\"); File.WriteAllText(Path.Combine(dataDir, "config.json"), // $$$(三个 $):JSON 尾部连续两个 "}" 是内容、不是插值闭括号——$ 数必须大于内容中最大连续括号数 - $$$"""{"Auth":{"AdminUsername":"u1","AdminPassword":"p1"},"GitSync":{"RepositoryUrl":"nowhere","LocalPath":"{{{J(Path.Combine(Path.GetTempPath(), "none"))}}}","OutputDir":"{{{J(outDir)}}}","PinRef":""}}"""); - var client = _factory.CreateClient(); - await client.PostAsync("/api/login?username=u1&password=p1", null); // 签发 Cookie + $$$"""{"Auth":{"AdminUsername":"u1","AdminPassword":"p1"},"GitSync":{"RepositoryUrl":"nowhere","LocalPath":"{{{J(Path.Combine(Path.GetTempPath(), "none"))}}}","OutputDir":"{{{J(outDir)}}}","PinRef":"{{{pinRef}}}"}}"""); + // 上一行 "PinRef" 值闭合引号后的两个 "}" 为字面内容(分别闭合 GitSync 与根对象),勿删 + } + + [Fact] // N2 哨兵:若 ?ref= 绑定失败则 ref_=null+PinRef 空 → 400;200 即绑定成功 + public async Task PostExtract_WithLogin_BindsRefQuery_Returns200() + { + WriteConfig(""); + using var client = await LoginClientAsync(_factory); var resp = await client.PostAsync("/api/versions/extract?ref=v10.8.1&force=false", null); Assert.Equal(HttpStatusCode.OK, resp.StatusCode); // 后台 fire-and-forget 提取任务对 "nowhere" 的失败不影响本断言(StartExtraction 立即返回,异常被 T4 修复R2 捕获落痕) } + + [Theory] // Imp-2a:pin 端点 ref 缺失/空白 → 400(与 extract 端点对称),不进 SlotName(否则 ArgumentException→500) + [InlineData("/api/versions/pin")] + [InlineData("/api/versions/pin?ref=")] + [InlineData("/api/versions/pin?ref=%20%20")] + public async Task PostPin_MissingOrBlankRef_Returns400(string url) + { + WriteConfig(""); + using var client = await LoginClientAsync(_factory); + var resp = await client.PostAsync(url, null); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + [Fact] // Imp-2b(端点面):路径形 ref 返回错误文案,且 config.json 的 PinRef 不被垃圾值覆盖 + public async Task PostPin_UnsafeRef_ReturnsErrorText_PinRefUnchanged() + { + WriteConfig("keepme"); + using var client = await LoginClientAsync(_factory); + var resp = await client.PostAsync("/api/versions/pin?ref=c:evil", null); // 盘符形(VolumeSeparator 守卫命中,URL 无需转义) + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var body = await resp.Content.ReadAsStringAsync(); + Assert.Contains("Invalid", body, StringComparison.OrdinalIgnoreCase); + Assert.Equal("keepme", new AppSettingsManager().LoadSettings().PinRef); // 垃圾 PinRef 未落盘 + } } diff --git a/Program.cs b/Program.cs index 704836e..aaeb6d4 100644 --- a/Program.cs +++ b/Program.cs @@ -115,9 +115,11 @@ // N2(审核实测):C# 关键字 ref 不能作参数名,而 minimal API 不做 ref_→ref 的下划线匹配 → // 必须 [FromQuery(Name = "ref")];bool force 必须带默认值,否则省略即 400 -app.MapPost("/api/versions/pin", ([FromQuery(Name = "ref")] string ref_, ExtractionService ext, AppSettingsManager cfg) => +// Imp-2a:ref 缺失/空白 → 400(与 extract 端点对称),不再让 SlotName 抛 ArgumentException 变 500 +app.MapPost("/api/versions/pin", ([FromQuery(Name = "ref")] string? ref_, ExtractionService ext, AppSettingsManager cfg) => { - return Results.Ok(ext.PinAndExtract(cfg, ref_)); // 保存 PinRef + StartExtraction 立即返回(与 McpService.PinVersion 共享同一实现) + if (string.IsNullOrWhiteSpace(ref_)) return Results.BadRequest("ref is required."); + return Results.Ok(ext.PinAndExtract(cfg, ref_)); // 保存 PinRef + StartExtraction 立即返回(与 McpService.PinVersion 共享同一实现;非法 ref 由 PinAndExtract 前置校验拦下) }).RequireAuthorization(); app.MapPost("/api/versions/extract", ([FromQuery(Name = "ref")] string? ref_, ExtractionService ext, AppSettingsManager cfg, bool force = false) => // brief 笔误修正:可选参数 force 须置于必选参数之后(CS1737),仅重排顺序、绑定语义不变 diff --git a/Services/ExtractionService.cs b/Services/ExtractionService.cs index 0ff874c..1afe8e8 100644 --- a/Services/ExtractionService.cs +++ b/Services/ExtractionService.cs @@ -129,6 +129,9 @@ private ExtractionResult RunPipeline(string slot, bool force) /// 【fire-and-forget】后台启动提取,立即返回提示串。对外 MCP/REST 一律经此进入,绝不 await RunAsync 挂住调用方。 public string StartExtraction(string slot, bool force, string extraNote = "") { + // Imp-2d(spec §6.2):占用时新请求同步返回「提取已在进行中」,不再谎报 started(三渠道此前都拿启动文案) + if (_lock.CurrentCount == 0) + return $"Extraction already in progress for slot '{slot}'. Poll GetAvailableVersions for progress."; // T4 修复R2:fire-and-forget 不得吞故障——Clone/Fetch 网络失败、extract 抛出、目录替换/manifest IO 失败等 // 若不捕获将成为 unobserved task exception(零日志零痕迹,首次 Clone 失败时槽连 manifest 都没有,列表不可见; // 夜间 cron 全经此进入)。捕获后记日志并尽力补写 failed manifest(写 manifest 再套 catch 防二次抛) @@ -136,7 +139,11 @@ public string StartExtraction(string slot, bool force, string extraNote = "") { try { - await RunAsync(slot, force); + var result = await RunAsync(slot, force); + // Imp-2c:not-found/invalid 等非 Started 拒绝结果此前被直接丢弃(零日志零 manifest)——调用方只拿到 + // 「Extraction started」而轮询永远等不到解释;这里记 Warning 作为唯一痕迹出口 + if (!result.Started) + _logger.LogWarning("Extraction not started for slot '{Slot}': {Message}", slot, result.Message); } catch (Exception ex) { @@ -165,10 +172,13 @@ private void TryWriteFailedManifest(string slotInput, Exception ex) } } - /// 钉住并提取:保存 PinRef=槽名后启动提取(T6/T7 共享)。 + /// 钉住并提取:先校验 ref(Imp-2b:空/路径形非法 → 返回错误文案,不写 PinRef、不落盘、不分发后台任务), + /// 合法则保存 PinRef=槽名后启动提取(T6/T7 共享)。此前路径形 ref 先把垃圾 PinRef 写进 config 才被 RunAsync 拒绝,垃圾状态永久驻留。 public string PinAndExtract(AppSettingsManager cfg, string refOrVersion) { - var slot = VersionSlot.SlotName(refOrVersion); + var slot = VersionSlot.Normalize(refOrVersion); + if (slot == null || IsUnsafeSlotName(slot)) + return $"Invalid ref '{refOrVersion}'. PinRef not saved."; var st = _settings(); st.PinRef = slot; cfg.SaveSettings(st); From f66850f84e48388a728f040fd5491be3a59a5034 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Mon, 17 Aug 2026 18:28:29 +0800 Subject: [PATCH 17/24] =?UTF-8?q?fix(ui):=20=E7=89=88=E6=9C=AC=E6=A7=BD?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=8F=8C=E8=89=B2=E6=B8=B2=E6=9F=93=E4=B8=8E?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E7=B1=BB=E6=8F=90=E7=A4=BA=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Index.razor 新增 _slotIsError:_slotResult 按结果语义分 success/danger 两色——错误文案配 alert-danger 与告警图标(Imp-4);赋值点:OnExtractNew 的必填/非法槽名错误路径置 true,OnDeleteSlot 按服务串语义分类(Invalid 前缀/cannot be deleted 视为错误),启动成功路径显式置 false - 启动类操作不再直显服务层英文串(Imp-2e):OnSyncClick 钉住分支、OnReExtract、OnExtractNew 经 LocalizeStartMessage 映射本地化键;busy 语义(服务串含 already in progress)单独走 ExtractionInProgress 键,不与「已启动」混淆 - Locales/zh-CN.json、Locales/en-US.json 新增 ExtractStarted / ExtractionInProgress 双语键(Imp-2e 配套) - VersionSlot.cs 补 Apache copyright header,与其余 5 个 Services 文件对齐(终审顺手项1) - 验证:dotnet test 全量 65 用例通过 / 0 失败;dotnet build 0 error;UI 逻辑为纯服务端渲染、无独立单测,人工核验路径:登录后仪表盘提交空版本号/非法槽名观察红色告警、正常提取观察绿色提示 - 影响面:OnSyncClick 遗产分支(PinRef 空)两条文案维持原样;刷新/删除流程行为不变 Co-Authored-By: ZCode (GLM-5.3) --- Components/Pages/Index.razor | 27 +++++++++++++++++++++------ Locales/en-US.json | 4 +++- Locales/zh-CN.json | 4 +++- Services/VersionSlot.cs | 4 ++++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/Components/Pages/Index.razor b/Components/Pages/Index.razor index 0831728..d6ef6b1 100644 --- a/Components/Pages/Index.razor +++ b/Components/Pages/Index.razor @@ -107,8 +107,8 @@ @if (!string.IsNullOrEmpty(_slotResult)) { -