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.Test/BootstrapBlazor.McpServer.Test.csproj b/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj new file mode 100644 index 0000000..ba75962 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/BootstrapBlazor.McpServer.Test.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs new file mode 100644 index 0000000..29096dc --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs @@ -0,0 +1,558 @@ +// BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs +// 全部用 fake(FakeGit/FakeRunner)+ 真实临时目录,离线,不碰真 git/build/extractor; +// 标注「真实 git」的用例例外:本地源仓库路径 clone/fetch,同样离线。 +using System.Collections.Concurrent; +using BootstrapBlazor.McpServer.Services; +using LibGit2Sharp; +using Microsoft.Extensions.Logging; +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) + { + 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 + Assert.Empty(git.CreateWorktreeCalls); // T2 扩展断言:跳过路径零 worktree 调用 + } + + [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 语义级改造(T2 并行提取):旧全局锁下异槽亦互斥;新语义异槽并行、同槽第二次立即 busy 且文案带槽名 + public async Task SameSlot_SecondCall_BusyWithSlotName() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + runner.HoldCount = 1; // 挂起首个 build 直至 Release(),期间持着 v1.0.0 的槽锁 + var first = svc.RunAsync("v1.0.0", force: true); + await WaitUntil(() => runner.EnteredCount >= 1, "first build should have entered"); + + var second = await svc.RunAsync("v1.0.0", force: true); + Assert.False(second.Started); + Assert.Contains("Extraction already in progress for slot 'v1.0.0'.", second.Message); + + runner.Release(); + Assert.True((await first).Started); + } + + [Fact] // T2 并行成立(spec §1 目标):槽 A build 挂起进行中,异槽 B 同时 RunAsync 不被阻塞、完整跑通 + public async Task DifferentSlots_ExtractInParallel() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + runner.HoldCount = 1; + var a = svc.RunAsync("v1.0.0", force: true); + await WaitUntil(() => runner.EnteredCount >= 1, "first build should have entered"); + + var b = await svc.RunAsync("v2.0.0", force: true); // 异槽:不被 A 的进行中状态阻塞 + Assert.True(b.Started); + + runner.Release(); + Assert.True((await a).Started); + Assert.Equal(2, git.CreateWorktreeCalls.Count); // 两槽各自开了独立 worktree + } + + [Fact] // T2 坑②锁死(spec §6):worktree committish 必为 TipOf 解析的 SHA(非裸槽名/本地分支名); + // manifest.Commit 共用同一次解析(build 后不得二次 TipOf) + public async Task BranchSlot_WorktreeCommitish_IsTipOfSha() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + git.TipOfResult = "deadbeef"; + var r = await svc.RunAsync("main", force: true); + Assert.True(r.Started); + var call = Assert.Single(git.CreateWorktreeCalls); + Assert.Equal("deadbeef", call.Committish); // 非 "main" + Assert.Equal("wt-main", call.Name); // 注册名约定 wt-{slot} + Assert.Equal(call.Path, Assert.Single(runner.WorkingDirs)); // build 工作目录即 worktree + Assert.Equal("deadbeef", VersionSlot.LoadManifest(s.OutputDir, "main")!.Commit); + Assert.Equal(1, git.Calls.Count(c => c == "TipOf")); // 提取路径唯一解析点,恰一次 + } + + [Fact] // T2 生命周期(spec §4.1):成功与失败路径 Create→Remove 各一次(即用即删,成败都删) + public async Task WorktreeRemoved_OnSuccessAndFailure() + { + var s = new AppSettingsModel(); var (svc, git, _) = Make(s); + Assert.True((await svc.RunAsync("v9.9.9", force: true)).Started); + Assert.Single(git.CreateWorktreeCalls); + Assert.Equal(1, git.RemoveWorktreeCalls); + + var s2 = new AppSettingsModel(); var (svc2, git2, runner2) = Make(s2); + runner2.ExitCode = 1; // build 失败 + var bad = await svc2.RunAsync("v9.9.8", force: true); + Assert.False(bad.Started); + Assert.Equal("failed", VersionSlot.LoadManifest(s2.OutputDir, "v9.9.8")!.Status); + Assert.Single(git2.CreateWorktreeCalls); + Assert.Equal(1, git2.RemoveWorktreeCalls); // 失败路径同样删除 worktree + } + + [Fact] // T2 tag ready 跳过路径零 worktree 调用(夜间幂等不碰 worktree) + public async Task TagReadySkip_NoWorktreeCalls() + { + 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.Empty(git.CreateWorktreeCalls); + Assert.Equal(0, git.RemoveWorktreeCalls); + } + + [Fact] // T2 锁序与容量(spec §4.3/§6):Clone/Fetch 均在 fetch 锁窗口(fake 时序无 OVERLAP); + // clone 双检恰一次;并发上限 2——前两个 in-flight 时第三个排队、放行后完成(信号验证,不做精确计时) + public async Task FetchClone_Serialized_And_CapacityQueues() + { + // 场景1:clone 双检 + 串行——挂起首个 Clone(锁内挂住)期间让第二槽读到 IsValid=false; + // 无锁实现会交叠(OVERLAP)/双 Clone,正确实现下第二槽进锁重验 IsValid=true 不再 Clone + var s1 = new AppSettingsModel(); var (svc1, git1, _) = Make(s1); + git1.IsValidResult = false; git1.CloneFlipsValid = true; git1.HoldFirstMutation = true; + var a1 = svc1.RunAsync("v1.0.0", force: true); + var a2 = svc1.RunAsync("v2.0.0", force: true); + await WaitUntil(() => string.Join(",", git1.Timeline).Contains("Clone:enter") + && git1.Calls.Count(c => c == "IsValid") >= 3, + "first clone entered in-lock and second slot passed its pre-check"); + git1.ReleaseMutation(); + Assert.True((await a1).Started); + Assert.True((await a2).Started); + Assert.Equal(1, git1.Calls.Count(c => c == "Clone")); // 双检:进锁重验后第二次不 Clone + Assert.DoesNotContain("OVERLAP", string.Join(",", git1.Timeline)); + + // 场景2:fetch 串行——RefExists=false 两槽都须 fetch,无交叠 + var s2 = new AppSettingsModel(); var (svc2, git2, _) = Make(s2); + git2.RefExistsResult = false; git2.HoldFirstMutation = true; + var b1 = svc2.RunAsync("v1.0.0", force: true); + var b2 = svc2.RunAsync("v2.0.0", force: true); + await WaitUntil(() => string.Join(",", git2.Timeline).Contains("Fetch:enter") + && git2.Calls.Count(c => c == "RefExists") >= 2, + "first fetch entered in-lock and second slot passed its pre-check"); + git2.ReleaseMutation(); + var r1 = await b1; var r2 = await b2; + Assert.Contains("not found", r1.Message + r2.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, git2.Calls.Count(c => c == "Fetch")); + Assert.DoesNotContain("OVERLAP", string.Join(",", git2.Timeline)); + + // 场景3:并发上限 2——前两个 build 挂起 in-flight,第三个在容量处排队不进入;放行后完成 + var s3 = new AppSettingsModel(); var (svc3, _, runner3) = Make(s3); + runner3.HoldCount = 2; + var t1 = svc3.RunAsync("v1.0.0", force: true); + var t2 = svc3.RunAsync("v2.0.0", force: true); + var t3 = svc3.RunAsync("v3.0.0", force: true); + await WaitUntil(() => runner3.EnteredCount >= 2, "two builds in flight"); + await Task.Delay(300); // 稳定窗:第三槽若不受容量约束必已进入(正确实现下停在 2) + Assert.Equal(2, runner3.EnteredCount); + runner3.Release(); + Assert.True((await t1).Started); + Assert.True((await t2).Started); + Assert.True((await t3).Started); + Assert.Equal(2, runner3.MaxInFlight); // 峰值恰为上限 2:不多(无越限)不少(真并行) + } + + [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); + } + + [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 + // T2 语义改造:并行化后异槽不再互斥——同步 busy 检查改为查该槽(同槽组合才应 busy) + public async Task StartExtraction_WhileBusy_ReturnsBusyMessageImmediately() + { + var s = new AppSettingsModel(); var (svc, git, runner) = Make(s); + runner.HoldCount = 1; + svc.StartExtraction("main", force: true); + + // 等第一个任务确定持有该槽互斥(runner.Run 进入即已在锁内),消除调度竞态 + await WaitUntil(() => runner.EnteredCount >= 1, "first build should have entered"); + + var second = svc.StartExtraction("main", force: true); + Assert.Contains("already in progress", second, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Extraction started", second, StringComparison.OrdinalIgnoreCase); + runner.Release(); + } + + /// 捕获 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() + { + 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"))); + } + + [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); + } + + /// 轮询等待条件成立(默认 5s 超时后以 because 失败)——并发测试消除调度竞态用。 + private static async Task WaitUntil(Func condition, string because) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (!condition() && DateTime.UtcNow < deadline) + await Task.Delay(20); + Assert.True(condition(), because); + } + + /// 记录全部调用名(T2 并行场景 → 线程安全容器);可用 *Result 属性控制返回值。 + /// T2 扩展(spec §4.5):CreateWorktreeCalls/RemoveWorktreeCalls 捕获 worktree 调用与收到的 committish; + /// Timeline 记 Fetch/Clone 的 enter/exit——并发交叠时记 {op}:OVERLAP(fetch 锁窗口断言用); + /// HoldFirstMutation 挂起首个 Clone/Fetch 直至 ReleaseMutation();CloneFlipsValid 模拟真实 Clone 后仓库变有效(双检锁测试)。 + private sealed class FakeGit : IGitClient + { + public ConcurrentQueue Calls { get; } = new(); + public bool IsValidResult { get; set; } = true; + public bool RefExistsResult { get; set; } = true; + public string HeadCommitResult { get; set; } = "0000000"; + // T2:TipOf 成为流水线唯一 SHA 解析点,默认非 null 让提取路径可跑通(branch-skip 用例可覆写,含 null 注入) + public string? TipOfResult { get; set; } = "deadbeef"; + + public ConcurrentQueue<(string Name, string Path, string Committish)> CreateWorktreeCalls { get; } = new(); + private int _removeWorktreeCalls; + public int RemoveWorktreeCalls => Volatile.Read(ref _removeWorktreeCalls); + + public ConcurrentQueue Timeline { get; } = new(); + private int _mutationDepth; + private int _mutationCount; + public bool CloneFlipsValid { get; set; } + public bool HoldFirstMutation { get; set; } + private readonly ManualResetEventSlim _mutationRelease = new(false); + public void ReleaseMutation() => _mutationRelease.Set(); + + public bool IsValid(string path) { Calls.Enqueue("IsValid"); return IsValidResult; } + public void Clone(string sourceUrl, string workDir) + { + MutationEnter("Clone"); + Calls.Enqueue("Clone"); + if (CloneFlipsValid) IsValidResult = true; + MutationExit("Clone"); + } + public void Fetch(string workDir) + { + MutationEnter("Fetch"); + Calls.Enqueue("Fetch"); + MutationExit("Fetch"); + } + public bool RefExists(string workDir, string refName) { Calls.Enqueue("RefExists"); return RefExistsResult; } + public void Checkout(string workDir, string refName, bool force) { Calls.Enqueue("Checkout"); } + public string HeadCommit(string workDir) { Calls.Enqueue("HeadCommit"); return HeadCommitResult; } + public string? TipOf(string workDir, string refName) { Calls.Enqueue("TipOf"); return TipOfResult; } + public void CreateWorktree(string workDir, string name, string path, string commitSha) + { + Calls.Enqueue("CreateWorktree"); + CreateWorktreeCalls.Enqueue((name, path, commitSha)); + } + public void RemoveWorktree(string workDir, string name) + { + Calls.Enqueue("RemoveWorktree"); + _ = Interlocked.Increment(ref _removeWorktreeCalls); + } + + private void MutationEnter(string op) + { + var n = Interlocked.Increment(ref _mutationCount); + var depth = Interlocked.Increment(ref _mutationDepth); + Timeline.Enqueue(depth > 1 ? $"{op}:OVERLAP" : $"{op}:enter"); + if (HoldFirstMutation && n == 1) _mutationRelease.Wait(); + } + + private void MutationExit(string op) + { + _ = Interlocked.Decrement(ref _mutationDepth); + Timeline.Enqueue($"{op}:exit"); + } + } + + /// 记录调用与工作目录(T2 并行场景 → 线程安全容器);HoldCount 挂起前 N 次 Run 直至 Release(); + /// EnteredCount/MaxInFlight 记进入次数与同时在 Run 内的峰值(并发上限断言用,不做精确计时)。 + private sealed class FakeRunner : IProcessRunner + { + public int ExitCode { get; set; } = 0; + public TimeSpan Delay { get; set; } = TimeSpan.Zero; + public ConcurrentQueue Calls { get; } = new(); + public ConcurrentQueue WorkingDirs { get; } = new(); + + public int HoldCount { get; set; } + private readonly ManualResetEventSlim _release = new(false); + private readonly object _sync = new(); + private int _entered; + private int _inFlight; + private int _maxInFlight; + + public int EnteredCount => Volatile.Read(ref _entered); + public int MaxInFlight { get { lock (_sync) return _maxInFlight; } } + public void Release() => _release.Set(); + + public int Run(string fileName, string arguments, string workingDir, Action? onOutput = null) + { + Calls.Enqueue($"{fileName} {arguments}"); + WorkingDirs.Enqueue(workingDir); + var n = Interlocked.Increment(ref _entered); + lock (_sync) + { + _inFlight++; + if (_inFlight > _maxInFlight) _maxInFlight = _inFlight; + } + if (n <= HoldCount) _release.Wait(); // 挂起前 HoldCount 个调用,测试控制放行时点 + if (Delay > TimeSpan.Zero) Thread.Sleep(Delay); + lock (_sync) { _inFlight--; } + return ExitCode; + } + } +} 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/GitClientTests.cs b/BootstrapBlazor.McpServer.Test/GitClientTests.cs new file mode 100644 index 0000000..5f1137c --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/GitClientTests.cs @@ -0,0 +1,237 @@ +// BootstrapBlazor.McpServer.Test/GitClientTests.cs +using BootstrapBlazor.McpServer.Services; +using LibGit2Sharp; + +public class GitClientTests +{ + /// 构造本地源仓库:两 commit + tag v9.9.9。非 bare——0.31 在 bare 上 Commit 抛 BareRepositoryException(T1 既有实证)。 + private static string MakeSourceRepo(string root) + { + var src = Path.Combine(root, "src"); + var sig = new Signature("t", "t", DateTimeOffset.Now); + 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", sig, sig); + File.AppendAllText(Path.Combine(src, "a.txt"), " world"); + Commands.Stage(repo, "a.txt"); + repo.Commit("second", sig, sig); + repo.ApplyTag("v9.9.9"); + } + return src; + } + + /// 测试自有脚手架:git 将 loose object 写为只读文件,须先去掉只读属性才能删除目录(best-effort,断言不受影响)。 + private static void CleanupTree(string root) + { + try + { + foreach (var f in Directory.GetFiles(root, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + Directory.Delete(root, true); + } + catch { /* best-effort */ } + } + + [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,无网络) + 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 + { + CleanupTree(root); + } + } + + [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 + { + CleanupTree(root); + } + } + + [Fact] // 并行提取 T1:离线 worktree 创建/移除——检出于 TipOf 解析的 SHA(detached)、注册在册、移除后目录与注册双净 + public void Worktree_CreateAtSha_And_Remove_CleansRegistration() + { + var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N")); + try + { + var src = MakeSourceRepo(root); + var work = Path.Combine(root, "work"); + var git = new GitClient(); + git.Clone(src, work); + var sha = git.TipOf(work, "v9.9.9"); // 与 Task 2 同源:committish 必须是 TipOf 解析 SHA,不得裸传槽短名 + var wtPath = Path.Combine(root, "wt-demo"); + git.CreateWorktree(work, "wt-demo", wtPath, sha); + Assert.True(Directory.Exists(wtPath)); + // Repository 持有 .git 句柄(Windows 下会挡住后续删除),断言完即确定性释放 + using (var w = new Repository(work)) + Assert.NotNull(w.Worktrees["wt-demo"]); // 注册在册 + using (var wt = new Repository(wtPath)) + Assert.Equal(sha, wt.Head.Tip.Sha[..7]); // 检出于指定 SHA(detached) + git.RemoveWorktree(work, "wt-demo"); + Assert.False(Directory.Exists(wtPath)); // 目录删净 + using (var w = new Repository(work)) + { + Assert.Null(w.Worktrees["wt-demo"]); // 注册注销(.git/worktrees 无残留) + Assert.Null(w.Branches["wt-demo"]); // 检出分支删净(Add 以 name 建分支、Prune 不删) + } + } + finally + { + CleanupTree(root); + } + } + + [Fact] // 生产事故回归(第二批验收 blocker):同仓库同名 worktree 第二次创建不得撞 "reference already exists"—— + // RemoveWorktree 当场删净同名分支后,create→remove→create 再成;清理职责在当前提取收尾,非下次请求 + public void Worktree_RecreateSameName_InSameRepo_Succeeds() + { + var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N")); + try + { + var src = MakeSourceRepo(root); + var work = Path.Combine(root, "work"); + var git = new GitClient(); + git.Clone(src, work); + var sha = git.TipOf(work, "v9.9.9"); + var wtPath = Path.Combine(root, "wt-demo"); + + git.CreateWorktree(work, "wt-demo", wtPath, sha); // 第一次(等价首次部署提取) + git.RemoveWorktree(work, "wt-demo"); // 第一次收尾清理 + git.CreateWorktree(work, "wt-demo", wtPath, sha); // 第二次(等价重提取)——修复前此行抛 reference already exists + + Assert.True(Directory.Exists(wtPath)); + using (var wt = new Repository(wtPath)) + Assert.Equal(sha, wt.Head.Tip.Sha[..7]); // 重建后仍检出于指定 SHA + git.RemoveWorktree(work, "wt-demo"); + using (var w = new Repository(work)) + { + Assert.Null(w.Worktrees["wt-demo"]); + Assert.Null(w.Branches["wt-demo"]); // 两轮之后仍三净(注册/目录/分支) + } + Assert.False(Directory.Exists(wtPath)); + } + finally + { + CleanupTree(root); + } + } + + [Fact] // 并行提取 T1:路径残留(含只读文件——模拟上次删除失败的半成品)先健壮清除再重建 + public void CreateWorktree_OverwritesStaleDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N")); + try + { + var src = MakeSourceRepo(root); + var work = Path.Combine(root, "work"); + var git = new GitClient(); + git.Clone(src, work); + var sha = git.TipOf(work, "v9.9.9"); + var wtPath = Path.Combine(root, "wt-demo"); + Directory.CreateDirectory(wtPath); + var stale = Path.Combine(wtPath, "stale.txt"); + File.WriteAllText(stale, "leftover"); + File.SetAttributes(stale, FileAttributes.ReadOnly); // git 只读残留同款形态:普通删除即抛 UnauthorizedAccess + git.CreateWorktree(work, "wt-demo", wtPath, sha); // 不抛、重建成功 + Assert.False(File.Exists(stale)); // 残留清干净——否则「先清后建」沦为空谈 + using (var wt = new Repository(wtPath)) + Assert.Equal(sha, wt.Head.Tip.Sha[..7]); + } + finally + { + CleanupTree(root); + } + } +} diff --git a/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs b/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs new file mode 100644 index 0000000..d8e0f2c --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/McpServiceVersionTests.cs @@ -0,0 +1,100 @@ +// 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"); + // Imp-3:ListSlots 改为「目录内含 manifest.json 才算槽」——notice 清单测试须以真实槽口径造夹具 + VersionSlot.SaveManifest(outputDir, slot, new(slot, "abc1234", null, DateTimeOffset.UtcNow, "ready")); + 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); + } + + [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 附可用槽清单 + } + + [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/McpToolBindingTests.cs b/BootstrapBlazor.McpServer.Test/McpToolBindingTests.cs new file mode 100644 index 0000000..6973e6c --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/McpToolBindingTests.cs @@ -0,0 +1,157 @@ +// 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; + public void CreateWorktree(string workDir, string name, string path, string commitSha) { } // 接口演进机械补齐(T1) + public void RemoveWorktree(string workDir, string name) { } + } + + private sealed class NoopRunner : IProcessRunner + { + public int Run(string fileName, string arguments, string workingDir, Action? onOutput = null) => 0; + } +} diff --git a/BootstrapBlazor.McpServer.Test/MlcAssemblyLoaderTests.cs b/BootstrapBlazor.McpServer.Test/MlcAssemblyLoaderTests.cs new file mode 100644 index 0000000..9f70770 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/MlcAssemblyLoaderTests.cs @@ -0,0 +1,56 @@ +// 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() + { + using var result = MlcAssemblyLoader.LoadBootstrapBlazor(BinDir); + Assert.NotNull(result); + var asm = result!.Assembly; + 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 DisposedContext_ReleasesFileMapping() + { + // 热修 #2 回归锁:Dispose 后必须能以写权限打开目标 DLL——未释放的 MLC 映射 + // 会让下一次提取的 build 复制步骤 MSB3027 锁死(v10.0.0 生产事故形态)。 + // 目标用临时副本:T9 哨兵在同进程对测试 bin 原件做 Assembly.LoadFrom(进程级锁、 + // 设计如此),对原件断言写权限会被其合法持有挡住 + var dir = Path.Combine(Path.GetTempPath(), "mlc-lock-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + File.Copy(Path.Combine(BinDir, "BootstrapBlazor.dll"), Path.Combine(dir, "BootstrapBlazor.dll")); + try + { + var result = MlcAssemblyLoader.LoadBootstrapBlazor(dir); + Assert.NotNull(result); + result!.Dispose(); + using var fs = File.Open(Path.Combine(dir, "BootstrapBlazor.dll"), FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + Assert.True(fs.Length > 0); + } + finally + { + Directory.Delete(dir, true); + } + } + + [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.Test/QueryEnhancementTests.cs b/BootstrapBlazor.McpServer.Test/QueryEnhancementTests.cs new file mode 100644 index 0000000..274a936 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/QueryEnhancementTests.cs @@ -0,0 +1,274 @@ +// BootstrapBlazor.McpServer.Test/QueryEnhancementTests.cs +// 查询体验增强(第二批⑥):① GetComponentDocs 可选 Section 分段返回;② not-found 提示附跨槽存在性信息。 +// 测试入口设计(plan Step 1 注记):McpService 构造占位不可行(五个真实依赖)——Section 组装与跨槽文案 +// 抽为 internal static 纯函数直击(ComposeDocument/EnrichNotFound,经 InternalsVisibleTo); +// 管线与绑定层用例仿 McpToolBindingTests 装配真实 McpService(写 data/config.json,AppSettingsFile 集合串行)。 +using System.Text.Json; +using BootstrapBlazor.McpServer.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +/// 纯函数直击用例:不构造 McpService、不落盘配置。 +public class QueryEnhancementTests +{ + private static string Tmp() => Path.Combine(Path.GetTempPath(), "qe-" + Guid.NewGuid().ToString("N")); + + private static string MakeSlotWith(string outputDir, string slot, string[] components) + { + var d = Path.Combine(outputDir, slot); + Directory.CreateDirectory(Path.Combine(d, "API")); + File.WriteAllText(Path.Combine(d, "manifest.json"), "{}"); // ListSlots 口径:一级目录含 manifest.json 即槽 + foreach (var c in components) File.WriteAllText(Path.Combine(d, "API", $"{c}.md"), $"# {c}"); + return d; + } + + // ---- ① Section 分段:ComposeDocument 纯函数 ---- + + [Fact] // 兼容红线:不传=全量,且全量格式与既有返回逐字一致 + public void Compose_Section_Null_ReturnsFullDocument_Verbally() + { + var full = McpService.ComposeDocument("Table", "api-body", "samples-body", null); + Assert.Equal("# Component: Table\n\n## API\napi-body\n\n## Samples\nsamples-body", full); + Assert.Equal(full, McpService.ComposeDocument("Table", "api-body", "samples-body", " ")); // 空白=省略 + } + + [Theory] + [InlineData("api", "## API", "## Samples")] + [InlineData("samples", "## Samples", "## API")] + [InlineData("API", "## API", "## Samples")] // 大小写不敏感 + [InlineData(" Samples ", "## Samples", "## API")] // trim 后归一 + public void Compose_Section_Split_ReturnsOnlyChosenSection(string section, string kept, string absent) + { + var r = McpService.ComposeDocument("Table", "api-body", "samples-body", section); + Assert.Contains(kept, r); + Assert.DoesNotContain(absent, r); + Assert.StartsWith("# Component: Table", r); + } + + [Fact] // 非法值不抛错,返回用法提示 + public void Compose_Section_Invalid_HintsWithoutThrow() + { + var r = McpService.ComposeDocument("Table", "api-body", "samples-body", "both"); + Assert.Contains("Invalid section 'both'", r); + Assert.Contains("'api'", r); + Assert.Contains("'samples'", r); + } + + // ---- ② 跨槽存在性提示:EnrichNotFound 纯函数 ---- + + [Fact] + public void EnrichNotFound_HintsCrossSlotExistence_SortedOrdinal() + { + var o = Tmp(); + MakeSlotWith(o, "v1.0.0", Array.Empty()); + MakeSlotWith(o, "v1.1.0", new[] { "RegexFilter" }); + MakeSlotWith(o, "v1.2.0", new[] { "RegexFilter" }); + var r = McpService.EnrichNotFound("RegexFilter", o, "v1.0.0"); + Assert.Contains("not found in 'v1.0.0'", r); + Assert.Contains("It exists in: v1.1.0, v1.2.0", r); // 槽名按字母序 + } + + [Fact] + public void EnrichNotFound_NoSlotContains_HintsNone() + { + var o = Tmp(); + MakeSlotWith(o, "v1.0.0", new[] { "Table" }); + var r = McpService.EnrichNotFound("NoSuch", o, "v1.0.0"); + Assert.Contains("It does not exist in any extracted version.", r); + } + + [Fact] // 跨槽 fuzzy 与主路径同规则:文件名 OrdinalIgnoreCase + public void EnrichNotFound_CrossSlotMatch_IsOrdinalIgnoreCase() + { + var o = Tmp(); + MakeSlotWith(o, "v1.0.0", Array.Empty()); + MakeSlotWith(o, "v1.1.0", new[] { "regexfilter" }); // 文件名小写 + var r = McpService.EnrichNotFound("RegexFilter", o, "v1.0.0"); + Assert.Contains("It exists in: v1.1.0", r); + } + + [Fact] // Samples 子目录同样计入存在性;缺 API/Samples 子目录的槽静默跳过不抛 + public void EnrichNotFound_SamplesDirCounts_AndPartialSlotsSkipped() + { + var o = Tmp(); + MakeSlotWith(o, "v1.0.0", Array.Empty()); + var samplesOnly = Path.Combine(o, "v1.1.0", "Samples"); // 仅 Samples 命中(无 API 子目录) + Directory.CreateDirectory(samplesOnly); + File.WriteAllText(Path.Combine(samplesOnly, "Table.md"), "# Table"); + File.WriteAllText(Path.Combine(o, "v1.1.0", "manifest.json"), "{}"); + Directory.CreateDirectory(Path.Combine(o, "v1.2.0")); // 有 manifest 但无 API/Samples 子目录 → 静默跳过不抛 + File.WriteAllText(Path.Combine(o, "v1.2.0", "manifest.json"), "{}"); + + var r = McpService.EnrichNotFound("Table", o, "v1.0.0"); + Assert.Contains("It exists in: v1.1.0", r); + } +} + +/// 管线级/绑定层用例:仿 McpToolBindingTests 装配真实 McpService(经 data/config.json 落盘,须串行)。 +[Collection("AppSettingsFile")] // 共享 data/config.json 落盘路径,与其他文件写测试串行 +public class QueryEnhancementPipelineTests +{ + private static string Tmp() => Path.Combine(Path.GetTempPath(), "qe-" + Guid.NewGuid().ToString("N")); + + private static string MakeSlotWith(string outputDir, string slot, string[] components) + { + var d = Path.Combine(outputDir, slot); + Directory.CreateDirectory(Path.Combine(d, "API")); + File.WriteAllText(Path.Combine(d, "manifest.json"), "{}"); // ListSlots 口径:一级目录含 manifest.json 即槽 + foreach (var c in components) File.WriteAllText(Path.Combine(d, "API", $"{c}.md"), $"# {c}"); + return d; + } + + private static (McpService mcp, AppSettingsManager cfg) Make(AppSettingsModel st) + { + var cfg = new AppSettingsManager(); + cfg.SaveSettings(st); // McpService 经 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); + } + + // ---- Section 经 GetComponentDocs 管线 ---- + + [Fact] // 兼容红线:不传 Section = 既有全量返回逐字不变;显式 null 等价 + public void GetComponentDocs_Section_Omitted_Or_Null_ReturnsFullDocument_Verbally() + { + var o = Tmp(); MakeSlotWith(o, "v10.8.1", new[] { "Table" }); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var omitted = mcp.GetComponentDocs(new() { ComponentName = "Table" }); + var explicitNull = mcp.GetComponentDocs(new() { ComponentName = "Table", Section = null }); + var expected = "# Component: Table\n\n## API\n# Table\n\n## Samples\nNo sample code available."; + Assert.Equal(expected, omitted); // 与既有全量格式逐字一致 + Assert.Equal(omitted, explicitNull); + } + + [Fact] // 分段经管线生效(钉住槽命中,notice=null 前缀) + public void GetComponentDocs_Section_Api_ReturnsOnlyApiPart() + { + var o = Tmp(); MakeSlotWith(o, "v10.8.1", new[] { "Table" }); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var r = mcp.GetComponentDocs(new() { ComponentName = "Table", Section = "api" }); + Assert.StartsWith("# Component: Table", r); + Assert.Contains("## API", r); + Assert.DoesNotContain("## Samples", r); + } + + [Fact] // 非法分段经管线返回用法提示,不抛错 + public void GetComponentDocs_Section_Invalid_HintsWithoutThrow() + { + var o = Tmp(); MakeSlotWith(o, "v10.8.1", new[] { "Table" }); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var r = mcp.GetComponentDocs(new() { ComponentName = "Table", Section = "both" }); + Assert.Contains("Invalid section 'both'", r); + Assert.Contains("'api'", r); + Assert.Contains("'samples'", r); + } + + // ---- not-found 跨槽提示经管线(GetComponentDocs/AskComponentExpert 共享 LoadComponentDocs,均流经)---- + + [Fact] // 组件存在于其他槽 → 提示槽名清单(按字母序) + public void NotFound_HintsCrossSlotExistence() + { + var o = Tmp(); + MakeSlotWith(o, "v1.0.0", Array.Empty()); + MakeSlotWith(o, "v1.1.0", new[] { "RegexFilter" }); + MakeSlotWith(o, "v1.2.0", new[] { "RegexFilter" }); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v1.0.0", OutputDir = o }); + + var r = mcp.GetComponentDocs(new() { ComponentName = "RegexFilter", Version = "v1.0.0" }); + Assert.Contains("not found", r); + Assert.Contains("It exists in: v1.1.0, v1.2.0", r); // 槽名按字母序 + } + + [Fact] // 版本有效但任何槽都没有该组件 → 明确「不存在于任何已提取版本」 + public void NotFound_NoSlotContains_HintsNone() + { + var o = Tmp(); MakeSlotWith(o, "v1.0.0", new[] { "Table" }); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v1.0.0", OutputDir = o }); + + var r = mcp.GetComponentDocs(new() { ComponentName = "NoSuch", Version = "v1.0.0" }); + Assert.Contains("It does not exist in any extracted version.", r); + } + + [Fact] // 遗产模式(无槽可扫)→ 原样文案,逐字断言(依赖外层显式「无槽→原样」守卫) + public void NotFound_LegacyMode_KeepsOriginalWording() + { + var o = Tmp(); Directory.CreateDirectory(Path.Combine(o, "API")); // 遗产根产物,无槽 + var (mcp, _) = Make(new AppSettingsModel { PinRef = "", OutputDir = o }); + + var r = mcp.GetComponentDocs(new() { ComponentName = "NoSuch" }); + Assert.Equal("Component 'NoSuch' not found in documentation.", r); // 原样,零附加 + } + + // ---- 绑定层(wire 兼容锁定,仿 McpToolBindingTests 装配)---- + + /// 与 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] // DTO 新增可空属性不破坏既有绑定:args 只含 componentName 调用成功并返回全量 + public async Task GetComponentDocs_DtoNewProperty_BindingStillWorks() + { + var o = Tmp(); MakeSlotWith(o, "v10.8.1", new[] { "Table" }); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var f = CreateBoundFunction(mcp.GetComponentDocs); + var text = await InvokeTextAsync(f, new Dictionary + { + ["args"] = JsonSerializer.SerializeToElement(new { componentName = "Table" }), + }); + Assert.Contains("## API", text); + Assert.Contains("## Samples", text); + } + + [Fact] // section 经 wire 层绑定(camelCase → Section)生效,只返回所选分段 + public async Task GetComponentDocs_Section_WireBinding_Applies() + { + var o = Tmp(); MakeSlotWith(o, "v10.8.1", new[] { "Table" }); + var (mcp, _) = Make(new AppSettingsModel { PinRef = "v10.8.1", OutputDir = o }); + + var f = CreateBoundFunction(mcp.GetComponentDocs); + var text = await InvokeTextAsync(f, new Dictionary + { + ["args"] = JsonSerializer.SerializeToElement(new { componentName = "Table", section = "api" }), + }); + Assert.Contains("## API", text); + Assert.DoesNotContain("## Samples", text); + } + + /// 绑定层测试用的静默 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; + public void CreateWorktree(string workDir, string name, string path, string commitSha) { } // 接口演进机械补齐(T1) + public void RemoveWorktree(string workDir, string name) { } + } + + private sealed class NoopRunner : IProcessRunner + { + public int Run(string fileName, string arguments, string workingDir, Action? onOutput = null) => 0; + } +} diff --git a/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs b/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs new file mode 100644 index 0000000..28e3263 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/VersionPinningIntegrationTests.cs @@ -0,0 +1,114 @@ +// 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 System.Reflection; +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() + { + // 哨兵确定性武装(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")); + 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); + // 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); + } + + /// 现场编译夹具假 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); + } +} 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.Test/VersionsApiTests.cs b/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs new file mode 100644 index 0000000..4af9333 --- /dev/null +++ b/BootstrapBlazor.McpServer.Test/VersionsApiTests.cs @@ -0,0 +1,91 @@ +// BootstrapBlazor.McpServer.Test/VersionsApiTests.cs +// REST 端点集成测试:WebApplicationFactory 内存宿主(TestServer,不绑定真实端口、不启动真实服务)。 +// 与 ExtractionServiceTests 同 Collection 串行——两类都读写 {BaseDirectory}/data/config.json +// (AppSettingsManager 的落盘路径固定),xUnit 默认并行跑 Collection,串行化避免互相覆盖导致偶发红。 +using BootstrapBlazor.McpServer.Services; +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); + } + + 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); + 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":"{{{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/BootstrapBlazor.McpServer.csproj b/BootstrapBlazor.McpServer.csproj index 6153fa0..364a31a 100644 --- a/BootstrapBlazor.McpServer.csproj +++ b/BootstrapBlazor.McpServer.csproj @@ -7,6 +7,16 @@ enable + + + + + + + + + + @@ -16,6 +26,7 @@ + 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/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..d6ef6b1 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,166 @@
+
+
+ + +
@Localizer["VersionSlots"]
+
+ +

@Localizer["VersionSlotsDesc"]

+
+
+ + + } + + + +
+
+ +
+
+
+
+ + @if (!string.IsNullOrEmpty(_slotResult)) + { + + } + + + + + @code { private string _syncResult = ""; + private string _slotResult = ""; + private bool _slotIsError; // Imp-4:结果按语义分 success/danger 两色(错误文案配 alert-danger) + 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 + { + // Imp-2e:不再直显服务层英文串,映射为本地化提示(区分「已启动」/「已在进行中」两种语义) + _syncResult = LocalizeStartMessage(Ext.StartExtraction(VersionSlot.SlotName(pin), force: true)); + } + } + + private void OnRefreshSlots() + { + _slots = Ext.GetAvailableVersions(); + } + + private void OnReExtract(string slotName) + { + _slotResult = LocalizeStartMessage(Ext.StartExtraction(slotName, force: true)); + _slotIsError = false; + 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; + var msg = Ext.DeleteSlot(slotName); + _slotResult = msg; + _slotIsError = msg.StartsWith("Invalid", StringComparison.OrdinalIgnoreCase) + || msg.Contains("cannot be deleted", StringComparison.OrdinalIgnoreCase); // 拒绝删除(非法槽名/钉住槽)按错误渲染 + OnRefreshSlots(); + } + + private void OnExtractNew() + { + if (string.IsNullOrWhiteSpace(_newRef)) + { + _slotResult = Localizer["NewRefRequired"]; + _slotIsError = true; + return; + } + var slot = VersionSlot.SlotName(_newRef); + // 槽名守卫前置到 UI:StartExtraction 为 fire-and-forget,RunAsync 内的拒绝结果不回传调用方 + if (ExtractionService.IsUnsafeSlotName(slot)) + { + _slotResult = string.Format(Localizer["InvalidSlotName"], slot); + _slotIsError = true; + return; + } + _slotResult = LocalizeStartMessage(Ext.StartExtraction(slot, force: true)); + _slotIsError = false; + OnRefreshSlots(); + } + + /// Imp-2e:启动类操作的服务层提示映射为本地化文案(busy 语义单独成键,不与「已启动」混淆)。 + private string LocalizeStartMessage(string serviceMessage) + => serviceMessage.Contains("already in progress", StringComparison.OrdinalIgnoreCase) + ? Localizer["ExtractionInProgress"] + : Localizer["ExtractStarted"]; + + 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..945eb3b 100644 --- a/Locales/en-US.json +++ b/Locales/en-US.json @@ -15,7 +15,25 @@ "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}'.", + "ExtractStarted": "Extraction started and running in the background. Click Refresh to see progress.", + "ExtractionInProgress": "An extraction is already in progress. Click Refresh to see progress." }, "BootstrapBlazor.McpServer.Components.Pages.Config": { "PageTitle": "Configuration - MCP Server", @@ -33,6 +51,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..a1b42e5 100644 --- a/Locales/zh-CN.json +++ b/Locales/zh-CN.json @@ -15,7 +15,25 @@ "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}」。", + "ExtractStarted": "提取已开始,正在后台运行;点击「刷新」查看进度。", + "ExtractionInProgress": "已有提取任务正在进行中;点击「刷新」查看进度。" }, "BootstrapBlazor.McpServer.Components.Pages.Config": { "PageTitle": "全局配置 - MCP Server", @@ -33,6 +51,7 @@ "CronSchedule": "定时同步 Cron 表达式", "LocalPath": "仓库本地克隆路径", "OutputDir": "文档输出目录", + "PinRef": "钉住版本", "AiBaseUrl": "AI 接口 Base URL", "AiApiKey": "AI 接口 API Key", "AiModel": "AI 模型名称", diff --git a/Program.cs b/Program.cs index b107bec..aaeb6d4 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 => @@ -74,7 +85,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) => @@ -98,6 +109,26 @@ 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 +// Imp-2a:ref 缺失/空白 → 400(与 extract 端点对称),不再让 SlotName 抛 ArgumentException 变 500 +app.MapPost("/api/versions/pin", ([FromQuery(Name = "ref")] string? ref_, ExtractionService ext, AppSettingsManager cfg) => +{ + 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),仅重排顺序、绑定语义不变 +{ + 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 +158,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 { } diff --git a/README.md b/README.md index 447540d..4938b96 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ In Cursor settings, add an MCP server pointing to `http://localhost:5251/mcp`. | `GetComponentList` | Returns a list of all available BootstrapBlazor components | | `SearchComponentKeyword` | Searches for components matching a keyword | | `GetComponentDocs` | Returns the raw API table + code samples for a component | +| ↳ `section` arg | Optional for `GetComponentDocs`: `api` or `samples` (case-insensitive) to return only that part; omit for the full document | | `AskComponentExpert` | Answers a natural-language question about a component (uses AI if enabled) | --- @@ -167,6 +168,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 run **in parallel across slots**: each slot builds in its own throw-away git worktree (checked out at the slot's resolved commit, deleted right after the run — success or failure alike), while the same slot stays mutually exclusive — a request for a slot that is already extracting returns "Extraction already in progress for slot '{slot}'." Two valves cap the parallelism: all clone/fetch operations are serialized by a global lock (LibGit2Sharp is not thread-safe), and at most 2 builds run at the same time — further requests queue and then proceed, they are never rejected. + +### 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..74f322c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -150,6 +150,7 @@ docker run -d \ | `GetComponentList` | 获取所有可用 BootstrapBlazor 组件列表 | | `SearchComponentKeyword` | 按关键词搜索匹配的组件 | | `GetComponentDocs` | 获取指定组件的 API 参数表和代码示例原文 | +| ↳ `section` 参数 | `GetComponentDocs` 可选:`api` 或 `samples`(大小写不敏感)只返回对应分段;不传返回全量文档 | | `AskComponentExpert` | 以自然语言向组件专家提问(若启用 AI 则调用 AI 回答) | --- @@ -167,6 +168,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` 即可。提取**按槽并行**:每个槽在自己的临时 git worktree 中构建(检出于该槽解析出的 commit,运行后即删——成败都删),同槽仍互斥——对正在提取的槽再次发起请求返回「Extraction already in progress for slot '{slot}'.」。并行由两道闸约束:所有 clone/fetch 操作经全局锁串行(LibGit2Sharp 非线程安全);同时最多 2 个 build 在跑——后续请求排队等待后继续,不会被拒绝。 + +### 新增 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` 中配置的账号密码登录。 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/Services/DocsExtractorService.cs b/Services/DocsExtractorService.cs index ab12b11..c593817 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,37 @@ 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); + // 热修 #2:MLC 上下文必须随提取段结束释放(using 范围 = 本 try 块)——不 Dispose 的文件映射 + // 会锁住目标 DLL,下一次提取的 build 复制被 MSB3027 锁死;所有 Type 访问均在下方 API 段内完成, + // Dispose 前无逃逸引用(Samples 段只读文件,不触反射类型) + using var mlcScope = MlcAssemblyLoader.LoadBootstrapBlazor(targetDir); + // 三级回退均落空时抛出,由下方既有 catch 记日志后继续 Samples 段—— + // 与旧码 LoadFrom 抛异常被 catch 的控制流一致(实际不可达:dllPath 存在性已前置校验) + var assembly = mlcScope?.Assembly + ?? 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()) @@ -181,6 +203,10 @@ public void Extract(string basePath, string outputDir) // 去掉末尾的 's'(示例类通常以 s 结尾),与 API 文件名一致 var componentKey = sampleClassName.EndsWith('s') && sampleClassName.Length > 2 ? sampleClassName[..^1] : sampleClassName; var mdPath = Path.Combine(outputDir, "Samples", $"{componentKey}.md"); + // docs.json 条目可含分隔符(如 Charts\Bar),componentKey 会带出嵌套相对路径; + // 上游此处从未创建子目录,旧版被 Invoke 的整体 catch 静默吞掉——槽流水线会如实记 failed,故写前必须建目录 + var mdDir = Path.GetDirectoryName(mdPath); + if (!string.IsNullOrEmpty(mdDir)) Directory.CreateDirectory(mdDir); using var writer = new StreamWriter(mdPath); writer.WriteLine($"# {folderKey} 示例 (Samples)"); writer.WriteLine(); @@ -221,6 +247,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/ExtractionService.cs b/Services/ExtractionService.cs new file mode 100644 index 0000000..fdff383 --- /dev/null +++ b/Services/ExtractionService.cs @@ -0,0 +1,326 @@ +// 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.Collections.Concurrent; +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/clone/build/staging 原子替换/manifest/按槽互斥)。单例,DI 注册于 T7。 +/// 并行提取(spec §4.3):异槽并行(每槽独立 worktree 即用即删)+ fetch/clone 全局锁 + 并发上限 2。 +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 ConcurrentDictionary _slotLocks = new(); + private readonly SemaphoreSlim _capacity = new(2); // 全局并发上限:同时最多 2 个 build(await 排队等待、不拒绝) + private readonly SemaphoreSlim _fetchLock = new(1, 1); // LibGit2Sharp Repository 非线程安全:Clone/Fetch 必在持锁内 + + public ExtractionService(IGitClient git, IProcessRunner runner, Action extract, + Func settings, ILogger logger) + { + _git = git; + _runner = runner; + _extract = extract; + _settings = settings; + _logger = logger; + } + + /// 槽级互斥 + 并发上限内跑完整提取流水线;槽 ready 且内容未变时跳过(nightly 幂等)。 + /// spec §4.3:异槽并行、同槽 WaitAsync(0) 占用即 busy(文案带槽名)。 + 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}'."); + + var slotLock = _slotLocks.GetOrAdd(slot, _ => new SemaphoreSlim(1, 1)); + if (!await slotLock.WaitAsync(0)) + return new ExtractionResult(false, false, slot, $"Extraction already in progress for slot '{slot}'."); + + try + { + await _capacity.WaitAsync(); // 并发上限:排队等待、不拒绝 + try + { + // 注记③:流水线主体放 Task.Run——互斥持有期间不阻塞调用方线程,并发测试(剧本E)真实成立 + return await Task.Run(() => RunPipeline(slot, force)); + } + finally + { + _capacity.Release(); + } + } + finally + { + slotLock.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 锁内 fetch 后取远端分支 tip 与 manifest commit 比对(注记①:主仓库 HEAD 不代表目标 ref) + var upToDate = false; + InFetchLock(() => + { + _git.Fetch(st.LocalPath); + var tip = _git.TipOf(st.LocalPath, gitRef); + upToDate = tip is not null && tip == existing.Commit; + }); + if (upToDate) + { + return new ExtractionResult(false, true, slot, $"Slot '{slot}' up to date."); + } + } + + // 走到这里 = 需要真正提取 + if (!_git.IsValid(st.LocalPath)) + { + // clone 双检锁(spec §4.3 折入):进锁后重验 IsValid 再 Clone——两槽并发首提取只 Clone 一次 + InFetchLock(() => + { + if (!_git.IsValid(st.LocalPath)) _git.Clone(st.RepositoryUrl, st.LocalPath); + }); + } + if (!_git.RefExists(st.LocalPath, gitRef)) + { + InFetchLock(() => _git.Fetch(st.LocalPath)); + if (!_git.RefExists(st.LocalPath, gitRef)) + { + return new ExtractionResult(false, false, slot, $"Ref '{gitRef}' not found after fetch."); + } + } + // spec §4.3 唯一 SHA 解析点:worktree committish 与 manifest.Commit 共用同一次 TipOf 结果 + // (分支 origin 优先 / tag PeeledTarget 剥壳),build 后不得二次 TipOf——防并发 fetch 在 build 窗口 + // 推进 ref 导致 manifest 与内容错标。ref 已由上方 RefExists 验证存在,null 属不可达防御 + var sha = _git.TipOf(st.LocalPath, gitRef); + if (sha is null) + { + return new ExtractionResult(false, false, slot, $"Ref '{gitRef}' could not be resolved."); + } + + var worktreePath = Path.Combine(Path.GetDirectoryName(st.LocalPath)!, "Worktrees", slot); + 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, sha, null, DateTimeOffset.UtcNow, SlotStatus.Extracting)) + with { Status = SlotStatus.Extracting, Commit = sha }); + // spec §4.1:worktree 即用即删——提取主体在独立 worktree 上跑,主仓库工作树不被检出互踩(并行的根基) + _git.CreateWorktree(st.LocalPath, $"wt-{slot}", worktreePath, sha); + try + { + var exit = _runner.Run("dotnet", BuildArguments, worktreePath); // 工作目录=worktree,命令串不变 + if (exit != 0) + { + // 失败:槽目录旧产物不动(仅 manifest 改记 failed),staging 留待下轮清理 + VersionSlot.SaveManifest(st.OutputDir, slot, + new SlotManifest(gitRef, sha, 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(worktreePath, staging); + var final = Path.Combine(st.OutputDir, slot); + // 两步替换(删旧槽目录 + staging 改名)非单步原子:窗口内槽短暂缺失,由下轮重提自愈 + if (Directory.Exists(final)) Directory.Delete(final, true); + Directory.Move(staging, final); + VersionSlot.SaveManifest(st.OutputDir, slot, + new SlotManifest(gitRef, sha, ReadBbVersion(worktreePath), DateTimeOffset.UtcNow, SlotStatus.Ready)); + _logger.LogInformation("Slot {Slot} extracted at commit {Commit}.", slot, sha); + return new ExtractionResult(true, false, slot, $"Slot '{slot}' extracted (commit {sha})."); + } + finally + { + // spec §4.1:成功失败都删;删除失败仅记 Warning 不判提取失败(产物已落槽,残留目录由下次同名创建前清理兜底) + TryRemoveWorktree(st.LocalPath, slot); + } + } + + /// fetch/clone 全局锁内执行(LibGit2Sharp Repository 非线程安全,spec §4.3)。 + /// 流水线在线程池线程上同步跑,同步 Wait 即可;锁序恒为 slotLock→capacity→fetchLock,无环不死锁。 + private void InFetchLock(Action action) + { + _fetchLock.Wait(); + try + { + action(); + } + finally + { + _fetchLock.Release(); + } + } + + /// 注销并删除 wt-{slot} worktree(与 CreateWorktree 同名约定拼接); + /// 失败仅记 Warning(spec §4.1:绝不因删除失败判提取失败)。 + private void TryRemoveWorktree(string workDir, string slot) + { + try + { + _git.RemoveWorktree(workDir, $"wt-{slot}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to remove worktree for slot {Slot}; stale directory will be cleaned before next create.", slot); + } + } + + /// 【fire-and-forget】后台启动提取,立即返回提示串。对外 MCP/REST 一律经此进入,绝不 await RunAsync 挂住调用方。 + public string StartExtraction(string slot, bool force, string extraNote = "") + { + // Imp-2d(spec §6.2):该槽占用时新请求同步返回「提取已在进行中」,不再谎报 started(三渠道此前都拿启动文案) + // T2 并行改造:原全局 CurrentCount 检查改为查该槽的信号量(spec §4.3;文案 HEAD 已带槽名,不动) + if (_slotLocks.TryGetValue(slot, out var slotLock) && slotLock.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 防二次抛) + _ = Task.Run(async () => + { + try + { + 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) + { + _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,不再抛出 + } + } + + /// 钉住并提取:先校验 ref(Imp-2b:空/路径形非法 → 返回错误文案,不写 PinRef、不落盘、不分发后台任务), + /// 合法则保存 PinRef=槽名后启动提取(T6/T7 共享)。此前路径形 ref 先把垃圾 PinRef 写进 config 才被 RunAsync 拒绝,垃圾状态永久驻留。 + public string PinAndExtract(AppSettingsManager cfg, string 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); + 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; + } + } + + /// 拒绝路径穿越形槽名:纯点段("."、".." 等)、含任意路径分隔符("/"、"\")或卷分隔符(":")。 + /// 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(Path.VolumeSeparatorChar); + + /// 注记②:读构建产物 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 new file mode 100644 index 0000000..9d09bee --- /dev/null +++ b/Services/GitClient.cs @@ -0,0 +1,177 @@ +// 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、本地分支或 refs/remotes/origin/{name} 存在(分支 origin 优先语义,T4 修复R1)。 + bool RefExists(string workDir, string refName); + + /// 检出 ref:分支检出 origin tip 的 commit(detached);tag 按名检出(detached 属合法状态)。 + void Checkout(string workDir, string refName, bool force); + + /// HEAD 指向 commit 的短 SHA(7 位)。 + string HeadCommit(string workDir); + + /// 分支(origin 优先,回退本地)或 tag 指向 commit 的短 SHA(7 位);皆不存在返回 null。跳过判定用(T4 注记①/修复R1)。 + string? TipOf(string workDir, string refName); + + /// 并行提取:于 commitSha(TipOf 解析产物,勿裸传槽短名)创建 worktree 至 path(detached 检出于该 commit;路径残留先健壮清除)。 + void CreateWorktree(string workDir, string name, string path, string commitSha); + + /// 并行提取:注销 name 的 worktree 注册、健壮删除其目录(Windows 重试链 ≤3 次 ×500ms,全平台清只读属性), + /// 并删除 Add 时创建的同名检出分支——**每次提取的 finally 当场删净,正常路径零残留**; + /// name 未注册时为安全 no-op(异常路径的残留由下次 CreateWorktree 同名重建前清理兜底)。 + void RemoveWorktree(string workDir, string name); +} + +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); + // 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 枚举。 + // 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 + }); + } + + 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 同格式,可直接比对。 + // 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["origin/" + refName]?.Tip.Sha + ?? repo.Branches[refName]?.Tip.Sha + ?? tag?.PeeledTarget.Sha + ?? tag?.Target.Sha; + return sha?[..7]; + } + + public void CreateWorktree(string workDir, string name, string path, string commitSha) + { + // spec §4.1:路径残留(上次删除失败的半成品)先健壮清除再重建 + RobustDelete(path); + // 0.31 实测:libgit2 不创建中间目录,父目录缺失直接抛 "failed to make directory"——Worktrees/{slot} 首建须自备 + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var repo = new Repository(workDir); + // 崩溃恢复兜底(非常规清理):进程被杀等异常路径可能在 Add 后留下同名分支/注册/目录—— + // 正常路径的清理在 RemoveWorktree(每次提取的 finally 当场删净),此处仅为异常后自愈 + var staleBranch = repo.Branches[name]; + if (staleBranch != null) repo.Branches.Remove(staleBranch); + // spec §4.2 实证形态:Add(committishOrBranchSpec, name, path, isLocked)——无 WorktreeAddOptions 类; + // committish 收 TipOf 的(7 位短)SHA,libgit2 可直接解析并 detached 检出于该 commit(spec §4.3:勿传裸槽短名) + repo.Worktrees.Add(commitSha, name, path, isLocked: false); + } + + public void RemoveWorktree(string workDir, string name) + { + using var repo = new Repository(workDir); + var wt = repo.Worktrees[name]; + if (wt == null) + { + // brief 笔误修正:0.31 的 Worktree 无 Path 属性,路径无从反查——注册不在即无目录可删; + // 残留目录由下次 CreateWorktree 同名重建前清理兜底(spec §4.1) + return; + } + // wtRepo 须先 Dispose 释放 .git 句柄,否则 Windows 下后续目录删除可能失败 + string wtPath; + using (var wtRepo = wt.WorktreeRepository) + { + wtPath = wtRepo.Info.WorkingDirectory!; + } + // spec §4.2:0.31 无 Worktrees.Remove——移除 = Prune(worktree, ifLocked) 注销 .git/worktrees/{name}(实测连带删工作目录) + repo.Worktrees.Prune(wt, ifLocked: false); + // 兜底:Prune 未删净(Windows 句柄残留等)时健壮删除;重试穷尽的最终失败上抛,由调用方(ExtractionService)catch 记 Warning + RobustDelete(wtPath); + // 生产事故根因(第二批验收 blocker):Add 传 committish 时以 name 创建同名分支(refs/heads/{name})作检出载体, + // Prune 只注销注册**不删该分支**——不在此删净则同名 worktree 第二次创建(重提取/夜间刷新)即撞 + // "reference already exists" 立败。删除职责归当前提取的收尾(本方法由 finally 调用):分支仅作检出车辆, + // Prune 后无人引用,当场删除——正常路径零残留,不依赖下次请求 + var branch = repo.Branches[name]; + if (branch != null) repo.Branches.Remove(branch); + } + + /// + /// 健壮删除目录(spec §4.4 全平台纪律,禁 P/Invoke 与 Windows 特有 API): + /// 只读属性清理统一用可移植 BCL File.SetAttributes(.NET 在 Unix 映射权限位、Windows 映射属性位); + /// 删除重试链(≤3 次 ×500ms——MSBuild 节点进程短暂攥 obj 句柄仅 Windows 存在,POSIX 可删打开中的文件) + /// 仅 OperatingSystem.IsWindows() 启用,非 Windows 直接常规删除;重试穷尽后异常上抛。 + /// + private static void RobustDelete(string path) + { + if (!Directory.Exists(path)) + { + return; + } + foreach (var f in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + if (!OperatingSystem.IsWindows()) + { + Directory.Delete(path, true); + return; + } + for (var attempt = 1; ; attempt++) + { + try + { + Directory.Delete(path, true); + return; + } + catch (IOException) when (attempt < 3) + { + Thread.Sleep(500); + } + } + } +} 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 diff --git a/Services/McpService.cs b/Services/McpService.cs index ced00ab..4651d24 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 = null) // Imp-1:参数可空——AIFunctionFactory 不把可选参数标 required,旧客户端空参调用绑定为 null 而非抛 ArgumentException { 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,65 @@ 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 / samples,大小写不敏感):只返回该节(节标题与全量逐字一致);不传或空白返回全量文档;其他值返回用法提示。 + public string? Section { get; set; } } /// - /// 根据组件名直接返回原始文档信息(API 参数表 + 代码示例),不经过 AI 处理。 + /// 根据组件名直接返回指定版本的原始文档信息(API 参数表 + 代码示例),不经过 AI 处理。 + /// 建议传入当前项目实际引用的 BootstrapBlazor 版本(如 10.8.1);不传时使用钉住槽(PinRef),遗产模式(未配置 PinRef)使用根目录产物。 + /// 可选 section 参数支持分段返回('api' 仅 API 节 / 'samples' 仅示例节),适用于大文档避免截断。 /// [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, settings.OutputDir); + if (error != null) return WithNotice(notice, error); - return $"# Component: {args.ComponentName}\n\n## API\n{apiContent}\n\n## Samples\n{sampleContent}"; + return WithNotice(notice, ComposeDocument(args.ComponentName, apiContent, sampleContent, args.Section)); } 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, settings.OutputDir); + 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 +156,122 @@ 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 slots = _extraction.GetAvailableVersions(); + var legacy = string.IsNullOrWhiteSpace(_settingsManager.LoadSettings().PinRef) ? "\n(legacy mode: no PinRef configured)" : ""; + if (slots.Count == 0) return "(no versions extracted yet)" + legacy; + // 终审顺手项:未钉住槽不再产生尾部 " | " 悬挂分隔符; + // extractedAt(本地时间,秒精度)入列——并行验收需要批次时间戳直接判据(异会话复验建议 + 首轮终审留档 Minor) + return string.Join("\n", slots.Select(s => + $"{s.SlotName} | BB {s.BootstrapBlazorVersion ?? "?"} | {s.Commit ?? "?"} | {s.Status} | {s.ExtractedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}{(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) + { + // Imp-2b:校验前置到 PinAndExtract 入口(空/非法 ref 返回错误文案且不落盘),MCP 侧不再以 SlotName 抛异常 + return _extraction.PinAndExtract(_settingsManager, args.Ref); + } + + 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 下的候选槽目录名(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('.') && File.Exists(VersionSlot.ManifestPath(outputDir, n!))) + .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 时表示找不到组件。 + /// outputDir 为 OutputDir 根:组件未命中且存在槽(含 manifest.json 的一级目录)时, + /// error 附跨槽存在性信息(EnrichNotFound);无槽(遗产根产物)则保持原样文案。 /// - private (string apiContent, string sampleContent, string? error) LoadComponentDocs(AppSettingsModel settings, string componentName) + private (string apiContent, string sampleContent, string? error) LoadComponentDocs(string root, string componentName, string outputDir) { - 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"); @@ -149,7 +295,12 @@ Based strictly on the API and examples provided above (and your general Blazor s if (!File.Exists(apiFile) && !File.Exists(sampleFile)) { - return (string.Empty, string.Empty, $"Component '{componentName}' not found in documentation."); + // 显式守卫(审核折入①):无槽可扫(遗产根产物)→ 原样文案,零附加; + // 遗产用例 NotFound_LegacyMode 的 Assert.Equal 逐字断言依赖此分支,不得移除。 + var error = HasAnySlot(outputDir) + ? EnrichNotFound(componentName, outputDir, CurrentSlotOrRoot(root)) + : $"Component '{componentName}' not found in documentation."; + return (string.Empty, string.Empty, error); } } @@ -162,4 +313,66 @@ Based strictly on the API and examples provided above (and your general Blazor s var sampleContent = File.Exists(sampleFile) ? File.ReadAllText(sampleFile) : "No sample code available."; return (apiContent, sampleContent, null); } + + /// + /// 组装 GetComponentDocs 返回文案(纯函数,可测)。section null/空白 → 全量(与既有返回逐字一致); + /// "api"/"samples"(大小写不敏感)→ 仅该节(节标题与全量逐字一致);其他值 → 用法提示(不抛错)。 + /// + internal static string ComposeDocument(string name, string api, string samples, string? section) + { + var s = section?.Trim().ToLowerInvariant(); + return s switch + { + null or "" => $"# Component: {name}\n\n## API\n{api}\n\n## Samples\n{samples}", + "api" => $"# Component: {name}\n\n## API\n{api}", + "samples" => $"# Component: {name}\n\n## Samples\n{samples}", + _ => $"Invalid section '{section}'. Use 'api' or 'samples', or omit for the full document." + }; + } + + /// + /// not-found 跨槽存在性提示(纯函数,可测;调用前提:OutputDir 下至少存在一个槽)。 + /// 遍历 outputDir 下含 manifest.json 的槽(与 ListSlots 同口径),检查 API/Samples 子目录中 + /// 文件名 OrdinalIgnoreCase 匹配 componentName 的文档(与主路径 fuzzy 同规则);槽名按 Ordinal 字母序列出。 + /// + internal static string EnrichNotFound(string componentName, string outputDir, string currentSlotOrRoot) + { + var hits = Directory.EnumerateDirectories(outputDir) + .Select(Path.GetFileName) + .Where(n => !string.IsNullOrEmpty(n) && !n.StartsWith('.')) + .Where(n => File.Exists(VersionSlot.ManifestPath(outputDir, n!))) + .Where(n => SlotContainsComponent(Path.Combine(outputDir, n!), componentName)) + .Select(n => n!) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + return hits.Count > 0 + ? $"Component '{componentName}' not found in '{currentSlotOrRoot}'. It exists in: {string.Join(", ", hits)}." + : $"Component '{componentName}' not found in '{currentSlotOrRoot}'. It does not exist in any extracted version."; + } + + /// outputDir 下是否存在至少一个槽(含 manifest.json 的一级目录,与 ListSlots 同口径);不存在目录时安全返回 false。 + private static bool HasAnySlot(string outputDir) + => Directory.Exists(outputDir) + && Directory.EnumerateDirectories(outputDir) + .Select(Path.GetFileName) + .Any(n => !string.IsNullOrEmpty(n) && !n.StartsWith('.') && File.Exists(VersionSlot.ManifestPath(outputDir, n!))); + + /// 槽内是否存在指定组件的文档:扫描 API/Samples 子目录 *.md,文件名 OrdinalIgnoreCase 匹配; + /// 子目录缺失的槽静默跳过(Directory.EnumerateFiles 对不存在目录会抛异常)。 + private static bool SlotContainsComponent(string slotDir, string componentName) + { + foreach (var sub in new[] { "API", "Samples" }) + { + var dir = Path.Combine(slotDir, sub); + if (!Directory.Exists(dir)) continue; + if (Directory.EnumerateFiles(dir, "*.md").Any(f => + Path.GetFileNameWithoutExtension(f).Equals(componentName, StringComparison.OrdinalIgnoreCase))) + return true; + } + return false; + } + + /// 当前查询目标的人类可读标签:root 为槽(目录内含 manifest.json)时取槽名,否则(OutputDir 根产物)为 "root"。 + private static string CurrentSlotOrRoot(string root) + => File.Exists(Path.Combine(root, "manifest.json")) ? Path.GetFileName(root) : "root"; } diff --git a/Services/MlcAssemblyLoader.cs b/Services/MlcAssemblyLoader.cs new file mode 100644 index 0000000..3c8586a --- /dev/null +++ b/Services/MlcAssemblyLoader.cs @@ -0,0 +1,56 @@ +// 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; + +/// 已加载目标与其所属 MLC 上下文。用完必须 Dispose:MLC 的文件映射若滞留, +/// 会锁住目标 DLL 使下一次提取的 build 复制步骤 MSB3027 失败(v10.0.0 生产事故根因)。 +public sealed record LoadedTarget(Assembly Assembly, MetadataLoadContext Context) : IDisposable +{ + public void Dispose() => Context.Dispose(); +} + +/// +/// MetadataLoadContext 加载器:以元数据专用 ALC 从目标目录加载 BootstrapBlazor.dll, +/// 与主程序进程内加载的 BootstrapBlazor 包版本完全隔离(多版本钉住改造坑①的根治)。 +/// +public static class MlcAssemblyLoader +{ + /// 加载目录下的 BootstrapBlazor.dll(三部分 resolver:目标目录 + AspNetCore.App 共享框架 + NETCore 运行时目录)。 + public static LoadedTarget? 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 new LoadedTarget(mlc.LoadFromAssemblyPath(dll), mlc); + } +} 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; + } +} diff --git a/Services/VersionSlot.cs b/Services/VersionSlot.cs new file mode 100644 index 0000000..c4c368e --- /dev/null +++ b/Services/VersionSlot.cs @@ -0,0 +1,63 @@ +// 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.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 })); + } +} 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",