From b9a9dbd493cbe977691f94341385ad04927795f2 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 12 May 2026 00:57:18 +0300 Subject: [PATCH 1/7] feat(cli): SharpConsoleUI command center for the interactive shell Replace the prompt-first Spectre.Console SelectionPrompt loop (bare `dotnet skills` / `agents`) with a retained-mode SharpConsoleUI shell built on ConsoleWindowSystem + NavigationView. The classic prompt loop is preserved as RunClassicShellAsync and used automatically when stdin/stdout is redirected (CI, pipes, dumb terminals). InteractiveConsoleApp.Shell.cs (new) adds the command center: - NavigationView with pages for every HomeAction surface (Home, Skills, Installed, Collections, Bundles, Packages, Agents, Project, Analysis, Remove-all, Update-all, Settings, About) driven by the existing NavigationSurfaceManifest. - Page content reuses the existing BuildRich* Spectre renderables verbatim via SpectreRenderableControl - no rendering rewrite. - Selection flows are ListControl activation -> modal Windows with a ToolbarControl row of ButtonControls. Mutations call the Runtime installers (SkillInstaller / AgentInstaller / ProjectSkillRecommender) directly and re-render the affected page in place. - Interactive bottom status bar (StatusBarControl): per-page dynamic hints, clickable items, shortcut highlighting, ticking clock, live catalog summary, toast slot for action results. Ctrl+R / Ctrl+U / Ctrl+I shortcuts are also wired in the window key handler. - Main window: rounded subtle border, hidden title, Maximized; Esc and OnClosed both call ws.Shutdown(0). Modals: centered, non-minimizable / non-maximizable, Esc dismisses. - List selection theme is pinned so keyboard, mouse hover, and click all paint the same solid selection bar (otherwise the three list states render in three different colors). SharpConsoleUI 2.4.55 -> 2.4.61, and added to ManagedCode.DotnetSkills (the main tool had no SharpConsoleUI reference yet; the dep was only declared in the agents / dotnet-agents wrappers, which compile the same sources). All four projects build clean. All 613 tests pass. `dotnet skills ` (list / install / recommend / ...) is unchanged - only the bare interactive invocation is affected. --- .../ManagedCode.Agents.csproj | 2 +- .../ManagedCode.DotnetAgents.csproj | 2 +- .../InteractiveConsoleApp.Shell.cs | 1338 +++++++++++++++++ .../InteractiveConsoleApp.cs | 9 +- .../ManagedCode.DotnetSkills.csproj | 1 + 5 files changed, 1348 insertions(+), 4 deletions(-) create mode 100644 cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs diff --git a/cli/ManagedCode.Agents/ManagedCode.Agents.csproj b/cli/ManagedCode.Agents/ManagedCode.Agents.csproj index 435dce3..ad96f59 100644 --- a/cli/ManagedCode.Agents/ManagedCode.Agents.csproj +++ b/cli/ManagedCode.Agents/ManagedCode.Agents.csproj @@ -50,7 +50,7 @@ - + diff --git a/cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj b/cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj index b143852..cb61459 100644 --- a/cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj +++ b/cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj @@ -50,7 +50,7 @@ - + diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs new file mode 100644 index 0000000..97f2b07 --- /dev/null +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -0,0 +1,1338 @@ +// ----------------------------------------------------------------------------- +// SharpConsoleUI command center — the retained-mode, windowed interactive shell. +// +// This is the default surface for the bare `dotnet skills` (and `agents`) +// invocation. It replaces the prompt-first Spectre loop in +// InteractiveConsoleApp.cs with a NavigationView-driven shell: +// * each former Show* screen is a NavigationView page +// * Spectre renderables built by the existing BuildRich* helpers are hosted +// in SpectreRenderableControl +// * SelectionPrompt/Confirm flows become ListControl activation + modal +// windows with ButtonControls +// * mutating actions call the Runtime installers directly and re-render the +// affected page in place +// +// The classic prompt loop survives as RunClassicShellAsync and is used as a +// fallback when stdin/stdout is redirected (CI, pipes, dumb terminals). +// ----------------------------------------------------------------------------- + +using ManagedCode.DotnetSkills.Runtime; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Drivers; +using SharpConsoleUI.Helpers; +using SharpConsoleUI.Layout; +using SharpConsoleUI.Themes; +using SpectreRendering = Spectre.Console.Rendering; + +namespace ManagedCode.DotnetSkills; + +internal sealed partial class InteractiveConsoleApp +{ + // One selection treatment for every list mode (keyboard-highlight, mouse-hover, click). + // The list control otherwise renders three subtly different states: HighlightBackgroundColor + // for the focused selection, the theme's ListHoverBackgroundColor for mouse hover, and the + // theme's ListUnfocusedHighlightBackgroundColor when the list does not hold focus. We pin all + // of them so the bar looks the same regardless of how the row was reached. + private static readonly Color SelectionBg = new(150, 205, 255); + private static readonly Color SelectionFg = Color.Black; + private static readonly Color UnfocusedSelectionBg = new(44, 62, 92); + private static readonly Color UnfocusedSelectionFg = new(205, 218, 236); + private static readonly Color ShortcutAccent = new(130, 205, 255); + + // Live shell state for the dynamic status bar. + private ConsoleWindowSystem? _ws; + private ScrollablePanelControl? _activePanel; + private HomeAction? _currentPage; + private StatusBarControl? _statusBar; + private StatusBarItem? _clockItem; + private StatusBarItem? _statusMessage; + + private static readonly Color[] SectionPalette = + { + new(120, 180, 255), + new(120, 220, 160), + new(220, 170, 110), + new(195, 150, 230), + new(235, 150, 150), + new(150, 210, 220), + }; + + /// + /// Entry point for the bare interactive invocation. Launches the SharpConsoleUI + /// command center; falls back to the classic prompt loop when there is no real terminal. + /// + public async Task RunAsync() + { + if (Console.IsInputRedirected || Console.IsOutputRedirected) + { + return await RunClassicShellAsync(); + } + + try + { + toolUpdateStatus = await getToolUpdateStatusAsync(cachePath); + await LoadCatalogsAsync(refreshCatalog: false); + } + catch (Exception exception) + { + Console.Error.WriteLine($"Failed to load the skill catalog: {exception.Message}"); + return 1; + } + + try + { + var windowSystem = new ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer), BuildTheme()); + windowSystem.PanelStateService.ShowTopPanel = true; + windowSystem.PanelStateService.ShowBottomPanel = false; // replaced by the interactive StatusBarControl + windowSystem.PanelStateService.TopStatus = $"dotnet skills v{ToolVersionInfo.CurrentVersion} · command center"; + + CreateCommandCenter(windowSystem); + windowSystem.Run(); + return 0; + } + catch (Exception exception) + { + Console.Clear(); + ExceptionFormatter.WriteException(exception); + return 1; + } + } + + private void CreateCommandCenter(ConsoleWindowSystem ws) + { + _ws = ws; + + var installedCount = SafeCount(GetInstalledSkillCount); + var outdatedCount = SafeCount(GetOutdatedSkillCount); + var actions = GetHomeActions(installedCount, outdatedCount) + .Where(action => action.Action != HomeAction.Exit) + .ToArray(); + + var nav = Controls.NavigationView() + .WithNavWidth(30) + .WithPaneHeader("[bold rgb(120,180,255)] ◆ dotnet skills[/]") + .WithPaneDisplayMode(NavigationViewDisplayMode.Auto) + .WithExpandedThreshold(96) + .WithCompactThreshold(54) + .WithContentBorder(BorderStyle.Rounded) + .WithContentBorderColor(new Color(70, 100, 150)) + .WithContentPadding(1, 0, 1, 0) + .WithContentHeader(true) + .WithSelectedColors(Color.White, new Color(40, 80, 160)) + .AddItem(new NavigationItem("Home", icon: "◈", subtitle: "Session & telemetry"), panel => BuildHomePage(ws, panel)); + + var sectionIndex = 0; + foreach (var section in actions.GroupBy(action => action.Section)) + { + var color = SectionPalette[sectionIndex++ % SectionPalette.Length]; + nav = nav.AddHeader(section.Key, color, header => + { + foreach (var action in section) + { + var captured = action; + header.AddItem( + new NavigationItem(captured.Label, icon: "›", subtitle: captured.Summary) { Tag = captured.Action }, + panel => BuildActionPage(ws, panel, captured.Action)); + } + }); + } + + var navView = nav + .OnSelectedItemChanged((_, e) => RebuildStatusBar(e.NewItem?.Tag as HomeAction?)) + .WithAlignment(HorizontalAlignment.Stretch) + .WithVerticalAlignment(VerticalAlignment.Fill) + .Build(); + + _statusBar = new StatusBarControl(stickyBottom: true) + { + HorizontalAlignment = HorizontalAlignment.Stretch, + BackgroundColor = Color.Transparent, + ShortcutForegroundColor = ShortcutAccent, + SeparatorChar = "·", + ShortcutLabelSeparator = " ", + }; + + new WindowBuilder(ws) + .WithTitle("dotnet skills — command center") + .HideTitle() + .Maximized() + .Movable(false) + .Resizable(false) + .HideTitleButtons() + .WithBorderStyle(BorderStyle.Rounded) + .WithBorderColor(new Color(70, 88, 116)) + .WithAsyncWindowThread(ClockLoopAsync) + .OnKeyPressed((_, e) => HandleGlobalKey(e)) + .OnClosed((_, _) => ws.Shutdown(0)) + .AddControl(navView) + .AddControl(_statusBar) + .BuildAndShow(); + + RebuildStatusBar(null); + } + + private void HandleGlobalKey(KeyPressedEventArgs e) + { + var key = e.KeyInfo; + if (key.Key == ConsoleKey.Escape) + { + // Root window: Esc ends the session rather than dismissing the window. + _ws?.Shutdown(0); + e.Handled = true; + return; + } + + if ((key.Modifiers & ConsoleModifiers.Control) == 0) + { + return; + } + + switch (key.Key) + { + case ConsoleKey.R: + RefreshCatalogFromUi(); + e.Handled = true; + break; + case ConsoleKey.U when _currentPage == HomeAction.ManageInstalled: + UpdateAllOutdatedFromUi(); + e.Handled = true; + break; + case ConsoleKey.I when _currentPage == HomeAction.SyncProject: + InstallAllRecommendedFromUi(); + e.Handled = true; + break; + } + } + + // ------------------------------------------------------------------------- + // Page dispatch + // ------------------------------------------------------------------------- + + private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl panel, HomeAction action) + { + _activePanel = panel; + _currentPage = action; + switch (action) + { + case HomeAction.BrowseSkills: BuildSkillBrowserPage(ws, panel); break; + case HomeAction.ManageInstalled: BuildInstalledPage(ws, panel); break; + case HomeAction.BrowseCollections: BuildCollectionsPage(ws, panel); break; + case HomeAction.BrowseBundles: BuildBundlesPage(ws, panel, primaryOnly: true); break; + case HomeAction.BrowsePackages: BuildBundlesPage(ws, panel, primaryOnly: false); break; + case HomeAction.BrowseAgents: BuildAgentsPage(ws, panel); break; + case HomeAction.SyncProject: BuildProjectPage(ws, panel); break; + case HomeAction.Analysis: BuildAnalysisPage(ws, panel); break; + case HomeAction.RemoveAll: BuildRemoveAllPage(ws, panel); break; + case HomeAction.UpdateAll: BuildUpdateAllPage(ws, panel); break; + case HomeAction.Workspace: BuildSettingsPage(ws, panel); break; + case HomeAction.About: BuildAboutPage(panel); break; + default: + panel.ClearContents(); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel(action.ToString(), new Spectre.Console.Markup("[dim]Not available in this surface.[/]")))); + break; + } + } + + // ------------------------------------------------------------------------- + // Home + // ------------------------------------------------------------------------- + + private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + _activePanel = panel; + _currentPage = null; + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + var outdated = installed.Count(record => !record.IsCurrent); + + var session = BuildRichPropertyGrid( + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("platform", Escape(Session.Agent.ToString())), + ("scope", Escape(Session.Scope.ToString())), + ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), + ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]")); + + var telemetry = BuildRichCardGrid(new SpectreRendering.IRenderable[] + { + BuildRichMetricCard("skills", skillCatalog.Skills.Count.ToString(), "in catalog", "deepskyblue1"), + BuildRichMetricCard("bundles", GetPrimaryBundles().Count.ToString(), "focused", "turquoise2"), + BuildRichMetricCard("installed", $"{installed.Count}/{skillCatalog.Skills.Count}", "in current target", installed.Count > 0 ? "green" : "grey"), + BuildRichMetricCard("outdated", outdated.ToString(), outdated == 0 ? "all current" : "need update", outdated == 0 ? "green" : "yellow"), + BuildRichMetricCard("agents", agentCatalog.Agents.Count.ToString(), "orchestration", "mediumpurple2"), + }, maxColumns: 3); + + var quickStart = BuildRichDetailCard("quick start", "deepskyblue1", + "[dim]Use the rail on the left to browse and install.[/]", + "[grey]Skills[/] [dim]browse and install individual catalog skills[/]", + "[grey]Installed[/] [dim]update or remove what is already installed[/]", + "[grey]Project[/] [dim]scan the current solution and install recommended skills[/]", + "[grey]Agents[/] [dim]install orchestration agents into native agent directories[/]"); + + var parts = new List + { + BuildRichShellPanel("session", session), + BuildRichShellPanel("catalog telemetry", telemetry), + }; + + var update = BuildToolUpdatePanel(toolUpdateStatus); + if (update is not null) + { + parts.Add(update); + } + + parts.Add(quickStart); + + panel.AddControl(new SpectreRenderableControl(BuildRichStack(parts.ToArray()))); + } + + // ------------------------------------------------------------------------- + // Skill browser + // ------------------------------------------------------------------------- + + private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + var available = skillCatalog.Skills + .Where(skill => installed.All(record => !string.Equals(record.Skill.Name, skill.Name, StringComparison.OrdinalIgnoreCase))) + .OrderBy(skill => CatalogOrganization.GetStackRank(skill.Stack)) + .ThenBy(skill => skill.Stack, StringComparer.Ordinal) + .ThenBy(skill => skill.Name, StringComparer.Ordinal) + .ToArray(); + + var summary = BuildRichPropertyGrid( + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("available", available.Length.ToString()), + ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}")); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("skill browser", summary, "turquoise2"))); + + if (available.Length == 0) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("available", new Spectre.Console.Markup("[dim]Every catalog skill is already installed in this target.[/]")))); + return; + } + + var list = StyledList("Available skills (Enter for details)") + .MaxVisibleItems(16) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var skill in available) + { + list.AddItem(BuildSkillChoiceLabel(skill, installed), skill); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is SkillEntry skill) + { + ShowSkillDetailModal(ws, panel, skill); + } + }); + panel.AddControl(list.Build()); + } + + private void ShowSkillDetailModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillEntry skill) + { + var detail = BuildRichStack( + BuildRichShellPanel(ToAlias(skill.Name), BuildRichPropertyGrid( + ("skill", Escape(skill.Name)), + ("collection", Escape(skill.Stack)), + ("lane", Escape(skill.Lane)), + ("version", Escape(skill.Version)), + ("tokens", FormatTokenCount(skill.TokenCount))), "turquoise2"), + BuildRichShellPanel("summary", new Spectre.Console.Markup(Escape(skill.Description))), + BuildRichShellPanel("preview", new Spectre.Console.Markup(Escape(LoadSkillPreview(skill))))); + + ShowModal(ws, $"Skill · {ToAlias(skill.Name)}", detail, + ("Install into current target", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + Toast(summary is null + ? $"Install failed for {ToAlias(skill.Name)}" + : $"{ToAlias(skill.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + BuildSkillBrowserPage(ws, owner); + }), + ("Force reinstall", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: true), default(SkillInstallSummary)); + Toast(summary is null ? $"Install failed for {ToAlias(skill.Name)}" : $"{ToAlias(skill.Name)}: reinstalled ({summary.InstalledCount} written)"); + BuildSkillBrowserPage(ws, owner); + })); + } + + // ------------------------------------------------------------------------- + // Installed skills + // ------------------------------------------------------------------------- + + private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) + .OrderBy(record => record.Skill.Name, StringComparer.Ordinal) + .ToArray(); + var outdated = installed.Where(record => !record.IsCurrent).ToArray(); + + var summary = BuildRichPropertyGrid( + ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("installed", installed.Length.ToString()), + ("outdated", outdated.Length == 0 ? "[green]0[/]" : $"[yellow]{outdated.Length}[/]"), + ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount)))); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("installed skills", summary, "green"))); + + if (installed.Length == 0) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("installed", new Spectre.Console.Markup("[dim]No catalog skills are installed in this target yet. Visit the Skills page to add some.[/]")))); + return; + } + + var list = StyledList("Installed skills (Enter for details)") + .MaxVisibleItems(14) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var record in installed) + { + list.AddItem((record.IsCurrent ? "✓ " : "↻ ") + BuildInstalledSkillChoiceLabel(record), record); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is InstalledSkillRecord record) + { + ShowInstalledSkillModal(ws, panel, record); + } + }); + panel.AddControl(list.Build()); + + if (outdated.Length > 0) + { + panel.AddControl(Controls.Button($"Update all {outdated.Length} outdated skill(s)") + .OnClick((_, _) => + { + var summaryText = UpdateSkillRecords(outdated); + Toast(summaryText); + BuildInstalledPage(ws, panel); + }).Build()); + } + + panel.AddControl(Controls.Button($"Remove all {installed.Length} installed skill(s)") + .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?", + $"This removes every catalog skill from {layout.PrimaryRoot.FullName}.", + () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); + Toast(summary is null ? "Remove failed" : $"Removed {summary.RemovedCount} skill(s)"); + BuildInstalledPage(ws, panel); + })).Build()); + } + + private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, InstalledSkillRecord record) + { + var detail = BuildRichStack( + BuildRichShellPanel(ToAlias(record.Skill.Name), BuildRichPropertyGrid( + ("skill", Escape(record.Skill.Name)), + ("collection", Escape($"{record.Skill.Stack} / {record.Skill.Lane}")), + ("installed", Escape(record.InstalledVersion)), + ("latest", Escape(record.Skill.Version)), + ("status", record.IsCurrent ? "[green]✓ current[/]" : "[yellow]↻ update available[/]"), + ("tokens", FormatTokenCount(record.Skill.TokenCount))), "green"), + BuildRichShellPanel("summary", new Spectre.Console.Markup(Escape(record.Skill.Description)))); + + var buttons = new List<(string, Action)>(); + if (!record.IsCurrent) + { + buttons.Add(($"Update to {record.Skill.Version}", () => + { + Toast(UpdateSkillRecords(new[] { record })); + BuildInstalledPage(ws, owner); + })); + } + buttons.Add(("Reinstall (force)", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { record.Skill }, ResolveSkillLayout(), force: true), default(SkillInstallSummary)); + Toast(summary is null ? "Reinstall failed" : $"{ToAlias(record.Skill.Name)}: reinstalled"); + BuildInstalledPage(ws, owner); + })); + buttons.Add(("Remove", () => ConfirmModal(ws, $"Remove {ToAlias(record.Skill.Name)}?", $"Deletes the skill directory from {ResolveSkillLayout().PrimaryRoot.FullName}.", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(new[] { record.Skill }, ResolveSkillLayout()), default(SkillRemoveSummary)); + Toast(summary is null ? "Remove failed" : $"Removed {ToAlias(record.Skill.Name)}"); + BuildInstalledPage(ws, owner); + }))); + + ShowModal(ws, $"Installed · {ToAlias(record.Skill.Name)}", detail, buttons.ToArray()); + } + + private string UpdateSkillRecords(IReadOnlyList records) + { + var layout = ResolveSkillLayout(); + var skills = records.Select(record => record.Skill).ToArray(); + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, layout, force: true), default(SkillInstallSummary)); + return summary is null ? "Update failed" : $"Updated {summary.InstalledCount} skill(s)"; + } + + // ------------------------------------------------------------------------- + // Collections + // ------------------------------------------------------------------------- + + private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + var views = BuildCollectionViews(installed) + .OrderBy(view => CatalogOrganization.GetStackRank(view.Collection)) + .ThenBy(view => view.Collection, StringComparer.Ordinal) + .ToArray(); + + var summary = BuildRichPropertyGrid( + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("collections", views.Length.ToString()), + ("skills", skillCatalog.Skills.Count.ToString()), + ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}")); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("collection browser", summary))); + + if (views.Length == 0) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("collections", new Spectre.Console.Markup("[dim]No collections in this catalog version.[/]")))); + return; + } + + var cards = views.Select(view => (SpectreRendering.IRenderable)BuildRichDetailCard( + view.Collection, "deepskyblue1", + $"[dim]lanes[/] {view.Lanes.Count} [dim]skills[/] {view.InstalledCount}/{view.SkillCount} [dim]tokens[/] {FormatTokenCount(view.TokenCount)}", + $"[grey]{Escape(string.Join(", ", view.Lanes.Take(6).Select(lane => lane.Lane)))}[/]")).ToArray(); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("overview", BuildRichCardGrid(cards, maxColumns: 2)))); + + var list = StyledList("Collections (Enter to install the whole collection)") + .MaxVisibleItems(14) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var view in views) + { + list.AddItem(BuildCollectionChoiceLabel(view), view); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is CollectionCatalogView view) + { + ConfirmModal(ws, $"Install collection {view.Collection}?", + $"Installs all {view.SkillCount} skill(s) from this collection into {ResolveSkillLayout().PrimaryRoot.FullName}.", + () => + { + var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromCollections(new[] { view.Collection }), Array.Empty()); + var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + Toast(summary is null ? $"Could not install collection {view.Collection}" : $"{view.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + BuildCollectionsPage(ws, panel); + }); + } + }); + panel.AddControl(list.Build()); + } + + // ------------------------------------------------------------------------- + // Bundles / packages + // ------------------------------------------------------------------------- + + private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl panel, bool primaryOnly) + { + panel.ClearContents(); + + var packages = (primaryOnly + ? GetPrimaryBundles() + : skillCatalog.Packages.OrderBy(p => p.Name, StringComparer.Ordinal).ToArray()) + .ToArray(); + var title = primaryOnly ? "focused bundles" : "catalog packages"; + var skillTokens = skillCatalog.Skills.ToDictionary(skill => skill.Name, skill => skill.TokenCount, StringComparer.OrdinalIgnoreCase); + + var summary = BuildRichPropertyGrid( + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + (primaryOnly ? "bundles" : "packages", packages.Length.ToString()), + ("skills covered", skillCatalog.Skills.Count.ToString())); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel(title, summary))); + + if (packages.Length == 0) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel(title, new Spectre.Console.Markup("[dim]Nothing available in this catalog version.[/]")))); + return; + } + + var list = StyledList($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)") + .MaxVisibleItems(16) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var package in packages) + { + var tokenCount = package.Skills.Sum(name => skillTokens.TryGetValue(name, out var value) ? value : 0); + list.AddItem($"{package.Name} [dim]({package.Skills.Count} skills, {FormatTokenCount(tokenCount)} tokens)[/]", package); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is SkillPackageEntry package) + { + ShowBundleModal(ws, panel, package, primaryOnly); + } + }); + panel.AddControl(list.Build()); + } + + private void ShowBundleModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillPackageEntry package, bool primaryOnly) + { + var detail = BuildRichStack( + BuildRichShellPanel(package.Name, BuildRichPropertyGrid( + ("package", Escape(package.Name)), + ("title", Escape(package.Title)), + ("skills", package.Skills.Count.ToString()), + ("includes", Escape(string.Join(", ", package.Skills.Take(10).Select(ToAlias))))), "turquoise2"), + BuildRichShellPanel("summary", new Spectre.Console.Markup(Escape(package.Description)))); + + ShowModal(ws, $"Bundle · {package.Name}", detail, + ("Install bundle into current target", () => + { + var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromPackages(new[] { package.Name }), Array.Empty()); + var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + Toast(summary is null ? $"Could not install bundle {package.Name}" : $"{package.Name}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + BuildBundlesPage(ws, owner, primaryOnly); + })); + } + + // ------------------------------------------------------------------------- + // Agents + // ------------------------------------------------------------------------- + + private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = TryResolveAgentLayout(out var layoutError); + var installer = new AgentInstaller(agentCatalog); + var installed = layout is null + ? Array.Empty() + : SafeGet(() => installer.GetInstalledAgents(layout), Array.Empty()); + + var summary = BuildRichPropertyGrid( + ("agents", agentCatalog.Agents.Count.ToString()), + ("platform", Escape(Session.Agent.ToString())), + ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}")); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("orchestration agents", summary, "mediumpurple2"))); + + if (agentCatalog.Agents.Count == 0) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("agents", new Spectre.Console.Markup("[dim]No agents available in the catalog.[/]")))); + return; + } + + var list = StyledList("Agents (Enter for details)") + .MaxVisibleItems(14) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var agent in agentCatalog.Agents.OrderBy(a => a.Name, StringComparer.Ordinal)) + { + var isInstalled = installed.Any(i => string.Equals(i.Agent.Name, agent.Name, StringComparison.OrdinalIgnoreCase)); + list.AddItem($"{(isInstalled ? "✓ " : "○ ")}{ToAlias(agent.Name)} [dim]{Escape(CompactDescription(agent.Description))}[/]", agent); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is AgentEntry agent) + { + ShowAgentModal(ws, panel, agent); + } + }); + panel.AddControl(list.Build()); + + if (layout is null) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("note", new Spectre.Console.Markup("[yellow]No native agent directory resolved. Set the platform on the Settings page, or create one of .codex/.claude/.github/.gemini/.junie.[/]")))); + return; + } + + panel.AddControl(Controls.Button("Install all agents into detected native directories") + .OnClick((_, _) => + { + var detected = SafeGet(() => AgentInstallTarget.ResolveAllDetected(Session.ProjectDirectory, Session.Scope), Array.Empty()); + if (detected.Count == 0) + { + Toast("No native agent directories detected"); + return; + } + var summary2 = SafeGet(() => new AgentInstaller(agentCatalog).InstallToMultiple(agentCatalog.Agents, detected, force: false), default(AgentInstallSummary)); + Toast(summary2 is null ? "Install failed" : $"Installed {summary2.InstalledCount} agent file(s) across {detected.Count} platform(s)"); + BuildAgentsPage(ws, panel); + }).Build()); + } + + private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, AgentEntry agent) + { + var detail = BuildRichStack( + BuildRichShellPanel(ToAlias(agent.Name), BuildRichPropertyGrid( + ("agent", Escape(agent.Name)), + ("skills", agent.Skills.Count == 0 ? "[dim]-[/]" : Escape(string.Join(", ", agent.Skills.Select(ToAlias)))), + ("platform", Escape(Session.Agent.ToString()))), "mediumpurple2"), + BuildRichShellPanel("summary", new Spectre.Console.Markup(Escape(agent.Description)))); + + var buttons = new List<(string, Action)>(); + var layout = TryResolveAgentLayout(out _); + if (layout is not null) + { + buttons.Add(("Install into current target", () => + { + var summary = SafeGet(() => new AgentInstaller(agentCatalog).Install(new[] { agent }, layout, force: false), default(AgentInstallSummary)); + Toast(summary is null ? "Install failed" : $"{ToAlias(agent.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + BuildAgentsPage(ws, owner); + })); + buttons.Add(("Remove from current target", () => + { + var summary = SafeGet(() => new AgentInstaller(agentCatalog).Remove(new[] { agent }, layout), default(AgentRemoveSummary)); + Toast(summary is null ? "Remove failed" : $"Removed {ToAlias(agent.Name)} ({summary.RemovedCount} file(s))"); + BuildAgentsPage(ws, owner); + })); + } + + ShowModal(ws, $"Agent · {ToAlias(agent.Name)}", detail, buttons.ToArray()); + } + + // ------------------------------------------------------------------------- + // Project sync / recommend + // ------------------------------------------------------------------------- + + private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var scan = SafeGet(() => new ProjectSkillRecommender(skillCatalog).Analyze(Session.ProjectDirectory), null); + if (scan is null) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("project scan", new Spectre.Console.Markup("[red]Could not scan the project directory.[/]")))); + return; + } + + var installer = new SkillInstaller(skillCatalog); + var installedByName = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) + .ToDictionary(record => record.Skill.Name, StringComparer.OrdinalIgnoreCase); + + var high = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.High); + var med = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Medium); + var low = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Low); + + var summary = BuildRichPropertyGrid( + ("project", $"[dim]{Escape(CompactPath(scan.ProjectRoot.FullName))}[/]"), + ("scanned", $"{scan.ProjectFiles.Count} project file(s)"), + ("frameworks", scan.TargetFrameworks.Count == 0 ? "[dim]unknown[/]" : Escape(string.Join(", ", scan.TargetFrameworks))), + ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("recommendations", $"{scan.Recommendations.Count} [dim]([/][green]{high} high[/][dim] · [/][yellow]{med} med[/][dim] · [/][grey]{low} low[/][dim])[/]")); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("project scan", summary))); + + if (scan.Recommendations.Count == 0) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("recommendations", new Spectre.Console.Markup("[dim]No package or framework signals matched the catalog. Start with the[/] [green]dotnet[/] [dim]and[/] [green]modern-csharp[/] [dim]skills from the Skills page.[/]")))); + return; + } + + var list = StyledList("Recommended skills (Enter to install)") + .MaxVisibleItems(16) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var recommendation in scan.Recommendations + .OrderByDescending(r => r.Confidence) + .ThenBy(r => r.Skill.Name, StringComparer.Ordinal)) + { + var marker = recommendation.Confidence switch + { + RecommendationConfidence.High => "[green]●●●[/]", + RecommendationConfidence.Medium => "[yellow]●●○[/]", + _ => "[grey]●○○[/]", + }; + installedByName.TryGetValue(recommendation.Skill.Name, out var record); + var status = record is null ? "[deepskyblue1]new[/]" : record.IsCurrent ? "[green]installed[/]" : "[yellow]update[/]"; + list.AddItem($"{marker} {ToAlias(recommendation.Skill.Name)} [dim]{status}[/] [grey]{Escape(string.Join("; ", recommendation.Reasons.Take(2)))}[/]", recommendation); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is ProjectSkillRecommendation recommendation) + { + var summary2 = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { recommendation.Skill }, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + Toast(summary2 is null ? $"Install failed for {ToAlias(recommendation.Skill.Name)}" : $"{ToAlias(recommendation.Skill.Name)}: {summary2.InstalledCount} written, {summary2.SkippedExisting.Count} skipped"); + BuildProjectPage(ws, panel); + } + }); + panel.AddControl(list.Build()); + + var installable = scan.Recommendations + .Where(r => !installedByName.TryGetValue(r.Skill.Name, out var rec) || !rec.IsCurrent) + .Select(r => r.Skill) + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .ToArray(); + if (installable.Length > 0) + { + panel.AddControl(Controls.Button($"Install all {installable.Length} recommended skill(s)") + .OnClick((_, _) => + { + var summary2 = SafeGet(() => new SkillInstaller(skillCatalog).Install(installable, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + Toast(summary2 is null ? "Install failed" : $"Installed {summary2.InstalledCount}, skipped {summary2.SkippedExisting.Count}"); + BuildProjectPage(ws, panel); + }).Build()); + } + } + + // ------------------------------------------------------------------------- + // Catalog analysis + // ------------------------------------------------------------------------- + + private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + var views = BuildCollectionViews(installed) + .OrderByDescending(view => view.SkillCount) + .ToArray(); + var signals = SafeGet(BuildPackageSignals, Array.Empty()); + var heaviest = skillCatalog.Skills.OrderByDescending(skill => skill.TokenCount).Take(12).ToArray(); + + var summary = BuildRichPropertyGrid( + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("collections", views.Length.ToString()), + ("skills", skillCatalog.Skills.Count.ToString()), + ("total tokens", FormatTokenCount(skillCatalog.Skills.Sum(skill => skill.TokenCount))), + ("package signals", signals.Count.ToString())); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("catalog analysis", summary))); + + var collectionCards = views.Take(12).Select(view => (SpectreRendering.IRenderable)BuildRichDetailCard( + view.Collection, "deepskyblue1", + $"[dim]skills[/] {view.SkillCount} [dim]installed[/] {view.InstalledCount} [dim]tokens[/] {FormatTokenCount(view.TokenCount)}")).ToArray(); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("collections by size", BuildRichCardGrid(collectionCards, maxColumns: 3)))); + + var heavyList = StyledList("Heaviest skills (Enter for details)") + .MaxVisibleItems(12) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var skill in heaviest) + { + heavyList.AddItem($"{FormatTokenCount(skill.TokenCount)} tokens · {ToAlias(skill.Name)} [dim]{Escape(skill.Stack)}[/]", skill); + } + heavyList.OnItemActivated((_, item) => + { + if (item.Tag is SkillEntry skill) + { + ShowSkillDetailModal(ws, panel, skill); + } + }); + panel.AddControl(heavyList.Build()); + + if (signals.Count > 0) + { + var signalCards = signals.Take(18).Select(signal => (SpectreRendering.IRenderable)new Spectre.Console.Markup( + $"[grey]{Escape(signal.Signal)}[/] [dim]({Escape(signal.Kind)})[/] [dim]→[/] {Escape(ToAlias(signal.Skill.Name))}")).ToArray(); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("package signals", BuildRichStack(signalCards)))); + } + } + + // ------------------------------------------------------------------------- + // Remove all / Update all action pages + // ------------------------------------------------------------------------- + + private void BuildRemoveAllPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("remove all installed skills", BuildRichPropertyGrid( + ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("installed", installed.Count.ToString()))))); + + if (installed.Count == 0) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("status", new Spectre.Console.Markup("[dim]Nothing to remove in this target.[/]")))); + return; + } + + panel.AddControl(Controls.Button($"Remove all {installed.Count} skill(s) from this target") + .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?", $"Deletes every catalog skill directory under {layout.PrimaryRoot.FullName}.", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); + Toast(summary is null ? "Remove failed" : $"Removed {summary.RemovedCount} skill(s)"); + BuildRemoveAllPage(ws, panel); + })).Build()); + } + + private void BuildUpdateAllPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var outdated = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) + .Where(record => !record.IsCurrent) + .ToArray(); + + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("update all outdated skills", BuildRichPropertyGrid( + ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("outdated", outdated.Length.ToString()))))); + + if (outdated.Length == 0) + { + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("status", new Spectre.Console.Markup("[green]All installed skills already match the catalog version.[/]")))); + return; + } + + var listCards = outdated.Select(record => (SpectreRendering.IRenderable)new Spectre.Console.Markup( + $"[yellow]↻[/] {Escape(ToAlias(record.Skill.Name))} [dim]{Escape(record.InstalledVersion)} → {Escape(record.Skill.Version)}[/]")).ToArray(); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("pending updates", BuildRichStack(listCards)))); + + panel.AddControl(Controls.Button($"Update all {outdated.Length} skill(s)") + .OnClick((_, _) => + { + Toast(UpdateSkillRecords(outdated)); + BuildUpdateAllPage(ws, panel); + }).Build()); + } + + // ------------------------------------------------------------------------- + // Settings / workspace + // ------------------------------------------------------------------------- + + private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var agentStatus = ResolveAgentStatus(); + var summary = BuildRichPropertyGrid( + ("platform", Escape(Session.Agent.ToString())), + ("scope", Escape(Session.Scope.ToString())), + ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), + ("skill target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("agent target", agentStatus.Layout is null ? $"[red]{Escape(agentStatus.Summary)}[/]" : $"[dim]{Escape(CompactPath(agentStatus.Layout.PrimaryRoot.FullName))}[/]"), + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("build", ToolVersionInfo.IsDevelopmentBuild ? "[dim]local development[/]" : "[green]published[/]")); + panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("workspace", summary))); + + var list = StyledList("Settings (Enter to change)") + .MaxVisibleItems(8); + list.AddItem($"Platform: {Session.Agent}", "platform"); + list.AddItem($"Install scope: {Session.Scope}", "scope"); + list.AddItem("Refresh catalog now", "refresh"); + list.OnItemActivated((_, item) => + { + switch (item.Tag as string) + { + case "platform": + ChooseEnumModal(ws, "Install platform", Enum.GetValues(), Session.Agent, value => + { + Session.Agent = value; + Toast($"Platform set to {value}"); + BuildSettingsPage(ws, panel); + }); + break; + case "scope": + ChooseEnumModal(ws, "Install scope", Enum.GetValues(), Session.Scope, value => + { + Session.Scope = value; + Toast($"Scope set to {value}"); + BuildSettingsPage(ws, panel); + }); + break; + case "refresh": + try + { + Toast("Refreshing catalog…"); + LoadCatalogsAsync(refreshCatalog: true).GetAwaiter().GetResult(); + Toast($"Catalog refreshed: {skillCatalog.CatalogVersion} ({skillCatalog.Skills.Count} skills)"); + } + catch (Exception exception) + { + Toast($"Refresh failed: {exception.Message}"); + } + BuildSettingsPage(ws, panel); + break; + } + }); + panel.AddControl(list.Build()); + } + + // ------------------------------------------------------------------------- + // About + // ------------------------------------------------------------------------- + + private void BuildAboutPage(ScrollablePanelControl panel) + { + panel.ClearContents(); + var about = BuildRichPropertyGrid( + ("tool", $"{Escape(ToolIdentity.DisplayCommand)}"), + ("package", Escape(ToolIdentity.PackageId)), + ("version", Escape(ToolVersionInfo.CurrentVersion)), + ("build", ToolVersionInfo.IsDevelopmentBuild ? "[dim]local development[/]" : "[green]published[/]"), + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("skills", skillCatalog.Skills.Count.ToString()), + ("agents", agentCatalog.Agents.Count.ToString())); + + var surface = BuildRichDetailCard("surface map", "deepskyblue1", + "[grey]Home[/] [dim]session, catalog telemetry, update notice[/]", + "[grey]Skills / Installed[/] [dim]browse, install, update, remove catalog skills[/]", + "[grey]Collections / Bundles / Packages[/] [dim]install grouped surfaces[/]", + "[grey]Agents[/] [dim]install orchestration agents into native agent directories[/]", + "[grey]Project[/] [dim]scan .csproj signals and install recommended skills[/]", + "[grey]Analysis[/] [dim]collection sizes, heaviest skills, package signals[/]"); + + var notes = BuildRichDetailCard("notes", "grey", + "[dim]This is the SharpConsoleUI command center. Run with redirected stdin/stdout to get the classic prompt shell instead.[/]", + "[dim]CLI sub-commands (list, install, recommend, …) are unchanged — see[/] [green]dotnet skills help[/][dim].[/]"); + + panel.AddControl(new SpectreRenderableControl(BuildRichStack( + BuildRichShellPanel("about", about), + surface, + notes))); + } + + // ------------------------------------------------------------------------- + // Modal + status helpers + // ------------------------------------------------------------------------- + + private void ShowModal(ConsoleWindowSystem ws, string title, SpectreRendering.IRenderable content, params (string Label, Action OnClick)[] buttons) + { + Window? modal = null; + var width = Math.Clamp(SafeConsole(() => Console.WindowWidth, 120) - 10, 56, 116); + var height = Math.Clamp(SafeConsole(() => Console.WindowHeight, 32) - 6, 14, 34); + + var body = Controls.ScrollablePanel().Build(); + body.AddControl(new SpectreRenderableControl(content)); + + void Close() + { + if (modal is not null) + { + ws.CloseWindow(modal); + } + } + + var toolbar = Controls.Toolbar().WithSpacing(2).WithAlignment(HorizontalAlignment.Center); + foreach (var (label, onClick) in buttons) + { + var captured = onClick; + toolbar.AddButton(label, (_, _) => { Close(); captured(); }); + } + toolbar.AddButton("Close", (_, _) => Close()); + + modal = new WindowBuilder(ws) + .WithTitle(title) + .WithSize(width, height) + .Centered() + .AsModal() + .Minimizable(false) + .Maximizable(false) + .WithBorderStyle(BorderStyle.Rounded) + .WithBorderColor(new Color(90, 110, 142)) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + Close(); + e.Handled = true; + } + }) + .AddControl(body) + .AddControl(toolbar.StickyBottom().Build()) + .BuildAndShow(); + } + + private void ConfirmModal(ConsoleWindowSystem ws, string title, string message, Action onConfirm) + { + ShowModal(ws, title, BuildRichShellPanel("confirm", new Spectre.Console.Markup($"[yellow]{Escape(message)}[/]"), "yellow"), + ("Yes, proceed", onConfirm)); + } + + private void ChooseEnumModal(ConsoleWindowSystem ws, string title, TEnum[] values, TEnum current, Action onPicked) + where TEnum : struct, Enum + { + Window? modal = null; + + void Close() + { + if (modal is not null) + { + ws.CloseWindow(modal); + } + } + + var list = StyledList(title).MaxVisibleItems(Math.Min(values.Length, 10)); + foreach (var value in values) + { + list.AddItem((value.Equals(current) ? "● " : " ") + value, value); + } + list.OnItemActivated((_, item) => + { + Close(); + if (item.Tag is TEnum picked) + { + onPicked(picked); + } + }); + + var toolbar = Controls.Toolbar().WithSpacing(2).WithAlignment(HorizontalAlignment.Center); + toolbar.AddButton("Cancel", (_, _) => Close()); + + modal = new WindowBuilder(ws) + .WithTitle(title) + .WithSize(Math.Clamp(values.Length == 0 ? 40 : values.Max(v => v.ToString().Length) + 24, 40, 70), Math.Min(values.Length + 8, 18)) + .Centered() + .AsModal() + .Minimizable(false) + .Maximizable(false) + .WithBorderStyle(BorderStyle.Rounded) + .WithBorderColor(new Color(90, 110, 142)) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + Close(); + e.Handled = true; + } + }) + .AddControl(list.Build()) + .AddControl(toolbar.StickyBottom().Build()) + .BuildAndShow(); + } + + private static ITheme BuildTheme() => new ModernGrayTheme + { + ListHoverBackgroundColor = SelectionBg, + ListHoverForegroundColor = SelectionFg, + ListUnfocusedHighlightBackgroundColor = UnfocusedSelectionBg, + ListUnfocusedHighlightForegroundColor = UnfocusedSelectionFg, + }; + + /// + /// A list control styled so the selected row is a solid inverted bar — the same bar whether + /// the row was reached by keyboard, mouse hover, or click (see ). + /// + private static ListBuilder StyledList(string? title = null) => Controls.List(title) + .WithScrollbarVisibility(ScrollbarVisibility.Auto) + .WithAutoHighlightOnFocus(true) + .WithHoverHighlighting(true) + .WithHighlightColors(SelectionFg, SelectionBg); + + // ------------------------------------------------------------------------- + // Interactive status bar (dynamic per page, clickable hints, highlighted keys) + // ------------------------------------------------------------------------- + + private void RebuildStatusBar(HomeAction? page) + { + var bar = _statusBar; + if (bar is null) + { + return; + } + + _currentPage = page; + bar.BatchUpdate(() => + { + bar.ClearAll(); + + bar.AddLeft("↑↓", "Move"); + bar.AddLeft("←→", "Switch pane"); + bar.AddLeft("Enter", page is HomeAction.SyncProject ? "Install" : page is HomeAction.Workspace ? "Change" : "Open"); + foreach (var (key, label, action) in PageShortcuts(page)) + { + bar.AddLeft(key, label, action); + } + bar.AddLeft("Ctrl+R", "Refresh", RefreshCatalogFromUi); + bar.AddLeft("Esc", "Quit", () => _ws?.Shutdown(0)); + + _statusMessage = bar.AddCenterText(string.Empty); + + bar.AddRightText($"[dim]v{Escape(skillCatalog.CatalogVersion)} · {skillCatalog.Skills.Count} skills[/]"); + bar.AddRightSeparator(); + _clockItem = bar.AddRightText(DateTime.Now.ToString("HH:mm:ss")); + }); + } + + private IEnumerable<(string Key, string Label, Action OnClick)> PageShortcuts(HomeAction? page) => page switch + { + HomeAction.ManageInstalled => new (string, string, Action)[] + { + ("Ctrl+U", "Update outdated", UpdateAllOutdatedFromUi), + ("Ctrl+Del", "Remove all", RemoveAllFromUi), + }, + HomeAction.SyncProject => new (string, string, Action)[] + { + ("Ctrl+I", "Install recommended", InstallAllRecommendedFromUi), + }, + _ => Array.Empty<(string, string, Action)>(), + }; + + private void RebuildActivePage() + { + if (_ws is null || _activePanel is null) + { + return; + } + + if (_currentPage is HomeAction action) + { + BuildActionPage(_ws, _activePanel, action); + } + else + { + BuildHomePage(_ws, _activePanel); + } + } + + private void Toast(string message) + { + if (_statusMessage is not null) + { + _statusMessage.Label = string.IsNullOrEmpty(message) ? string.Empty : $"[grey70]{Escape(message)}[/]"; + } + } + + private async Task ClockLoopAsync(Window window, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + if (_clockItem is not null) + { + _clockItem.Label = DateTime.Now.ToString("HH:mm:ss"); + window.Invalidate(false); + } + } + } + + private void RefreshCatalogFromUi() + { + try + { + Toast("Refreshing catalog…"); + LoadCatalogsAsync(refreshCatalog: true).GetAwaiter().GetResult(); + Toast($"Catalog refreshed: {skillCatalog.CatalogVersion} ({skillCatalog.Skills.Count} skills)"); + } + catch (Exception exception) + { + Toast($"Refresh failed: {exception.Message}"); + } + + RebuildStatusBar(_currentPage); + RebuildActivePage(); + } + + private void UpdateAllOutdatedFromUi() + { + var layout = ResolveSkillLayout(); + var outdated = SafeGet(() => new SkillInstaller(skillCatalog).GetInstalledSkills(layout), Array.Empty()) + .Where(record => !record.IsCurrent) + .ToArray(); + if (outdated.Length == 0) + { + Toast("No outdated skills in this target"); + return; + } + + Toast(UpdateSkillRecords(outdated)); + RebuildActivePage(); + } + + private void RemoveAllFromUi() + { + if (_ws is null) + { + return; + } + + var layout = ResolveSkillLayout(); + var installed = SafeGet(() => new SkillInstaller(skillCatalog).GetInstalledSkills(layout), Array.Empty()); + if (installed.Count == 0) + { + Toast("Nothing to remove in this target"); + return; + } + + ConfirmModal(_ws, "Remove all installed skills?", $"Deletes every catalog skill from {layout.PrimaryRoot.FullName}.", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(record => record.Skill).ToArray(), layout), default(SkillRemoveSummary)); + Toast(summary is null ? "Remove failed" : $"Removed {summary.RemovedCount} skill(s)"); + RebuildActivePage(); + }); + } + + private void InstallAllRecommendedFromUi() + { + var scan = SafeGet(() => new ProjectSkillRecommender(skillCatalog).Analyze(Session.ProjectDirectory), null); + if (scan is null) + { + Toast("Project scan failed"); + return; + } + + var layout = ResolveSkillLayout(); + var installedByName = SafeGet(() => new SkillInstaller(skillCatalog).GetInstalledSkills(layout), Array.Empty()) + .ToDictionary(record => record.Skill.Name, StringComparer.OrdinalIgnoreCase); + var installable = scan.Recommendations + .Where(r => !installedByName.TryGetValue(r.Skill.Name, out var rec) || !rec.IsCurrent) + .Select(r => r.Skill) + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .ToArray(); + if (installable.Length == 0) + { + Toast("No new recommended skills to install"); + return; + } + + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(installable, layout, force: false), default(SkillInstallSummary)); + Toast(summary is null ? "Install failed" : $"Installed {summary.InstalledCount}, skipped {summary.SkippedExisting.Count}"); + RebuildActivePage(); + } + + private static int SafeCount(Func getter) + { + try + { + return getter(); + } + catch + { + return 0; + } + } + + private static T SafeGet(Func getter, T fallback) + { + try + { + return getter(); + } + catch + { + return fallback; + } + } + + private static int SafeConsole(Func getter, int fallback) + { + try + { + var value = getter(); + return value > 0 ? value : fallback; + } + catch + { + return fallback; + } + } +} diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.cs index 2bcfca1..0f31a5a 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.cs @@ -4,7 +4,7 @@ namespace ManagedCode.DotnetSkills; -internal sealed class InteractiveConsoleApp +internal sealed partial class InteractiveConsoleApp { private readonly IInteractivePrompts prompts; private readonly Func> loadSkillCatalogAsync; @@ -46,7 +46,12 @@ public InteractiveConsoleApp( internal InteractiveSessionState Session { get; } - public async Task RunAsync() + /// + /// Legacy prompt-first interactive shell (Spectre based). + /// Retained as a fallback for non-interactive terminals; the default surface is the + /// SharpConsoleUI command center (see InteractiveConsoleApp.Shell.cs). + /// + public async Task RunClassicShellAsync() { toolUpdateStatus = await getToolUpdateStatusAsync(cachePath); await LoadCatalogsAsync(refreshCatalog: false); diff --git a/cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj b/cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj index c79749c..b9cfa1f 100644 --- a/cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj +++ b/cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj @@ -46,6 +46,7 @@ + From 42cc83687be06897660e6845b008adb963c60a2b Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Thu, 14 May 2026 00:01:57 +0300 Subject: [PATCH 2/7] feat(cli): live session state + dual status bars + frame rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the system-level top/bottom panels with two interactive StatusBarControls plus rule separators. Top bar carries session identity (project, scope, platform, catalog version); bottom bar keeps shortcuts + toast slot. Both stretch and use transparent backgrounds so the cxpost/cxfiles gradient shows through. InteractiveSessionState gains AgentChanged/ScopeChanged/ProjectChanged events and a SnapshotChanged signal. Each page builder calls AttachSessionEvents() to bind a fresh handler to the open page and detach the previous one — flipping scope or platform anywhere now refreshes the active page in place without re-navigating. RaiseSnapshotChanged() after a catalog refresh updates the top bar's version line through the same path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InteractiveConsoleApp.Shell.cs | 632 +++++++++++++----- .../InteractiveConsoleApp.cs | 67 +- 2 files changed, 524 insertions(+), 175 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index 97f2b07..b4fd228 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -1,12 +1,13 @@ // ----------------------------------------------------------------------------- // SharpConsoleUI command center — the retained-mode, windowed interactive shell. // -// This is the default surface for the bare `dotnet skills` (and `agents`) -// invocation. It replaces the prompt-first Spectre loop in -// InteractiveConsoleApp.cs with a NavigationView-driven shell: -// * each former Show* screen is a NavigationView page -// * Spectre renderables built by the existing BuildRich* helpers are hosted -// in SpectreRenderableControl +// This is the default surface for the bare `dotnet skills` invocation. (The +// `agents` / `dotnet-agents` wrappers still dispatch their bare invocation to +// the agents-list path in Program.cs; rerouting them through this command +// center is intentionally a follow-up.) It replaces the prompt-first Spectre +// loop in InteractiveConsoleApp.cs with a NavigationView-driven shell: +// * each former Show* screen is a NavigationView page rendered with native +// SharpConsoleUI controls (PanelControl + HorizontalGrid + MarkupControl) // * SelectionPrompt/Confirm flows become ListControl activation + modal // windows with ButtonControls // * mutating actions call the Runtime installers directly and re-render the @@ -23,8 +24,8 @@ using SharpConsoleUI.Drivers; using SharpConsoleUI.Helpers; using SharpConsoleUI.Layout; +using SharpConsoleUI.Rendering; using SharpConsoleUI.Themes; -using SpectreRendering = Spectre.Console.Rendering; namespace ManagedCode.DotnetSkills; @@ -41,6 +42,17 @@ internal sealed partial class InteractiveConsoleApp private static readonly Color UnfocusedSelectionFg = new(205, 218, 236); private static readonly Color ShortcutAccent = new(130, 205, 255); + // Accent palette — RGB equivalents of the xterm-256 color names this UI was originally + // designed around. Kept here so the palette has one canonical home. + private static readonly Color AccentDeepSkyBlue = new(0, 175, 255); // Spectre "deepskyblue1" + private static readonly Color AccentTurquoise = new(0, 215, 215); // Spectre "turquoise2" + private static readonly Color AccentMediumPurple = new(135, 95, 215); // Spectre "mediumpurple2" + private static readonly Color AccentSpringGreen = new(0, 175, 95); // Spectre "springgreen3" + private static readonly Color AccentGreen = new(0, 175, 0); // Spectre "green" + private static readonly Color AccentYellow = new(215, 175, 0); // Spectre "yellow" + private static readonly Color AccentGrey = new(135, 135, 135); // Spectre "grey" + private static readonly Color PanelBorderColor = new(70, 88, 116); // matches the root window border + // Live shell state for the dynamic status bar. private ConsoleWindowSystem? _ws; private ScrollablePanelControl? _activePanel; @@ -48,6 +60,15 @@ internal sealed partial class InteractiveConsoleApp private StatusBarControl? _statusBar; private StatusBarItem? _clockItem; private StatusBarItem? _statusMessage; + // Top status bar shows session identity (project, scope, agent, version) and updates + // live when InteractiveSessionState fires its change events. + private StatusBarControl? _topStatusBar; + private StatusBarItem? _topProjectItem; + private StatusBarItem? _topScopeItem; + private StatusBarItem? _topVersionItem; + // Unsubscribe handle for session-event subscriptions tied to the current page. Each page + // build resets this so subscriptions don't leak across page switches. + private Action? _detachSessionEvents; private static readonly Color[] SectionPalette = { @@ -84,9 +105,11 @@ public async Task RunAsync() try { var windowSystem = new ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer), BuildTheme()); - windowSystem.PanelStateService.ShowTopPanel = true; - windowSystem.PanelStateService.ShowBottomPanel = false; // replaced by the interactive StatusBarControl - windowSystem.PanelStateService.TopStatus = $"dotnet skills v{ToolVersionInfo.CurrentVersion} · command center"; + // Top/bottom system panels are both replaced by interactive StatusBarControl instances — + // the top one carries live session identity (project, scope, version), the bottom one + // carries shortcuts + toast slot. + windowSystem.PanelStateService.ShowTopPanel = false; + windowSystem.PanelStateService.ShowBottomPanel = false; CreateCommandCenter(windowSystem); windowSystem.Run(); @@ -145,6 +168,15 @@ private void CreateCommandCenter(ConsoleWindowSystem ws) .WithVerticalAlignment(VerticalAlignment.Fill) .Build(); + _topStatusBar = new StatusBarControl(stickyBottom: false) + { + StickyPosition = StickyPosition.Top, + HorizontalAlignment = HorizontalAlignment.Stretch, + BackgroundColor = Color.Transparent, + SeparatorChar = "·", + ShortcutLabelSeparator = " ", + }; + _statusBar = new StatusBarControl(stickyBottom: true) { HorizontalAlignment = HorizontalAlignment.Stretch, @@ -154,6 +186,15 @@ private void CreateCommandCenter(ConsoleWindowSystem ws) ShortcutLabelSeparator = " ", }; + // Rule separators above and below the content area (cxpost/cxfiles framing pattern). + var topRule = Controls.Rule(); + var bottomRule = Controls.Rule(); + topRule.StickyPosition = StickyPosition.Top; + bottomRule.StickyPosition = StickyPosition.Bottom; + + // Background gradient (cxpost / cxfiles house style — cool dark blue top to near-black bottom). + var backgroundGradient = ColorGradient.FromColors(new Color(25, 32, 52), new Color(7, 7, 13)); + new WindowBuilder(ws) .WithTitle("dotnet skills — command center") .HideTitle() @@ -163,14 +204,67 @@ private void CreateCommandCenter(ConsoleWindowSystem ws) .HideTitleButtons() .WithBorderStyle(BorderStyle.Rounded) .WithBorderColor(new Color(70, 88, 116)) + .WithBackgroundGradient(backgroundGradient, GradientDirection.Vertical) .WithAsyncWindowThread(ClockLoopAsync) .OnKeyPressed((_, e) => HandleGlobalKey(e)) .OnClosed((_, _) => ws.Shutdown(0)) + .AddControl(_topStatusBar) + .AddControl(topRule) .AddControl(navView) + .AddControl(bottomRule) .AddControl(_statusBar) .BuildAndShow(); RebuildStatusBar(null); + RebuildTopStatusBar(); + } + + /// + /// Repopulates the top status bar with current session identity. Called on initial build + /// and from session-change event subscriptions in BuildActionPage/BuildHomePage. + /// + private void RebuildTopStatusBar() + { + var bar = _topStatusBar; + if (bar is null) return; + + bar.BatchUpdate(() => + { + bar.ClearAll(); + _topProjectItem = bar.AddLeftText($"[bold rgb(120,180,255)]◆[/] [bold]dotnet skills[/] [grey50]v{Escape(ToolVersionInfo.CurrentVersion)}[/]"); + bar.AddLeftSeparator(); + bar.AddLeftText($"[grey50]project[/] {Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))}"); + bar.AddLeftSeparator(); + _topScopeItem = bar.AddLeftText($"[grey50]scope[/] {Escape(Session.Scope.ToString())} [grey50]·[/] [grey50]platform[/] {Escape(Session.Agent.ToString())}"); + + _topVersionItem = bar.AddRightText($"[grey50]catalog[/] {Escape(skillCatalog.CatalogVersion)} [grey50]·[/] {skillCatalog.Skills.Count} skills"); + }); + } + + /// + /// Replaces any prior session-event subscriptions with a fresh one bound to the active page, + /// so flipping Session.Scope/Agent/Project from anywhere refreshes the open page in place. + /// Must be called at the top of every page builder. + /// + private void AttachSessionEvents() + { + _detachSessionEvents?.Invoke(); + Action handler = () => + { + RebuildTopStatusBar(); + RebuildActivePage(); + }; + Session.AgentChanged += handler; + Session.ScopeChanged += handler; + Session.ProjectChanged += handler; + Session.SnapshotChanged += handler; + _detachSessionEvents = () => + { + Session.AgentChanged -= handler; + Session.ScopeChanged -= handler; + Session.ProjectChanged -= handler; + Session.SnapshotChanged -= handler; + }; } private void HandleGlobalKey(KeyPressedEventArgs e) @@ -203,6 +297,10 @@ private void HandleGlobalKey(KeyPressedEventArgs e) InstallAllRecommendedFromUi(); e.Handled = true; break; + case ConsoleKey.Delete when _currentPage == HomeAction.ManageInstalled: + RemoveAllFromUi(); + e.Handled = true; + break; } } @@ -214,13 +312,14 @@ private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl pane { _activePanel = panel; _currentPage = action; + AttachSessionEvents(); switch (action) { case HomeAction.BrowseSkills: BuildSkillBrowserPage(ws, panel); break; case HomeAction.ManageInstalled: BuildInstalledPage(ws, panel); break; case HomeAction.BrowseCollections: BuildCollectionsPage(ws, panel); break; case HomeAction.BrowseBundles: BuildBundlesPage(ws, panel, primaryOnly: true); break; - case HomeAction.BrowsePackages: BuildBundlesPage(ws, panel, primaryOnly: false); break; + case HomeAction.BrowsePackages: BuildPackagesPage(ws, panel); break; case HomeAction.BrowseAgents: BuildAgentsPage(ws, panel); break; case HomeAction.SyncProject: BuildProjectPage(ws, panel); break; case HomeAction.Analysis: BuildAnalysisPage(ws, panel); break; @@ -230,7 +329,7 @@ private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl pane case HomeAction.About: BuildAboutPage(panel); break; default: panel.ClearContents(); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel(action.ToString(), new Spectre.Console.Markup("[dim]Not available in this surface.[/]")))); + panel.AddControl(BuildNotePanel(action.ToString(), "[grey50]Not available in this surface.[/]", AccentGrey)); break; } } @@ -243,6 +342,7 @@ private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) { _activePanel = panel; _currentPage = null; + AttachSessionEvents(); panel.ClearContents(); var layout = ResolveSkillLayout(); @@ -250,44 +350,160 @@ private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); var outdated = installed.Count(record => !record.IsCurrent); - var session = BuildRichPropertyGrid( - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + panel.AddControl(BuildPropertyPanel("session", AccentDeepSkyBlue, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("platform", Escape(Session.Agent.ToString())), ("scope", Escape(Session.Scope.ToString())), ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), - ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]")); + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"))); + + // catalog telemetry — five native metric cards laid out by HorizontalGrid (responsive flex). + var installedAccent = installed.Count > 0 ? AccentGreen : AccentGrey; + var outdatedAccent = outdated == 0 ? AccentGreen : AccentYellow; + var telemetryGrid = Controls.HorizontalGrid() + .Column(col => col.Flex(1).Add(BuildMetricCard("skills", skillCatalog.Skills.Count.ToString(), "in catalog", AccentDeepSkyBlue))) + .Column(col => col.Flex(1).Add(BuildMetricCard("bundles", GetPrimaryBundles().Count.ToString(), "focused", AccentTurquoise))) + .Column(col => col.Flex(1).Add(BuildMetricCard("installed", $"{installed.Count}/{skillCatalog.Skills.Count}", "in current target", installedAccent))) + .Column(col => col.Flex(1).Add(BuildMetricCard("outdated", outdated.ToString(), outdated == 0 ? "all current" : "need update", outdatedAccent))) + .Column(col => col.Flex(1).Add(BuildMetricCard("agents", agentCatalog.Agents.Count.ToString(), "orchestration", AccentMediumPurple))) + .Build(); + panel.AddControl(telemetryGrid); + + if (toolUpdateStatus?.HasUpdate == true) + { + var freshness = toolUpdateStatus.CheckedAt is null + ? "[grey50]latest release detected[/]" + : toolUpdateStatus.UsedCachedValue + ? $"[grey50]cached[/] [grey]{Escape(toolUpdateStatus.CheckedAt.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}[/]" + : $"[grey50]checked[/] [grey]{Escape(toolUpdateStatus.CheckedAt.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}[/]"; + panel.AddControl(BuildBulletPanel("tool update", AccentYellow, + "[bold yellow]New dotnet-skills version available[/]", + $"[grey50]current[/] [grey]{Escape(toolUpdateStatus.CurrentVersion)}[/] [grey50]-> latest[/] [green]{Escape(toolUpdateStatus.LatestVersion ?? "?")}[/]", + $"[green]{Escape(GlobalToolUpdateCommand)}[/]", + $"[grey50]local tool manifest[/] [green]{Escape(LocalToolUpdateCommand)}[/]", + freshness)); + } - var telemetry = BuildRichCardGrid(new SpectreRendering.IRenderable[] - { - BuildRichMetricCard("skills", skillCatalog.Skills.Count.ToString(), "in catalog", "deepskyblue1"), - BuildRichMetricCard("bundles", GetPrimaryBundles().Count.ToString(), "focused", "turquoise2"), - BuildRichMetricCard("installed", $"{installed.Count}/{skillCatalog.Skills.Count}", "in current target", installed.Count > 0 ? "green" : "grey"), - BuildRichMetricCard("outdated", outdated.ToString(), outdated == 0 ? "all current" : "need update", outdated == 0 ? "green" : "yellow"), - BuildRichMetricCard("agents", agentCatalog.Agents.Count.ToString(), "orchestration", "mediumpurple2"), - }, maxColumns: 3); + panel.AddControl(BuildBulletPanel("quick start", AccentDeepSkyBlue, + "[grey50]Use the rail on the left to browse and install.[/]", + "[grey]Skills[/] [grey50]browse and install individual catalog skills[/]", + "[grey]Installed[/] [grey50]update or remove what is already installed[/]", + "[grey]Project[/] [grey50]scan the current solution and install recommended skills[/]", + "[grey]Agents[/] [grey50]install orchestration agents into native agent directories[/]")); + } - var quickStart = BuildRichDetailCard("quick start", "deepskyblue1", - "[dim]Use the rail on the left to browse and install.[/]", - "[grey]Skills[/] [dim]browse and install individual catalog skills[/]", - "[grey]Installed[/] [dim]update or remove what is already installed[/]", - "[grey]Project[/] [dim]scan the current solution and install recommended skills[/]", - "[grey]Agents[/] [dim]install orchestration agents into native agent directories[/]"); + // ------------------------------------------------------------------------- + // Native control helpers — every page and modal renders through these. + // ------------------------------------------------------------------------- - var parts = new List - { - BuildRichShellPanel("session", session), - BuildRichShellPanel("catalog telemetry", telemetry), - }; + /// + /// A native PanelControl with rounded border, themed header, and accent border color — + /// the visual equivalent of BuildRichShellPanel but drawn directly into the cell buffer + /// so its border aligns with the surrounding window chrome. + /// + private static PanelControl BuildSectionPanel(string title, string body, Color accent) => Controls.Panel() + .WithHeader($"[bold]{Escape(title)}[/]") + .WithBorderStyle(BorderStyle.Rounded) + .WithBorderColor(accent) + .WithPadding(1, 0, 1, 0) + .WithContent(body) + .WithAlignment(HorizontalAlignment.Stretch) + .Build(); + + /// + /// A native metric card: three stacked lines (title accent, value bold, detail grey) inside + /// a rounded PanelControl with an accent border. Used in HorizontalGrid columns. + /// + private static PanelControl BuildMetricCard(string title, string value, string detail, Color accent) + { + // Multi-line markup body — PanelControl splits on \n and wraps each line. + var body = string.Join("\n", + $"[bold]{Escape(value)}[/]", + $"[grey50]{Escape(detail)}[/]"); + return Controls.Panel() + .WithHeader($"[bold]{Escape(title)}[/]") + .WithBorderStyle(BorderStyle.Rounded) + .WithBorderColor(accent) + .WithPadding(1, 0, 1, 0) + .WithContent(body) + .WithAlignment(HorizontalAlignment.Stretch) + .Build(); + } + + /// + /// Formats one row of a property grid as " label value" — fixed-width left column so values + /// line up when stacked. Equivalent to BuildRichPropertyGrid's two-column grid but rendered + /// inline as markup text (cheaper, and PanelControl wraps the value if it overflows). + /// + private static string FormatRow(string label, string value) + { + const int labelWidth = 12; + var padded = label.Length >= labelWidth ? label : label + new string(' ', labelWidth - label.Length); + return $"[grey50]{Escape(padded)}[/] {value}"; + } + + /// + /// A native section panel whose body is a property grid built from label/value rows. + /// The native equivalent of BuildRichShellPanel(BuildRichPropertyGrid(...)). + /// + private static PanelControl BuildPropertyPanel(string title, Color accent, params (string Label, string Value)[] rows) + { + var body = string.Join("\n", rows.Select(r => FormatRow(r.Label, r.Value))); + return BuildSectionPanel(title, body, accent); + } + + /// + /// A native section panel containing a single markup line — used for empty-state notes and + /// short status messages. + /// + private static PanelControl BuildNotePanel(string title, string markup, Color accent) + => BuildSectionPanel(title, markup, accent); + + /// + /// A native section panel whose body is a vertical stack of markup lines — used for + /// "quick start", "surface map", and similar bullet-list cards. Lines are joined with \n + /// so PanelControl wraps each independently. + /// + private static PanelControl BuildBulletPanel(string title, Color accent, params string[] lines) + { + var body = string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l))); + return BuildSectionPanel(title, body, accent); + } - var update = BuildToolUpdatePanel(toolUpdateStatus); - if (update is not null) + /// + /// Lays out a sequence of cards in a responsive HorizontalGrid with 1, 2, or 3 columns based + /// on the current console width — the native equivalent of BuildRichCardGrid(maxColumns). + /// Empty columns at the end of the last row are padded with blank MarkupControls so the cards + /// keep equal width. + /// + private static IWindowControl BuildCardGrid(IReadOnlyList cards, int maxColumns = 3) + { + if (cards.Count == 0) { - parts.Add(update); + return new MarkupControl(new List { "[grey50]No items available.[/]" }); } - parts.Add(quickStart); + var consoleWidth = SafeConsole(() => Console.WindowWidth, 120); + var columnCount = consoleWidth >= 190 ? Math.Min(maxColumns, 3) + : consoleWidth >= 130 ? Math.Min(maxColumns, 2) + : 1; + columnCount = Math.Max(1, Math.Min(columnCount, cards.Count)); - panel.AddControl(new SpectreRenderableControl(BuildRichStack(parts.ToArray()))); + var grid = Controls.HorizontalGrid(); + for (var i = 0; i < columnCount; i++) + { + var columnIndex = i; + grid = grid.Column(col => + { + col.Flex(1); + for (var cardIndex = columnIndex; cardIndex < cards.Count; cardIndex += columnCount) + { + col.Add(cards[cardIndex]); + } + }); + } + + return grid.Build(); } // ------------------------------------------------------------------------- @@ -308,16 +524,15 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro .ThenBy(skill => skill.Name, StringComparer.Ordinal) .ToArray(); - var summary = BuildRichPropertyGrid( - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + panel.AddControl(BuildPropertyPanel("skill browser", AccentTurquoise, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), ("available", available.Length.ToString()), - ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}")); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("skill browser", summary, "turquoise2"))); + ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); if (available.Length == 0) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("available", new Spectre.Console.Markup("[dim]Every catalog skill is already installed in this target.[/]")))); + panel.AddControl(BuildNotePanel("available", "[grey50]Every catalog skill is already installed in this target.[/]", AccentDeepSkyBlue)); return; } @@ -326,7 +541,10 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro .WithScrollbarVisibility(ScrollbarVisibility.Auto); foreach (var skill in available) { - list.AddItem(BuildSkillChoiceLabel(skill, installed), skill); + // ListControl parses item text as markup; BuildSkillChoiceLabel produces plain + // text containing bracketed stack/lane like "[.NET Foundations / ...]". Escape so + // brackets are not interpreted as Spectre markup tags. + list.AddItem(Escape(BuildSkillChoiceLabel(skill, installed)), skill); } list.OnItemActivated((_, item) => { @@ -340,17 +558,19 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro private void ShowSkillDetailModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillEntry skill) { - var detail = BuildRichStack( - BuildRichShellPanel(ToAlias(skill.Name), BuildRichPropertyGrid( + var detail = new IWindowControl[] + { + BuildPropertyPanel(ToAlias(skill.Name), AccentTurquoise, ("skill", Escape(skill.Name)), ("collection", Escape(skill.Stack)), ("lane", Escape(skill.Lane)), ("version", Escape(skill.Version)), - ("tokens", FormatTokenCount(skill.TokenCount))), "turquoise2"), - BuildRichShellPanel("summary", new Spectre.Console.Markup(Escape(skill.Description))), - BuildRichShellPanel("preview", new Spectre.Console.Markup(Escape(LoadSkillPreview(skill))))); + ("tokens", FormatTokenCount(skill.TokenCount))), + BuildNotePanel("summary", Escape(skill.Description), AccentDeepSkyBlue), + BuildNotePanel("preview", Escape(LoadSkillPreview(skill)), AccentGrey), + }; - ShowModal(ws, $"Skill · {ToAlias(skill.Name)}", detail, + ShowModalNative(ws, $"Skill · {ToAlias(skill.Name)}", detail, ("Install into current target", () => { var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); @@ -382,16 +602,15 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p .ToArray(); var outdated = installed.Where(record => !record.IsCurrent).ToArray(); - var summary = BuildRichPropertyGrid( - ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + panel.AddControl(BuildPropertyPanel("installed skills", AccentGreen, + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), ("installed", installed.Length.ToString()), ("outdated", outdated.Length == 0 ? "[green]0[/]" : $"[yellow]{outdated.Length}[/]"), - ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount)))); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("installed skills", summary, "green"))); + ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount))))); if (installed.Length == 0) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("installed", new Spectre.Console.Markup("[dim]No catalog skills are installed in this target yet. Visit the Skills page to add some.[/]")))); + panel.AddControl(BuildNotePanel("installed", "[grey50]No catalog skills are installed in this target yet. Visit the Skills page to add some.[/]", AccentDeepSkyBlue)); return; } @@ -400,7 +619,8 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p .WithScrollbarVisibility(ScrollbarVisibility.Auto); foreach (var record in installed) { - list.AddItem((record.IsCurrent ? "✓ " : "↻ ") + BuildInstalledSkillChoiceLabel(record), record); + // Escape: the label contains "[stack / lane]" which would otherwise be parsed as markup. + list.AddItem((record.IsCurrent ? "✓ " : "↻ ") + Escape(BuildInstalledSkillChoiceLabel(record)), record); } list.OnItemActivated((_, item) => { @@ -435,15 +655,17 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, InstalledSkillRecord record) { - var detail = BuildRichStack( - BuildRichShellPanel(ToAlias(record.Skill.Name), BuildRichPropertyGrid( + var detail = new IWindowControl[] + { + BuildPropertyPanel(ToAlias(record.Skill.Name), AccentGreen, ("skill", Escape(record.Skill.Name)), ("collection", Escape($"{record.Skill.Stack} / {record.Skill.Lane}")), ("installed", Escape(record.InstalledVersion)), ("latest", Escape(record.Skill.Version)), ("status", record.IsCurrent ? "[green]✓ current[/]" : "[yellow]↻ update available[/]"), - ("tokens", FormatTokenCount(record.Skill.TokenCount))), "green"), - BuildRichShellPanel("summary", new Spectre.Console.Markup(Escape(record.Skill.Description)))); + ("tokens", FormatTokenCount(record.Skill.TokenCount))), + BuildNotePanel("summary", Escape(record.Skill.Description), AccentDeepSkyBlue), + }; var buttons = new List<(string, Action)>(); if (!record.IsCurrent) @@ -467,7 +689,7 @@ private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelCont BuildInstalledPage(ws, owner); }))); - ShowModal(ws, $"Installed · {ToAlias(record.Skill.Name)}", detail, buttons.ToArray()); + ShowModalNative(ws, $"Installed · {ToAlias(record.Skill.Name)}", detail, buttons.ToArray()); } private string UpdateSkillRecords(IReadOnlyList records) @@ -494,31 +716,30 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl .ThenBy(view => view.Collection, StringComparer.Ordinal) .ToArray(); - var summary = BuildRichPropertyGrid( - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + panel.AddControl(BuildPropertyPanel("collection browser", AccentDeepSkyBlue, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("collections", views.Length.ToString()), ("skills", skillCatalog.Skills.Count.ToString()), - ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}")); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("collection browser", summary))); + ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); if (views.Length == 0) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("collections", new Spectre.Console.Markup("[dim]No collections in this catalog version.[/]")))); + panel.AddControl(BuildNotePanel("collections", "[grey50]No collections in this catalog version.[/]", AccentDeepSkyBlue)); return; } - var cards = views.Select(view => (SpectreRendering.IRenderable)BuildRichDetailCard( - view.Collection, "deepskyblue1", - $"[dim]lanes[/] {view.Lanes.Count} [dim]skills[/] {view.InstalledCount}/{view.SkillCount} [dim]tokens[/] {FormatTokenCount(view.TokenCount)}", - $"[grey]{Escape(string.Join(", ", view.Lanes.Take(6).Select(lane => lane.Lane)))}[/]")).ToArray(); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("overview", BuildRichCardGrid(cards, maxColumns: 2)))); + var collectionCards = views.Select(view => BuildBulletPanel( + view.Collection, AccentDeepSkyBlue, + $"[grey50]lanes[/] {view.Lanes.Count} [grey50]skills[/] {view.InstalledCount}/{view.SkillCount} [grey50]tokens[/] {FormatTokenCount(view.TokenCount)}", + $"[grey]{Escape(string.Join(", ", view.Lanes.Take(6).Select(lane => lane.Lane)))}[/]")).ToList(); + panel.AddControl(BuildCardGrid(collectionCards, maxColumns: 2)); var list = StyledList("Collections (Enter to install the whole collection)") .MaxVisibleItems(14) .WithScrollbarVisibility(ScrollbarVisibility.Auto); foreach (var view in views) { - list.AddItem(BuildCollectionChoiceLabel(view), view); + list.AddItem(Escape(BuildCollectionChoiceLabel(view)), view); } list.OnItemActivated((_, item) => { @@ -553,15 +774,14 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var title = primaryOnly ? "focused bundles" : "catalog packages"; var skillTokens = skillCatalog.Skills.ToDictionary(skill => skill.Name, skill => skill.TokenCount, StringComparer.OrdinalIgnoreCase); - var summary = BuildRichPropertyGrid( - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + panel.AddControl(BuildPropertyPanel(title, AccentDeepSkyBlue, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), (primaryOnly ? "bundles" : "packages", packages.Length.ToString()), - ("skills covered", skillCatalog.Skills.Count.ToString())); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel(title, summary))); + ("skills covered", skillCatalog.Skills.Count.ToString()))); if (packages.Length == 0) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel(title, new Spectre.Console.Markup("[dim]Nothing available in this catalog version.[/]")))); + panel.AddControl(BuildNotePanel(title, "[grey50]Nothing available in this catalog version.[/]", AccentDeepSkyBlue)); return; } @@ -571,7 +791,7 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan foreach (var package in packages) { var tokenCount = package.Skills.Sum(name => skillTokens.TryGetValue(name, out var value) ? value : 0); - list.AddItem($"{package.Name} [dim]({package.Skills.Count} skills, {FormatTokenCount(tokenCount)} tokens)[/]", package); + list.AddItem($"{Escape(package.Name)} [dim]({package.Skills.Count} skills, {FormatTokenCount(tokenCount)} tokens)[/]", package); } list.OnItemActivated((_, item) => { @@ -585,15 +805,17 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan private void ShowBundleModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillPackageEntry package, bool primaryOnly) { - var detail = BuildRichStack( - BuildRichShellPanel(package.Name, BuildRichPropertyGrid( + var detail = new IWindowControl[] + { + BuildPropertyPanel(package.Name, AccentTurquoise, ("package", Escape(package.Name)), ("title", Escape(package.Title)), ("skills", package.Skills.Count.ToString()), - ("includes", Escape(string.Join(", ", package.Skills.Take(10).Select(ToAlias))))), "turquoise2"), - BuildRichShellPanel("summary", new Spectre.Console.Markup(Escape(package.Description)))); + ("includes", Escape(string.Join(", ", package.Skills.Take(10).Select(ToAlias))))), + BuildNotePanel("summary", Escape(package.Description), AccentDeepSkyBlue), + }; - ShowModal(ws, $"Bundle · {package.Name}", detail, + ShowModalNative(ws, $"Bundle · {package.Name}", detail, ("Install bundle into current target", () => { var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromPackages(new[] { package.Name }), Array.Empty()); @@ -603,6 +825,44 @@ private void ShowBundleModal(ConsoleWindowSystem ws, ScrollablePanelControl owne })); } + // ------------------------------------------------------------------------- + // Packages — NuGet ids / prefixes → catalog skills + // ------------------------------------------------------------------------- + + private void BuildPackagesPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var signals = SafeGet(BuildPackageSignals, Array.Empty()); + panel.AddControl(BuildPropertyPanel("package signals", AccentTurquoise, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("signals", signals.Count.ToString()), + ("skills covered", signals.Select(s => s.Skill.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count().ToString()))); + + if (signals.Count == 0) + { + panel.AddControl(BuildNotePanel("packages", "[grey50]No NuGet package or prefix signals are present in this catalog version.[/]", AccentDeepSkyBlue)); + return; + } + + var list = StyledList("Package signals (Enter to inspect linked skill)") + .MaxVisibleItems(16) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var signal in signals) + { + // ListControl renders item text as markup — escape the whole plain-text label. + list.AddItem(Escape($"{signal.Signal} [{signal.Kind}] -> {ToAlias(signal.Skill.Name)} [{signal.Skill.Stack} / {signal.Skill.Lane}] ({FormatTokenCount(signal.Skill.TokenCount)} tokens)"), signal); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is PackageSignalView signal) + { + ShowSkillDetailModal(ws, panel, signal.Skill); + } + }); + panel.AddControl(list.Build()); + } + // ------------------------------------------------------------------------- // Agents // ------------------------------------------------------------------------- @@ -617,16 +877,15 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane ? Array.Empty() : SafeGet(() => installer.GetInstalledAgents(layout), Array.Empty()); - var summary = BuildRichPropertyGrid( + panel.AddControl(BuildPropertyPanel("orchestration agents", AccentMediumPurple, ("agents", agentCatalog.Agents.Count.ToString()), ("platform", Escape(Session.Agent.ToString())), - ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}")); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("orchestration agents", summary, "mediumpurple2"))); + ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}"))); if (agentCatalog.Agents.Count == 0) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("agents", new Spectre.Console.Markup("[dim]No agents available in the catalog.[/]")))); + panel.AddControl(BuildNotePanel("agents", "[grey50]No agents available in the catalog.[/]", AccentDeepSkyBlue)); return; } @@ -636,7 +895,7 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane foreach (var agent in agentCatalog.Agents.OrderBy(a => a.Name, StringComparer.Ordinal)) { var isInstalled = installed.Any(i => string.Equals(i.Agent.Name, agent.Name, StringComparison.OrdinalIgnoreCase)); - list.AddItem($"{(isInstalled ? "✓ " : "○ ")}{ToAlias(agent.Name)} [dim]{Escape(CompactDescription(agent.Description))}[/]", agent); + list.AddItem($"{(isInstalled ? "✓ " : "○ ")}{Escape(ToAlias(agent.Name))} [dim]{Escape(CompactDescription(agent.Description))}[/]", agent); } list.OnItemActivated((_, item) => { @@ -649,7 +908,7 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane if (layout is null) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("note", new Spectre.Console.Markup("[yellow]No native agent directory resolved. Set the platform on the Settings page, or create one of .codex/.claude/.github/.gemini/.junie.[/]")))); + panel.AddControl(BuildNotePanel("note", "[yellow]No native agent directory resolved. Set the platform on the Settings page, or create one of .codex/.claude/.github/.gemini/.junie.[/]", AccentYellow)); return; } @@ -670,12 +929,14 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, AgentEntry agent) { - var detail = BuildRichStack( - BuildRichShellPanel(ToAlias(agent.Name), BuildRichPropertyGrid( + var detail = new IWindowControl[] + { + BuildPropertyPanel(ToAlias(agent.Name), AccentMediumPurple, ("agent", Escape(agent.Name)), - ("skills", agent.Skills.Count == 0 ? "[dim]-[/]" : Escape(string.Join(", ", agent.Skills.Select(ToAlias)))), - ("platform", Escape(Session.Agent.ToString()))), "mediumpurple2"), - BuildRichShellPanel("summary", new Spectre.Console.Markup(Escape(agent.Description)))); + ("skills", agent.Skills.Count == 0 ? "[grey50]-[/]" : Escape(string.Join(", ", agent.Skills.Select(ToAlias)))), + ("platform", Escape(Session.Agent.ToString()))), + BuildNotePanel("summary", Escape(agent.Description), AccentDeepSkyBlue), + }; var buttons = new List<(string, Action)>(); var layout = TryResolveAgentLayout(out _); @@ -695,7 +956,7 @@ private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner })); } - ShowModal(ws, $"Agent · {ToAlias(agent.Name)}", detail, buttons.ToArray()); + ShowModalNative(ws, $"Agent · {ToAlias(agent.Name)}", detail, buttons.ToArray()); } // ------------------------------------------------------------------------- @@ -710,7 +971,7 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var scan = SafeGet(() => new ProjectSkillRecommender(skillCatalog).Analyze(Session.ProjectDirectory), null); if (scan is null) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("project scan", new Spectre.Console.Markup("[red]Could not scan the project directory.[/]")))); + panel.AddControl(BuildNotePanel("project scan", "[red]Could not scan the project directory.[/]", new Color(200, 60, 60))); return; } @@ -722,17 +983,16 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var med = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Medium); var low = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Low); - var summary = BuildRichPropertyGrid( - ("project", $"[dim]{Escape(CompactPath(scan.ProjectRoot.FullName))}[/]"), + panel.AddControl(BuildPropertyPanel("project scan", AccentDeepSkyBlue, + ("project", $"[grey50]{Escape(CompactPath(scan.ProjectRoot.FullName))}[/]"), ("scanned", $"{scan.ProjectFiles.Count} project file(s)"), - ("frameworks", scan.TargetFrameworks.Count == 0 ? "[dim]unknown[/]" : Escape(string.Join(", ", scan.TargetFrameworks))), - ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("recommendations", $"{scan.Recommendations.Count} [dim]([/][green]{high} high[/][dim] · [/][yellow]{med} med[/][dim] · [/][grey]{low} low[/][dim])[/]")); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("project scan", summary))); + ("frameworks", scan.TargetFrameworks.Count == 0 ? "[grey50]unknown[/]" : Escape(string.Join(", ", scan.TargetFrameworks))), + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("recommendations", $"{scan.Recommendations.Count} [grey50]([/][green]{high} high[/][grey50] · [/][yellow]{med} med[/][grey50] · [/][grey]{low} low[/][grey50])[/]"))); if (scan.Recommendations.Count == 0) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("recommendations", new Spectre.Console.Markup("[dim]No package or framework signals matched the catalog. Start with the[/] [green]dotnet[/] [dim]and[/] [green]modern-csharp[/] [dim]skills from the Skills page.[/]")))); + panel.AddControl(BuildNotePanel("recommendations", "[grey50]No package or framework signals matched the catalog. Start with the[/] [green]dotnet[/] [grey50]and[/] [green]modern-csharp[/] [grey50]skills from the Skills page.[/]", AccentDeepSkyBlue)); return; } @@ -751,31 +1011,48 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan }; installedByName.TryGetValue(recommendation.Skill.Name, out var record); var status = record is null ? "[deepskyblue1]new[/]" : record.IsCurrent ? "[green]installed[/]" : "[yellow]update[/]"; - list.AddItem($"{marker} {ToAlias(recommendation.Skill.Name)} [dim]{status}[/] [grey]{Escape(string.Join("; ", recommendation.Reasons.Take(2)))}[/]", recommendation); + list.AddItem($"{marker} {Escape(ToAlias(recommendation.Skill.Name))} [dim]{status}[/] [grey]{Escape(string.Join("; ", recommendation.Reasons.Take(2)))}[/]", recommendation); } list.OnItemActivated((_, item) => { if (item.Tag is ProjectSkillRecommendation recommendation) { - var summary2 = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { recommendation.Skill }, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + // Outdated recommendations need force=true: SkillInstaller.Install skips + // existing skill directories unless forced, so an "update" entry would + // otherwise be reported as skipped and stay outdated. + var isOutdated = installedByName.TryGetValue(recommendation.Skill.Name, out var existing) && !existing.IsCurrent; + var summary2 = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { recommendation.Skill }, ResolveSkillLayout(), force: isOutdated), default(SkillInstallSummary)); Toast(summary2 is null ? $"Install failed for {ToAlias(recommendation.Skill.Name)}" : $"{ToAlias(recommendation.Skill.Name)}: {summary2.InstalledCount} written, {summary2.SkippedExisting.Count} skipped"); BuildProjectPage(ws, panel); } }); panel.AddControl(list.Build()); - var installable = scan.Recommendations - .Where(r => !installedByName.TryGetValue(r.Skill.Name, out var rec) || !rec.IsCurrent) + // Split recommendations: new ones install with force=false, outdated ones need + // force=true so the existing skill directory is overwritten with the latest version. + var newSkills = scan.Recommendations + .Where(r => !installedByName.ContainsKey(r.Skill.Name)) + .Select(r => r.Skill) + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .ToArray(); + var outdatedSkills = scan.Recommendations + .Where(r => installedByName.TryGetValue(r.Skill.Name, out var rec) && !rec.IsCurrent) .Select(r => r.Skill) .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) .ToArray(); + var installable = newSkills.Concat(outdatedSkills).ToArray(); if (installable.Length > 0) { panel.AddControl(Controls.Button($"Install all {installable.Length} recommended skill(s)") .OnClick((_, _) => { - var summary2 = SafeGet(() => new SkillInstaller(skillCatalog).Install(installable, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); - Toast(summary2 is null ? "Install failed" : $"Installed {summary2.InstalledCount}, skipped {summary2.SkippedExisting.Count}"); + var skillLayout = ResolveSkillLayout(); + var installer2 = new SkillInstaller(skillCatalog); + var newSummary = newSkills.Length == 0 ? default : SafeGet(() => installer2.Install(newSkills, skillLayout, force: false), default(SkillInstallSummary)); + var updateSummary = outdatedSkills.Length == 0 ? default : SafeGet(() => installer2.Install(outdatedSkills, skillLayout, force: true), default(SkillInstallSummary)); + var installedCount = (newSummary?.InstalledCount ?? 0) + (updateSummary?.InstalledCount ?? 0); + var skippedCount = (newSummary?.SkippedExisting.Count ?? 0) + (updateSummary?.SkippedExisting.Count ?? 0); + Toast(installedCount == 0 && skippedCount == 0 ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}"); BuildProjectPage(ws, panel); }).Build()); } @@ -798,25 +1075,24 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa var signals = SafeGet(BuildPackageSignals, Array.Empty()); var heaviest = skillCatalog.Skills.OrderByDescending(skill => skill.TokenCount).Take(12).ToArray(); - var summary = BuildRichPropertyGrid( - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + panel.AddControl(BuildPropertyPanel("catalog analysis", AccentDeepSkyBlue, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("collections", views.Length.ToString()), ("skills", skillCatalog.Skills.Count.ToString()), ("total tokens", FormatTokenCount(skillCatalog.Skills.Sum(skill => skill.TokenCount))), - ("package signals", signals.Count.ToString())); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("catalog analysis", summary))); + ("package signals", signals.Count.ToString()))); - var collectionCards = views.Take(12).Select(view => (SpectreRendering.IRenderable)BuildRichDetailCard( - view.Collection, "deepskyblue1", - $"[dim]skills[/] {view.SkillCount} [dim]installed[/] {view.InstalledCount} [dim]tokens[/] {FormatTokenCount(view.TokenCount)}")).ToArray(); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("collections by size", BuildRichCardGrid(collectionCards, maxColumns: 3)))); + var collectionCards = views.Take(12).Select(view => BuildBulletPanel( + view.Collection, AccentDeepSkyBlue, + $"[grey50]skills[/] {view.SkillCount} [grey50]installed[/] {view.InstalledCount} [grey50]tokens[/] {FormatTokenCount(view.TokenCount)}")).ToList(); + panel.AddControl(BuildCardGrid(collectionCards, maxColumns: 3)); var heavyList = StyledList("Heaviest skills (Enter for details)") .MaxVisibleItems(12) .WithScrollbarVisibility(ScrollbarVisibility.Auto); foreach (var skill in heaviest) { - heavyList.AddItem($"{FormatTokenCount(skill.TokenCount)} tokens · {ToAlias(skill.Name)} [dim]{Escape(skill.Stack)}[/]", skill); + heavyList.AddItem($"{FormatTokenCount(skill.TokenCount)} tokens · {Escape(ToAlias(skill.Name))} [dim]{Escape(skill.Stack)}[/]", skill); } heavyList.OnItemActivated((_, item) => { @@ -829,9 +1105,9 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa if (signals.Count > 0) { - var signalCards = signals.Take(18).Select(signal => (SpectreRendering.IRenderable)new Spectre.Console.Markup( - $"[grey]{Escape(signal.Signal)}[/] [dim]({Escape(signal.Kind)})[/] [dim]→[/] {Escape(ToAlias(signal.Skill.Name))}")).ToArray(); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("package signals", BuildRichStack(signalCards)))); + var signalLines = signals.Take(18).Select(signal => + $"[grey]{Escape(signal.Signal)}[/] [grey50]({Escape(signal.Kind)})[/] [grey50]→[/] {Escape(ToAlias(signal.Skill.Name))}").ToArray(); + panel.AddControl(BuildBulletPanel("package signals", AccentTurquoise, signalLines)); } } @@ -846,13 +1122,13 @@ private void BuildRemoveAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p var installer = new SkillInstaller(skillCatalog); var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("remove all installed skills", BuildRichPropertyGrid( - ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("installed", installed.Count.ToString()))))); + panel.AddControl(BuildPropertyPanel("remove all installed skills", new Color(200, 60, 60), + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("installed", installed.Count.ToString()))); if (installed.Count == 0) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("status", new Spectre.Console.Markup("[dim]Nothing to remove in this target.[/]")))); + panel.AddControl(BuildNotePanel("status", "[grey50]Nothing to remove in this target.[/]", AccentDeepSkyBlue)); return; } @@ -874,19 +1150,19 @@ private void BuildUpdateAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p .Where(record => !record.IsCurrent) .ToArray(); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("update all outdated skills", BuildRichPropertyGrid( - ("target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("outdated", outdated.Length.ToString()))))); + panel.AddControl(BuildPropertyPanel("update all outdated skills", AccentYellow, + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("outdated", outdated.Length.ToString()))); if (outdated.Length == 0) { - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("status", new Spectre.Console.Markup("[green]All installed skills already match the catalog version.[/]")))); + panel.AddControl(BuildNotePanel("status", "[green]All installed skills already match the catalog version.[/]", AccentGreen)); return; } - var listCards = outdated.Select(record => (SpectreRendering.IRenderable)new Spectre.Console.Markup( - $"[yellow]↻[/] {Escape(ToAlias(record.Skill.Name))} [dim]{Escape(record.InstalledVersion)} → {Escape(record.Skill.Version)}[/]")).ToArray(); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("pending updates", BuildRichStack(listCards)))); + var pendingLines = outdated.Select(record => + $"[yellow]↻[/] {Escape(ToAlias(record.Skill.Name))} [grey50]{Escape(record.InstalledVersion)} → {Escape(record.Skill.Version)}[/]").ToArray(); + panel.AddControl(BuildBulletPanel("pending updates", AccentYellow, pendingLines)); panel.AddControl(Controls.Button($"Update all {outdated.Length} skill(s)") .OnClick((_, _) => @@ -906,15 +1182,14 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa var layout = ResolveSkillLayout(); var agentStatus = ResolveAgentStatus(); - var summary = BuildRichPropertyGrid( + panel.AddControl(BuildPropertyPanel("workspace", AccentDeepSkyBlue, ("platform", Escape(Session.Agent.ToString())), ("scope", Escape(Session.Scope.ToString())), ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), - ("skill target", $"[dim]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("agent target", agentStatus.Layout is null ? $"[red]{Escape(agentStatus.Summary)}[/]" : $"[dim]{Escape(CompactPath(agentStatus.Layout.PrimaryRoot.FullName))}[/]"), - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("build", ToolVersionInfo.IsDevelopmentBuild ? "[dim]local development[/]" : "[green]published[/]")); - panel.AddControl(new SpectreRenderableControl(BuildRichShellPanel("workspace", summary))); + ("skill target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("agent target", agentStatus.Layout is null ? $"[red]{Escape(agentStatus.Summary)}[/]" : $"[grey50]{Escape(CompactPath(agentStatus.Layout.PrimaryRoot.FullName))}[/]"), + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"))); var list = StyledList("Settings (Enter to change)") .MaxVisibleItems(8); @@ -966,45 +1241,43 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa private void BuildAboutPage(ScrollablePanelControl panel) { panel.ClearContents(); - var about = BuildRichPropertyGrid( + panel.AddControl(BuildPropertyPanel("about", AccentDeepSkyBlue, ("tool", $"{Escape(ToolIdentity.DisplayCommand)}"), ("package", Escape(ToolIdentity.PackageId)), ("version", Escape(ToolVersionInfo.CurrentVersion)), - ("build", ToolVersionInfo.IsDevelopmentBuild ? "[dim]local development[/]" : "[green]published[/]"), - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [dim]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"), + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("skills", skillCatalog.Skills.Count.ToString()), - ("agents", agentCatalog.Agents.Count.ToString())); - - var surface = BuildRichDetailCard("surface map", "deepskyblue1", - "[grey]Home[/] [dim]session, catalog telemetry, update notice[/]", - "[grey]Skills / Installed[/] [dim]browse, install, update, remove catalog skills[/]", - "[grey]Collections / Bundles / Packages[/] [dim]install grouped surfaces[/]", - "[grey]Agents[/] [dim]install orchestration agents into native agent directories[/]", - "[grey]Project[/] [dim]scan .csproj signals and install recommended skills[/]", - "[grey]Analysis[/] [dim]collection sizes, heaviest skills, package signals[/]"); - - var notes = BuildRichDetailCard("notes", "grey", - "[dim]This is the SharpConsoleUI command center. Run with redirected stdin/stdout to get the classic prompt shell instead.[/]", - "[dim]CLI sub-commands (list, install, recommend, …) are unchanged — see[/] [green]dotnet skills help[/][dim].[/]"); - - panel.AddControl(new SpectreRenderableControl(BuildRichStack( - BuildRichShellPanel("about", about), - surface, - notes))); + ("agents", agentCatalog.Agents.Count.ToString()))); + + panel.AddControl(BuildBulletPanel("surface map", AccentDeepSkyBlue, + "[grey]Home[/] [grey50]session, catalog telemetry, update notice[/]", + "[grey]Skills / Installed[/] [grey50]browse, install, update, remove catalog skills[/]", + "[grey]Collections / Bundles / Packages[/] [grey50]install grouped surfaces[/]", + "[grey]Agents[/] [grey50]install orchestration agents into native agent directories[/]", + "[grey]Project[/] [grey50]scan .csproj signals and install recommended skills[/]", + "[grey]Analysis[/] [grey50]collection sizes, heaviest skills, package signals[/]")); + + panel.AddControl(BuildBulletPanel("notes", AccentGrey, + "[grey50]This is the SharpConsoleUI command center. Run with redirected stdin/stdout to get the classic prompt shell instead.[/]", + "[grey50]CLI sub-commands (list, install, recommend, …) are unchanged — see[/] [green]dotnet skills help[/][grey50].[/]")); } // ------------------------------------------------------------------------- // Modal + status helpers // ------------------------------------------------------------------------- - private void ShowModal(ConsoleWindowSystem ws, string title, SpectreRendering.IRenderable content, params (string Label, Action OnClick)[] buttons) + private void ShowModalNative(ConsoleWindowSystem ws, string title, IReadOnlyList contents, params (string Label, Action OnClick)[] buttons) { Window? modal = null; var width = Math.Clamp(SafeConsole(() => Console.WindowWidth, 120) - 10, 56, 116); var height = Math.Clamp(SafeConsole(() => Console.WindowHeight, 32) - 6, 14, 34); var body = Controls.ScrollablePanel().Build(); - body.AddControl(new SpectreRenderableControl(content)); + foreach (var c in contents) + { + body.AddControl(c); + } void Close() { @@ -1046,8 +1319,11 @@ void Close() private void ConfirmModal(ConsoleWindowSystem ws, string title, string message, Action onConfirm) { - ShowModal(ws, title, BuildRichShellPanel("confirm", new Spectre.Console.Markup($"[yellow]{Escape(message)}[/]"), "yellow"), - ("Yes, proceed", onConfirm)); + var content = new IWindowControl[] + { + BuildNotePanel("confirm", $"[yellow]{Escape(message)}[/]", AccentYellow), + }; + ShowModalNative(ws, title, content, ("Yes, proceed", onConfirm)); } private void ChooseEnumModal(ConsoleWindowSystem ws, string title, TEnum[] values, TEnum current, Action onPicked) @@ -1228,8 +1504,10 @@ private void RefreshCatalogFromUi() Toast($"Refresh failed: {exception.Message}"); } + // RaiseSnapshotChanged fires the AttachSessionEvents handler which calls + // RebuildTopStatusBar() + RebuildActivePage(); also bump the bottom bar. + Session.RaiseSnapshotChanged(); RebuildStatusBar(_currentPage); - RebuildActivePage(); } private void UpdateAllOutdatedFromUi() @@ -1283,19 +1561,29 @@ private void InstallAllRecommendedFromUi() var layout = ResolveSkillLayout(); var installedByName = SafeGet(() => new SkillInstaller(skillCatalog).GetInstalledSkills(layout), Array.Empty()) .ToDictionary(record => record.Skill.Name, StringComparer.OrdinalIgnoreCase); - var installable = scan.Recommendations - .Where(r => !installedByName.TryGetValue(r.Skill.Name, out var rec) || !rec.IsCurrent) + var newSkills = scan.Recommendations + .Where(r => !installedByName.ContainsKey(r.Skill.Name)) .Select(r => r.Skill) .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) .ToArray(); - if (installable.Length == 0) + var outdatedSkills = scan.Recommendations + .Where(r => installedByName.TryGetValue(r.Skill.Name, out var rec) && !rec.IsCurrent) + .Select(r => r.Skill) + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .ToArray(); + if (newSkills.Length == 0 && outdatedSkills.Length == 0) { Toast("No new recommended skills to install"); return; } - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(installable, layout, force: false), default(SkillInstallSummary)); - Toast(summary is null ? "Install failed" : $"Installed {summary.InstalledCount}, skipped {summary.SkippedExisting.Count}"); + // force=true on outdated entries so existing skill dirs are overwritten, force=false on new ones. + var installer = new SkillInstaller(skillCatalog); + var newSummary = newSkills.Length == 0 ? default : SafeGet(() => installer.Install(newSkills, layout, force: false), default(SkillInstallSummary)); + var updateSummary = outdatedSkills.Length == 0 ? default : SafeGet(() => installer.Install(outdatedSkills, layout, force: true), default(SkillInstallSummary)); + var installedCount = (newSummary?.InstalledCount ?? 0) + (updateSummary?.InstalledCount ?? 0); + var skippedCount = (newSummary?.SkippedExisting.Count ?? 0) + (updateSummary?.SkippedExisting.Count ?? 0); + Toast(installedCount == 0 && skippedCount == 0 ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}"); RebuildActivePage(); } diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.cs index 0f31a5a..2f0091f 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.cs @@ -3718,13 +3718,74 @@ internal static string BuildHomeActionMenuLabel(HomeActionView action) internal sealed class InteractiveSessionState { - public AgentPlatform Agent { get; set; } + private AgentPlatform _agent; + private InstallScope _scope; + private string? _projectDirectory; + private bool _suspend; - public InstallScope Scope { get; set; } + // Raised after the corresponding property changes to a new value. The shell + // subscribes from BuildActionPage/BuildHomePage to refresh the open page when + // session identity flips from anywhere (Settings, command palette, etc.). + public event Action? AgentChanged; + public event Action? ScopeChanged; + public event Action? ProjectChanged; + public event Action? SnapshotChanged; - public string? ProjectDirectory { get; set; } + public AgentPlatform Agent + { + get => _agent; + set + { + if (EqualityComparer.Default.Equals(_agent, value)) return; + _agent = value; + if (!_suspend) AgentChanged?.Invoke(); + } + } + + public InstallScope Scope + { + get => _scope; + set + { + if (EqualityComparer.Default.Equals(_scope, value)) return; + _scope = value; + if (!_suspend) ScopeChanged?.Invoke(); + } + } + + public string? ProjectDirectory + { + get => _projectDirectory; + set + { + if (string.Equals(_projectDirectory, value, StringComparison.Ordinal)) return; + _projectDirectory = value; + if (!_suspend) ProjectChanged?.Invoke(); + } + } public bool BundledOnly { get; set; } + + /// + /// Suspends Agent/Scope/Project events while runs, then fires + /// SnapshotChanged once at the end so callers get a single refresh signal. + /// + public void RunSuspended(Action action) + { + _suspend = true; + try { action(); } + finally + { + _suspend = false; + SnapshotChanged?.Invoke(); + } + } + + /// + /// Fires SnapshotChanged — used after operations (like catalog refresh) that change + /// session-relevant state without going through the individual setters. + /// + public void RaiseSnapshotChanged() => SnapshotChanged?.Invoke(); } internal sealed record MenuOption(string Label, T Value); From cd73b3a80d26399a28cb11585b63fd6c5b2a6256 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Thu, 14 May 2026 00:09:29 +0300 Subject: [PATCH 3/7] feat(cli): severity-routed toast notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toast() now calls ConsoleWindowSystem.NotificationStateService.ShowNotification — install/refresh/remove results render as sliding cards on the right. Severity routing: Info/Success stay transient (card only); Warning/Danger also leave a sticky marker in the bottom status bar until the next page change so the user has time to read it. ClearStickyStatus() fires at every page entry. Adds ToastResult(result, fail, success) for the common SkillInstaller pattern where a null summary means failure. Every Toast call site is now severity-tagged. Settings refresh path raises Session.RaiseSnapshotChanged so the live event chain from Commit 1 reflows the open page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InteractiveConsoleApp.Shell.cs | 129 ++++++++++++------ 1 file changed, 90 insertions(+), 39 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index b4fd228..2e2c424 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -21,6 +21,7 @@ using SharpConsoleUI; using SharpConsoleUI.Builders; using SharpConsoleUI.Controls; +using SharpConsoleUI.Core; using SharpConsoleUI.Drivers; using SharpConsoleUI.Helpers; using SharpConsoleUI.Layout; @@ -313,6 +314,7 @@ private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl pane _activePanel = panel; _currentPage = action; AttachSessionEvents(); + ClearStickyStatus(); switch (action) { case HomeAction.BrowseSkills: BuildSkillBrowserPage(ws, panel); break; @@ -343,6 +345,7 @@ private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) _activePanel = panel; _currentPage = null; AttachSessionEvents(); + ClearStickyStatus(); panel.ClearContents(); var layout = ResolveSkillLayout(); @@ -574,15 +577,19 @@ private void ShowSkillDetailModal(ConsoleWindowSystem ws, ScrollablePanelControl ("Install into current target", () => { var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); - Toast(summary is null - ? $"Install failed for {ToAlias(skill.Name)}" - : $"{ToAlias(skill.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + if (summary is null) + Toast($"Install failed for {ToAlias(skill.Name)}", NotificationSeverity.Danger); + else + Toast($"{ToAlias(skill.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped", NotificationSeverity.Success); BuildSkillBrowserPage(ws, owner); }), ("Force reinstall", () => { var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: true), default(SkillInstallSummary)); - Toast(summary is null ? $"Install failed for {ToAlias(skill.Name)}" : $"{ToAlias(skill.Name)}: reinstalled ({summary.InstalledCount} written)"); + if (summary is null) + Toast($"Install failed for {ToAlias(skill.Name)}", NotificationSeverity.Danger); + else + Toast($"{ToAlias(skill.Name)}: reinstalled ({summary.InstalledCount} written)", NotificationSeverity.Success); BuildSkillBrowserPage(ws, owner); })); } @@ -637,7 +644,7 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p .OnClick((_, _) => { var summaryText = UpdateSkillRecords(outdated); - Toast(summaryText); + Toast(summaryText, summaryText.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); BuildInstalledPage(ws, panel); }).Build()); } @@ -648,7 +655,7 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p () => { var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); - Toast(summary is null ? "Remove failed" : $"Removed {summary.RemovedCount} skill(s)"); + ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); BuildInstalledPage(ws, panel); })).Build()); } @@ -672,20 +679,21 @@ private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelCont { buttons.Add(($"Update to {record.Skill.Version}", () => { - Toast(UpdateSkillRecords(new[] { record })); + var msg = UpdateSkillRecords(new[] { record }); + Toast(msg, msg.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); BuildInstalledPage(ws, owner); })); } buttons.Add(("Reinstall (force)", () => { var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { record.Skill }, ResolveSkillLayout(), force: true), default(SkillInstallSummary)); - Toast(summary is null ? "Reinstall failed" : $"{ToAlias(record.Skill.Name)}: reinstalled"); + ToastResult(summary, "Reinstall failed", $"{ToAlias(record.Skill.Name)}: reinstalled"); BuildInstalledPage(ws, owner); })); buttons.Add(("Remove", () => ConfirmModal(ws, $"Remove {ToAlias(record.Skill.Name)}?", $"Deletes the skill directory from {ResolveSkillLayout().PrimaryRoot.FullName}.", () => { var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(new[] { record.Skill }, ResolveSkillLayout()), default(SkillRemoveSummary)); - Toast(summary is null ? "Remove failed" : $"Removed {ToAlias(record.Skill.Name)}"); + ToastResult(summary, "Remove failed", $"Removed {ToAlias(record.Skill.Name)}"); BuildInstalledPage(ws, owner); }))); @@ -751,7 +759,7 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl { var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromCollections(new[] { view.Collection }), Array.Empty()); var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); - Toast(summary is null ? $"Could not install collection {view.Collection}" : $"{view.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + ToastResult(summary, $"Could not install collection {view.Collection}", summary is null ? string.Empty : $"{view.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); BuildCollectionsPage(ws, panel); }); } @@ -820,7 +828,7 @@ private void ShowBundleModal(ConsoleWindowSystem ws, ScrollablePanelControl owne { var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromPackages(new[] { package.Name }), Array.Empty()); var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); - Toast(summary is null ? $"Could not install bundle {package.Name}" : $"{package.Name}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + ToastResult(summary, $"Could not install bundle {package.Name}", summary is null ? string.Empty : $"{package.Name}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); BuildBundlesPage(ws, owner, primaryOnly); })); } @@ -918,11 +926,11 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane var detected = SafeGet(() => AgentInstallTarget.ResolveAllDetected(Session.ProjectDirectory, Session.Scope), Array.Empty()); if (detected.Count == 0) { - Toast("No native agent directories detected"); + Toast("No native agent directories detected", NotificationSeverity.Warning); return; } var summary2 = SafeGet(() => new AgentInstaller(agentCatalog).InstallToMultiple(agentCatalog.Agents, detected, force: false), default(AgentInstallSummary)); - Toast(summary2 is null ? "Install failed" : $"Installed {summary2.InstalledCount} agent file(s) across {detected.Count} platform(s)"); + ToastResult(summary2, "Install failed", summary2 is null ? string.Empty : $"Installed {summary2.InstalledCount} agent file(s) across {detected.Count} platform(s)"); BuildAgentsPage(ws, panel); }).Build()); } @@ -945,13 +953,13 @@ private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner buttons.Add(("Install into current target", () => { var summary = SafeGet(() => new AgentInstaller(agentCatalog).Install(new[] { agent }, layout, force: false), default(AgentInstallSummary)); - Toast(summary is null ? "Install failed" : $"{ToAlias(agent.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + ToastResult(summary, "Install failed", summary is null ? string.Empty : $"{ToAlias(agent.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); BuildAgentsPage(ws, owner); })); buttons.Add(("Remove from current target", () => { var summary = SafeGet(() => new AgentInstaller(agentCatalog).Remove(new[] { agent }, layout), default(AgentRemoveSummary)); - Toast(summary is null ? "Remove failed" : $"Removed {ToAlias(agent.Name)} ({summary.RemovedCount} file(s))"); + ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {ToAlias(agent.Name)} ({summary.RemovedCount} file(s))"); BuildAgentsPage(ws, owner); })); } @@ -1022,7 +1030,7 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan // otherwise be reported as skipped and stay outdated. var isOutdated = installedByName.TryGetValue(recommendation.Skill.Name, out var existing) && !existing.IsCurrent; var summary2 = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { recommendation.Skill }, ResolveSkillLayout(), force: isOutdated), default(SkillInstallSummary)); - Toast(summary2 is null ? $"Install failed for {ToAlias(recommendation.Skill.Name)}" : $"{ToAlias(recommendation.Skill.Name)}: {summary2.InstalledCount} written, {summary2.SkippedExisting.Count} skipped"); + ToastResult(summary2, $"Install failed for {ToAlias(recommendation.Skill.Name)}", summary2 is null ? string.Empty : $"{ToAlias(recommendation.Skill.Name)}: {summary2.InstalledCount} written, {summary2.SkippedExisting.Count} skipped"); BuildProjectPage(ws, panel); } }); @@ -1052,7 +1060,8 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var updateSummary = outdatedSkills.Length == 0 ? default : SafeGet(() => installer2.Install(outdatedSkills, skillLayout, force: true), default(SkillInstallSummary)); var installedCount = (newSummary?.InstalledCount ?? 0) + (updateSummary?.InstalledCount ?? 0); var skippedCount = (newSummary?.SkippedExisting.Count ?? 0) + (updateSummary?.SkippedExisting.Count ?? 0); - Toast(installedCount == 0 && skippedCount == 0 ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}"); + var failed = installedCount == 0 && skippedCount == 0; + Toast(failed ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}", failed ? NotificationSeverity.Danger : NotificationSeverity.Success); BuildProjectPage(ws, panel); }).Build()); } @@ -1136,7 +1145,7 @@ private void BuildRemoveAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?", $"Deletes every catalog skill directory under {layout.PrimaryRoot.FullName}.", () => { var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); - Toast(summary is null ? "Remove failed" : $"Removed {summary.RemovedCount} skill(s)"); + ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); BuildRemoveAllPage(ws, panel); })).Build()); } @@ -1167,7 +1176,8 @@ private void BuildUpdateAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p panel.AddControl(Controls.Button($"Update all {outdated.Length} skill(s)") .OnClick((_, _) => { - Toast(UpdateSkillRecords(outdated)); + var msg = UpdateSkillRecords(outdated); + Toast(msg, msg.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); BuildUpdateAllPage(ws, panel); }).Build()); } @@ -1204,30 +1214,30 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa ChooseEnumModal(ws, "Install platform", Enum.GetValues(), Session.Agent, value => { Session.Agent = value; - Toast($"Platform set to {value}"); - BuildSettingsPage(ws, panel); + Toast($"Platform set to {value}", NotificationSeverity.Success); + // The AgentChanged event from Commit 1's live-state plumbing will rebuild + // the page; no explicit BuildSettingsPage call needed. }); break; case "scope": ChooseEnumModal(ws, "Install scope", Enum.GetValues(), Session.Scope, value => { Session.Scope = value; - Toast($"Scope set to {value}"); - BuildSettingsPage(ws, panel); + Toast($"Scope set to {value}", NotificationSeverity.Success); }); break; case "refresh": try { - Toast("Refreshing catalog…"); + Toast("Refreshing catalog…", NotificationSeverity.Info); LoadCatalogsAsync(refreshCatalog: true).GetAwaiter().GetResult(); - Toast($"Catalog refreshed: {skillCatalog.CatalogVersion} ({skillCatalog.Skills.Count} skills)"); + Toast($"Catalog refreshed: {skillCatalog.CatalogVersion} ({skillCatalog.Skills.Count} skills)", NotificationSeverity.Success); + Session.RaiseSnapshotChanged(); } catch (Exception exception) { - Toast($"Refresh failed: {exception.Message}"); + Toast($"Refresh failed: {exception.Message}", NotificationSeverity.Danger); } - BuildSettingsPage(ws, panel); break; } }); @@ -1462,14 +1472,53 @@ private void RebuildActivePage() } } - private void Toast(string message) + /// + /// Shows a transient notification. Info/Success render only as a sliding card; Warning/Danger + /// also leave a sticky line in the bottom status bar until the next page change so the user + /// has time to read it. Default severity is Info. + /// + private void Toast(string message, NotificationSeverity? severity = null) { + if (string.IsNullOrEmpty(message)) { ClearStickyStatus(); return; } + + var sev = severity ?? NotificationSeverity.Info; + _ws?.NotificationStateService.ShowNotification(title: string.Empty, message, sev); + if (_statusMessage is not null) { - _statusMessage.Label = string.IsNullOrEmpty(message) ? string.Empty : $"[grey70]{Escape(message)}[/]"; + if (sev == NotificationSeverity.Warning) + { + _statusMessage.Label = $"[yellow]⚠ {Escape(message)}[/]"; + } + else if (sev == NotificationSeverity.Danger) + { + _statusMessage.Label = $"[red]✘ {Escape(message)}[/]"; + } + else + { + // Info / Success / None — the slide-in card carries the feedback; keep the bar quiet. + _statusMessage.Label = string.Empty; + } } } + private void ClearStickyStatus() + { + if (_statusMessage is not null) _statusMessage.Label = string.Empty; + } + + /// + /// Convenience for "install/remove" callers: a null result is treated as a failure (rendered + /// as a red toast with sticky status); a non-null result is success (transient green toast). + /// + private void ToastResult(object? result, string failureMessage, string successMessage) + { + if (result is null) + Toast(failureMessage, NotificationSeverity.Danger); + else + Toast(successMessage, NotificationSeverity.Success); + } + private async Task ClockLoopAsync(Window window, CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) @@ -1495,13 +1544,13 @@ private void RefreshCatalogFromUi() { try { - Toast("Refreshing catalog…"); + Toast("Refreshing catalog…", NotificationSeverity.Info); LoadCatalogsAsync(refreshCatalog: true).GetAwaiter().GetResult(); - Toast($"Catalog refreshed: {skillCatalog.CatalogVersion} ({skillCatalog.Skills.Count} skills)"); + Toast($"Catalog refreshed: {skillCatalog.CatalogVersion} ({skillCatalog.Skills.Count} skills)", NotificationSeverity.Success); } catch (Exception exception) { - Toast($"Refresh failed: {exception.Message}"); + Toast($"Refresh failed: {exception.Message}", NotificationSeverity.Danger); } // RaiseSnapshotChanged fires the AttachSessionEvents handler which calls @@ -1518,11 +1567,12 @@ private void UpdateAllOutdatedFromUi() .ToArray(); if (outdated.Length == 0) { - Toast("No outdated skills in this target"); + Toast("No outdated skills in this target", NotificationSeverity.Warning); return; } - Toast(UpdateSkillRecords(outdated)); + var msg = UpdateSkillRecords(outdated); + Toast(msg, msg.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); RebuildActivePage(); } @@ -1537,14 +1587,14 @@ private void RemoveAllFromUi() var installed = SafeGet(() => new SkillInstaller(skillCatalog).GetInstalledSkills(layout), Array.Empty()); if (installed.Count == 0) { - Toast("Nothing to remove in this target"); + Toast("Nothing to remove in this target", NotificationSeverity.Warning); return; } ConfirmModal(_ws, "Remove all installed skills?", $"Deletes every catalog skill from {layout.PrimaryRoot.FullName}.", () => { var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(record => record.Skill).ToArray(), layout), default(SkillRemoveSummary)); - Toast(summary is null ? "Remove failed" : $"Removed {summary.RemovedCount} skill(s)"); + ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); RebuildActivePage(); }); } @@ -1554,7 +1604,7 @@ private void InstallAllRecommendedFromUi() var scan = SafeGet(() => new ProjectSkillRecommender(skillCatalog).Analyze(Session.ProjectDirectory), null); if (scan is null) { - Toast("Project scan failed"); + Toast("Project scan failed", NotificationSeverity.Danger); return; } @@ -1573,7 +1623,7 @@ private void InstallAllRecommendedFromUi() .ToArray(); if (newSkills.Length == 0 && outdatedSkills.Length == 0) { - Toast("No new recommended skills to install"); + Toast("No new recommended skills to install", NotificationSeverity.Warning); return; } @@ -1583,7 +1633,8 @@ private void InstallAllRecommendedFromUi() var updateSummary = outdatedSkills.Length == 0 ? default : SafeGet(() => installer.Install(outdatedSkills, layout, force: true), default(SkillInstallSummary)); var installedCount = (newSummary?.InstalledCount ?? 0) + (updateSummary?.InstalledCount ?? 0); var skippedCount = (newSummary?.SkippedExisting.Count ?? 0) + (updateSummary?.SkippedExisting.Count ?? 0); - Toast(installedCount == 0 && skippedCount == 0 ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}"); + var failed = installedCount == 0 && skippedCount == 0; + Toast(failed ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}", failed ? NotificationSeverity.Danger : NotificationSeverity.Success); RebuildActivePage(); } From 96850298fa72162a7fa1a2de9c8192e7dc606121 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Thu, 14 May 2026 00:13:24 +0300 Subject: [PATCH 4/7] feat(cli): clickable Home metrics + inline Settings form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home metric cards (skills/bundles/installed/outdated/agents) are now native PanelControl mouse-click targets. Clicking installed/outdated lands on the Installed page; clicking skills/bundles/agents lands on the matching browse surface. BuildMetricCard takes an optional onClick that hooks PanelControl's MouseClick event. Settings replaces the 3-row prompt list and its ChooseEnumModal popups with native DropdownControls for Platform and Scope plus a Button for catalog refresh. SelectionChanged updates Session.Agent/Scope directly; the live event chain from Commit 1 redraws the page and top status bar without a modal. NavigateTo(HomeAction) is the shared entry point — also used by the command palette in Commit 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InteractiveConsoleApp.Shell.cs | 113 ++++++++++-------- 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index 2e2c424..c6bc039 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -242,6 +242,18 @@ private void RebuildTopStatusBar() }); } + /// + /// Navigates to a HomeAction page without going through the NavigationView rail — used by + /// the clickable Home metric cards and the command palette. The rail's visual selection + /// state won't follow, but the content panel rebuilds and status bars update. + /// + private void NavigateTo(HomeAction action) + { + if (_ws is null || _activePanel is null) return; + BuildActionPage(_ws, _activePanel, action); + RebuildStatusBar(action); + } + /// /// Replaces any prior session-event subscriptions with a fresh one bound to the active page, /// so flipping Session.Scope/Agent/Project from anywhere refreshes the open page in place. @@ -363,12 +375,13 @@ private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) // catalog telemetry — five native metric cards laid out by HorizontalGrid (responsive flex). var installedAccent = installed.Count > 0 ? AccentGreen : AccentGrey; var outdatedAccent = outdated == 0 ? AccentGreen : AccentYellow; + // Cards are clickable navigation targets — click "outdated" to jump to Installed, etc. var telemetryGrid = Controls.HorizontalGrid() - .Column(col => col.Flex(1).Add(BuildMetricCard("skills", skillCatalog.Skills.Count.ToString(), "in catalog", AccentDeepSkyBlue))) - .Column(col => col.Flex(1).Add(BuildMetricCard("bundles", GetPrimaryBundles().Count.ToString(), "focused", AccentTurquoise))) - .Column(col => col.Flex(1).Add(BuildMetricCard("installed", $"{installed.Count}/{skillCatalog.Skills.Count}", "in current target", installedAccent))) - .Column(col => col.Flex(1).Add(BuildMetricCard("outdated", outdated.ToString(), outdated == 0 ? "all current" : "need update", outdatedAccent))) - .Column(col => col.Flex(1).Add(BuildMetricCard("agents", agentCatalog.Agents.Count.ToString(), "orchestration", AccentMediumPurple))) + .Column(col => col.Flex(1).Add(BuildMetricCard("skills", skillCatalog.Skills.Count.ToString(), "in catalog", AccentDeepSkyBlue, () => NavigateTo(HomeAction.BrowseSkills)))) + .Column(col => col.Flex(1).Add(BuildMetricCard("bundles", GetPrimaryBundles().Count.ToString(), "focused", AccentTurquoise, () => NavigateTo(HomeAction.BrowseBundles)))) + .Column(col => col.Flex(1).Add(BuildMetricCard("installed", $"{installed.Count}/{skillCatalog.Skills.Count}", "in current target", installedAccent, () => NavigateTo(HomeAction.ManageInstalled)))) + .Column(col => col.Flex(1).Add(BuildMetricCard("outdated", outdated.ToString(), outdated == 0 ? "all current" : "need update", outdatedAccent, () => NavigateTo(HomeAction.ManageInstalled)))) + .Column(col => col.Flex(1).Add(BuildMetricCard("agents", agentCatalog.Agents.Count.ToString(), "orchestration", AccentMediumPurple, () => NavigateTo(HomeAction.BrowseAgents)))) .Build(); panel.AddControl(telemetryGrid); @@ -417,13 +430,15 @@ private static PanelControl BuildSectionPanel(string title, string body, Color a /// A native metric card: three stacked lines (title accent, value bold, detail grey) inside /// a rounded PanelControl with an accent border. Used in HorizontalGrid columns. /// - private static PanelControl BuildMetricCard(string title, string value, string detail, Color accent) + /// Optional click handler — when non-null, the card becomes a + /// navigation target via its MouseClick event. + private static PanelControl BuildMetricCard(string title, string value, string detail, Color accent, Action? onClick = null) { // Multi-line markup body — PanelControl splits on \n and wraps each line. var body = string.Join("\n", $"[bold]{Escape(value)}[/]", $"[grey50]{Escape(detail)}[/]"); - return Controls.Panel() + var card = Controls.Panel() .WithHeader($"[bold]{Escape(title)}[/]") .WithBorderStyle(BorderStyle.Rounded) .WithBorderColor(accent) @@ -431,6 +446,11 @@ private static PanelControl BuildMetricCard(string title, string value, string d .WithContent(body) .WithAlignment(HorizontalAlignment.Stretch) .Build(); + if (onClick is not null) + { + card.MouseClick += (_, _) => onClick(); + } + return card; } /// @@ -1201,47 +1221,46 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"))); - var list = StyledList("Settings (Enter to change)") - .MaxVisibleItems(8); - list.AddItem($"Platform: {Session.Agent}", "platform"); - list.AddItem($"Install scope: {Session.Scope}", "scope"); - list.AddItem("Refresh catalog now", "refresh"); - list.OnItemActivated((_, item) => - { - switch (item.Tag as string) + // Inline form: native dropdowns (change-on-pick, no modal) for Platform/Scope, + // a plain Button for catalog refresh. SelectedIndexChanged fires only on user + // interaction (DropdownBuilder attaches the handler AFTER SelectedIndex is set), + // so no guard flag is needed against the initial-paint pulse. + var platformValues = Enum.GetValues(); + var platformDropdown = Controls.Dropdown("Platform") + .AddItems(platformValues.Select(v => v.ToString()).ToArray()) + .SelectedIndex(Array.IndexOf(platformValues, Session.Agent)) + .OnSelectionChanged((_, idx) => { - case "platform": - ChooseEnumModal(ws, "Install platform", Enum.GetValues(), Session.Agent, value => - { - Session.Agent = value; - Toast($"Platform set to {value}", NotificationSeverity.Success); - // The AgentChanged event from Commit 1's live-state plumbing will rebuild - // the page; no explicit BuildSettingsPage call needed. - }); - break; - case "scope": - ChooseEnumModal(ws, "Install scope", Enum.GetValues(), Session.Scope, value => - { - Session.Scope = value; - Toast($"Scope set to {value}", NotificationSeverity.Success); - }); - break; - case "refresh": - try - { - Toast("Refreshing catalog…", NotificationSeverity.Info); - LoadCatalogsAsync(refreshCatalog: true).GetAwaiter().GetResult(); - Toast($"Catalog refreshed: {skillCatalog.CatalogVersion} ({skillCatalog.Skills.Count} skills)", NotificationSeverity.Success); - Session.RaiseSnapshotChanged(); - } - catch (Exception exception) - { - Toast($"Refresh failed: {exception.Message}", NotificationSeverity.Danger); - } - break; - } - }); - panel.AddControl(list.Build()); + if (idx < 0 || idx >= platformValues.Length) return; + var chosen = platformValues[idx]; + if (chosen.Equals(Session.Agent)) return; + Session.Agent = chosen; + Toast($"Platform set to {chosen}", NotificationSeverity.Success); + }) + .Build(); + + var scopeValues = Enum.GetValues(); + var scopeDropdown = Controls.Dropdown("Scope") + .AddItems(scopeValues.Select(v => v.ToString()).ToArray()) + .SelectedIndex(Array.IndexOf(scopeValues, Session.Scope)) + .OnSelectionChanged((_, idx) => + { + if (idx < 0 || idx >= scopeValues.Length) return; + var chosen = scopeValues[idx]; + if (chosen.Equals(Session.Scope)) return; + Session.Scope = chosen; + Toast($"Scope set to {chosen}", NotificationSeverity.Success); + }) + .Build(); + + panel.AddControl(BuildSectionPanel("install target", "[grey50]Platform and scope control where skills and agents are written. Changes take effect immediately.[/]", AccentDeepSkyBlue)); + panel.AddControl(platformDropdown); + panel.AddControl(scopeDropdown); + + panel.AddControl(BuildSectionPanel("catalog", "[grey50]Pull the latest catalog from upstream.[/]", AccentTurquoise)); + panel.AddControl(Controls.Button("Refresh catalog now") + .OnClick((_, _) => RefreshCatalogFromUi()) + .Build()); } // ------------------------------------------------------------------------- From 8d0fc958624e74b24073480bd69d62a8de5a0b11 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Thu, 14 May 2026 00:23:18 +0300 Subject: [PATCH 5/7] feat(cli): master-detail Collections + search overlay + command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collections is now a HorizontalGrid: left pane is the collection list, right pane is a ScrollablePanel that re-renders on selection change — collection stats, lanes, and an inline two-stage install button (first click arms, second commits) replace the modal-and-back-out flow. Satisfies AGENTS.md's "install overview before confirmation" rule without a popup. `/` opens a small search overlay (PromptControl, Enter applies) that filters the active list page (Skills/Installed/Collections/Bundles/Packages/Agents). Esc clears an active filter; page-switch clears it automatically. A small yellow chip at the top of filtered pages shows the active query. Ctrl+P opens a centered command palette modal — fuzzy haystack search across every catalog skill, bundle, agent, plus settings actions and page jumps. Activating an entry routes to the matching detail modal or page. Bottom status bar advertises `/` (when on a list page) and Ctrl+P (everywhere). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InteractiveConsoleApp.Shell.cs | 460 ++++++++++++++++-- 1 file changed, 423 insertions(+), 37 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index c6bc039..fd75990 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -71,6 +71,16 @@ internal sealed partial class InteractiveConsoleApp // build resets this so subscriptions don't leak across page switches. private Action? _detachSessionEvents; + // List-page filter active across rebuilds; cleared on page switch. Bound to the `/` overlay. + private string _searchFilter = string.Empty; + + // Currently selected collection in the master-detail Collections page (Commit 4). + private CollectionCatalogView? _selectedCollection; + + // First-click arms the inline two-stage install button on Collections detail (Commit 4); + // second click commits. Cleared every time the selected collection changes. + private bool _collectionInstallArmed; + private static readonly Color[] SectionPalette = { new(120, 180, 255), @@ -283,16 +293,30 @@ private void AttachSessionEvents() private void HandleGlobalKey(KeyPressedEventArgs e) { var key = e.KeyInfo; + + // Esc clears an active search filter first, then ends the session. if (key.Key == ConsoleKey.Escape) { - // Root window: Esc ends the session rather than dismissing the window. + if (!string.IsNullOrEmpty(_searchFilter)) + { + _searchFilter = string.Empty; + RebuildActivePage(); + e.Handled = true; + return; + } _ws?.Shutdown(0); e.Handled = true; return; } + // Plain `/` opens the search overlay on any list-bearing page (no modifier required). if ((key.Modifiers & ConsoleModifiers.Control) == 0) { + if (key.KeyChar == '/' && IsListBearingPage(_currentPage)) + { + ShowSearchOverlay(); + e.Handled = true; + } return; } @@ -314,15 +338,35 @@ private void HandleGlobalKey(KeyPressedEventArgs e) RemoveAllFromUi(); e.Handled = true; break; + case ConsoleKey.P: + if (_ws is not null) ShowCommandPalette(_ws); + e.Handled = true; + break; } } + private static bool IsListBearingPage(HomeAction? page) => page is + HomeAction.BrowseSkills or + HomeAction.ManageInstalled or + HomeAction.BrowseCollections or + HomeAction.BrowseBundles or + HomeAction.BrowsePackages or + HomeAction.BrowseAgents; + // ------------------------------------------------------------------------- // Page dispatch // ------------------------------------------------------------------------- private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl panel, HomeAction action) { + // Page-switch clears transient filters (search + Collections detail selection) so each + // page lands in a clean state. Use NavigateTo if you need to preserve filter context. + if (_currentPage != action) + { + _searchFilter = string.Empty; + _selectedCollection = null; + _collectionInstallArmed = false; + } _activePanel = panel; _currentPage = action; AttachSessionEvents(); @@ -354,6 +398,12 @@ private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl pane private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) { + if (_currentPage != null) + { + _searchFilter = string.Empty; + _selectedCollection = null; + _collectionInstallArmed = false; + } _activePanel = panel; _currentPage = null; AttachSessionEvents(); @@ -547,22 +597,30 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro .ThenBy(skill => skill.Name, StringComparer.Ordinal) .ToArray(); + var filtered = available.Where(s => MatchesFilter(s.Name, s.Stack, s.Lane)).ToArray(); + panel.AddControl(BuildPropertyPanel("skill browser", AccentTurquoise, ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("available", available.Length.ToString()), + ("available", $"{filtered.Length}/{available.Length}"), ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); + AddSearchChip(panel); if (available.Length == 0) { panel.AddControl(BuildNotePanel("available", "[grey50]Every catalog skill is already installed in this target.[/]", AccentDeepSkyBlue)); return; } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel("available", $"[grey50]No skills match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } var list = StyledList("Available skills (Enter for details)") .MaxVisibleItems(16) .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var skill in available) + foreach (var skill in filtered) { // ListControl parses item text as markup; BuildSkillChoiceLabel produces plain // text containing bracketed stack/lane like "[.NET Foundations / ...]". Escape so @@ -629,22 +687,30 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p .ToArray(); var outdated = installed.Where(record => !record.IsCurrent).ToArray(); + var filtered = installed.Where(r => MatchesFilter(r.Skill.Name, r.Skill.Stack, r.Skill.Lane)).ToArray(); + panel.AddControl(BuildPropertyPanel("installed skills", AccentGreen, ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("installed", installed.Length.ToString()), + ("installed", string.IsNullOrEmpty(_searchFilter) ? installed.Length.ToString() : $"{filtered.Length}/{installed.Length}"), ("outdated", outdated.Length == 0 ? "[green]0[/]" : $"[yellow]{outdated.Length}[/]"), ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount))))); + AddSearchChip(panel); if (installed.Length == 0) { panel.AddControl(BuildNotePanel("installed", "[grey50]No catalog skills are installed in this target yet. Visit the Skills page to add some.[/]", AccentDeepSkyBlue)); return; } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel("installed", $"[grey50]No installed skills match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } var list = StyledList("Installed skills (Enter for details)") .MaxVisibleItems(14) .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var record in installed) + foreach (var record in filtered) { // Escape: the label contains "[stack / lane]" which would otherwise be parsed as markup. list.AddItem((record.IsCurrent ? "✓ " : "↻ ") + Escape(BuildInstalledSkillChoiceLabel(record)), record); @@ -743,48 +809,118 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl .OrderBy(view => CatalogOrganization.GetStackRank(view.Collection)) .ThenBy(view => view.Collection, StringComparer.Ordinal) .ToArray(); + var filtered = views.Where(v => MatchesFilter(v.Collection)).ToArray(); panel.AddControl(BuildPropertyPanel("collection browser", AccentDeepSkyBlue, ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("collections", views.Length.ToString()), + ("collections", string.IsNullOrEmpty(_searchFilter) ? views.Length.ToString() : $"{filtered.Length}/{views.Length}"), ("skills", skillCatalog.Skills.Count.ToString()), ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); + AddSearchChip(panel); if (views.Length == 0) { panel.AddControl(BuildNotePanel("collections", "[grey50]No collections in this catalog version.[/]", AccentDeepSkyBlue)); return; } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel("collections", $"[grey50]No collections match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } - var collectionCards = views.Select(view => BuildBulletPanel( - view.Collection, AccentDeepSkyBlue, - $"[grey50]lanes[/] {view.Lanes.Count} [grey50]skills[/] {view.InstalledCount}/{view.SkillCount} [grey50]tokens[/] {FormatTokenCount(view.TokenCount)}", - $"[grey]{Escape(string.Join(", ", view.Lanes.Take(6).Select(lane => lane.Lane)))}[/]")).ToList(); - panel.AddControl(BuildCardGrid(collectionCards, maxColumns: 2)); - - var list = StyledList("Collections (Enter to install the whole collection)") - .MaxVisibleItems(14) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var view in views) + // Master-detail layout. Left column lists collections; right column shows the detail of + // _selectedCollection. Clicking a left-list row updates only the right pane in place — + // no modal, no full-page rebuild. The right pane is a ScrollablePanel so the detail can + // grow with the collection's lane list. + if (_selectedCollection is null + || !filtered.Any(v => string.Equals(v.Collection, _selectedCollection.Collection, StringComparison.OrdinalIgnoreCase))) { - list.AddItem(Escape(BuildCollectionChoiceLabel(view)), view); + _selectedCollection = filtered[0]; + _collectionInstallArmed = false; } - list.OnItemActivated((_, item) => + + // Build the detail pane as a standalone ScrollablePanelControl so we can update it + // independently of the left list when the user changes selection. + var rightPane = new ScrollablePanelControl { - if (item.Tag is CollectionCatalogView view) + ShowScrollbar = true, + VerticalScrollMode = ScrollMode.Scroll, + EnableMouseWheel = true, + }; + + var grid = Controls.HorizontalGrid() + .Column(col => { - ConfirmModal(ws, $"Install collection {view.Collection}?", - $"Installs all {view.SkillCount} skill(s) from this collection into {ResolveSkillLayout().PrimaryRoot.FullName}.", - () => + col.Flex(1); + var list = StyledList("Collections") + .MaxVisibleItems(20) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var view in filtered) + { + list.AddItem(Escape(BuildCollectionChoiceLabel(view)), view); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is CollectionCatalogView v) { - var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromCollections(new[] { view.Collection }), Array.Empty()); - var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); - ToastResult(summary, $"Could not install collection {view.Collection}", summary is null ? string.Empty : $"{view.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); - BuildCollectionsPage(ws, panel); - }); - } - }); - panel.AddControl(list.Build()); + _selectedCollection = v; + _collectionInstallArmed = false; + BuildCollectionDetail(rightPane, v); + } + }); + col.Add(list.Build()); + }) + .Column(col => + { + col.Flex(2).Add(rightPane); + }) + .Build(); + + panel.AddControl(grid); + BuildCollectionDetail(rightPane, _selectedCollection!); + } + + /// + /// Renders the right pane of the Collections master-detail view: stats, lanes, and an inline + /// two-stage install button (first click arms, second commits — satisfies AGENTS.md's + /// "install overview before confirmation" rule without a modal). + /// + private void BuildCollectionDetail(ScrollablePanelControl pane, CollectionCatalogView view) + { + pane.ClearContents(); + pane.AddControl(BuildPropertyPanel(view.Collection, AccentDeepSkyBlue, + ("collection", Escape(view.Collection)), + ("lanes", view.Lanes.Count.ToString()), + ("skills", $"{view.InstalledCount}/{view.SkillCount}"), + ("tokens", FormatTokenCount(view.TokenCount)))); + + if (view.Lanes.Count > 0) + { + pane.AddControl(BuildBulletPanel("lanes", AccentTurquoise, + view.Lanes.Select(lane => $"[grey50]·[/] [grey]{Escape(lane.Lane)}[/] [grey50]({lane.InstalledCount}/{lane.Skills.Count} skills, {FormatTokenCount(lane.TokenCount)} tokens)[/]").ToArray())); + } + + var armed = _collectionInstallArmed; + var label = armed + ? $"Click again to install all {view.SkillCount} skill(s)" + : $"Install collection ({view.SkillCount} skill(s))"; + pane.AddControl(Controls.Button(label) + .OnClick((_, _) => + { + if (!_collectionInstallArmed) + { + _collectionInstallArmed = true; + Toast($"Click again to confirm installing {view.SkillCount} skill(s)", NotificationSeverity.Warning); + BuildCollectionDetail(pane, view); + return; + } + var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromCollections(new[] { view.Collection }), Array.Empty()); + var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + ToastResult(summary, $"Could not install collection {view.Collection}", summary is null ? string.Empty : $"{view.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + _collectionInstallArmed = false; + if (_ws is not null && _activePanel is not null) BuildCollectionsPage(_ws, _activePanel); + }).Build()); } // ------------------------------------------------------------------------- @@ -802,21 +938,29 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var title = primaryOnly ? "focused bundles" : "catalog packages"; var skillTokens = skillCatalog.Skills.ToDictionary(skill => skill.Name, skill => skill.TokenCount, StringComparer.OrdinalIgnoreCase); + var filtered = packages.Where(p => MatchesFilter(p.Name, p.Title)).ToArray(); + panel.AddControl(BuildPropertyPanel(title, AccentDeepSkyBlue, ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - (primaryOnly ? "bundles" : "packages", packages.Length.ToString()), + (primaryOnly ? "bundles" : "packages", string.IsNullOrEmpty(_searchFilter) ? packages.Length.ToString() : $"{filtered.Length}/{packages.Length}"), ("skills covered", skillCatalog.Skills.Count.ToString()))); + AddSearchChip(panel); if (packages.Length == 0) { panel.AddControl(BuildNotePanel(title, "[grey50]Nothing available in this catalog version.[/]", AccentDeepSkyBlue)); return; } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel(title, $"[grey50]No bundles match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } var list = StyledList($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)") .MaxVisibleItems(16) .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var package in packages) + foreach (var package in filtered) { var tokenCount = package.Skills.Sum(name => skillTokens.TryGetValue(name, out var value) ? value : 0); list.AddItem($"{Escape(package.Name)} [dim]({package.Skills.Count} skills, {FormatTokenCount(tokenCount)} tokens)[/]", package); @@ -862,21 +1006,29 @@ private void BuildPackagesPage(ConsoleWindowSystem ws, ScrollablePanelControl pa panel.ClearContents(); var signals = SafeGet(BuildPackageSignals, Array.Empty()); + var filtered = signals.Where(s => MatchesFilter(s.Signal, s.Skill.Name, s.Skill.Stack, s.Skill.Lane)).ToArray(); + panel.AddControl(BuildPropertyPanel("package signals", AccentTurquoise, ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("signals", signals.Count.ToString()), + ("signals", string.IsNullOrEmpty(_searchFilter) ? signals.Count.ToString() : $"{filtered.Length}/{signals.Count}"), ("skills covered", signals.Select(s => s.Skill.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count().ToString()))); + AddSearchChip(panel); if (signals.Count == 0) { panel.AddControl(BuildNotePanel("packages", "[grey50]No NuGet package or prefix signals are present in this catalog version.[/]", AccentDeepSkyBlue)); return; } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel("packages", $"[grey50]No signals match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } var list = StyledList("Package signals (Enter to inspect linked skill)") .MaxVisibleItems(16) .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var signal in signals) + foreach (var signal in filtered) { // ListControl renders item text as markup — escape the whole plain-text label. list.AddItem(Escape($"{signal.Signal} [{signal.Kind}] -> {ToAlias(signal.Skill.Name)} [{signal.Skill.Stack} / {signal.Skill.Lane}] ({FormatTokenCount(signal.Skill.TokenCount)} tokens)"), signal); @@ -905,22 +1057,31 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane ? Array.Empty() : SafeGet(() => installer.GetInstalledAgents(layout), Array.Empty()); + var allAgents = agentCatalog.Agents.OrderBy(a => a.Name, StringComparer.Ordinal).ToArray(); + var filteredAgents = allAgents.Where(a => MatchesFilter(a.Name, a.Description)).ToArray(); + panel.AddControl(BuildPropertyPanel("orchestration agents", AccentMediumPurple, - ("agents", agentCatalog.Agents.Count.ToString()), + ("agents", string.IsNullOrEmpty(_searchFilter) ? agentCatalog.Agents.Count.ToString() : $"{filteredAgents.Length}/{agentCatalog.Agents.Count}"), ("platform", Escape(Session.Agent.ToString())), ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}"))); + AddSearchChip(panel); if (agentCatalog.Agents.Count == 0) { panel.AddControl(BuildNotePanel("agents", "[grey50]No agents available in the catalog.[/]", AccentDeepSkyBlue)); return; } + if (filteredAgents.Length == 0) + { + panel.AddControl(BuildNotePanel("agents", $"[grey50]No agents match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } var list = StyledList("Agents (Enter for details)") .MaxVisibleItems(14) .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var agent in agentCatalog.Agents.OrderBy(a => a.Name, StringComparer.Ordinal)) + foreach (var agent in filteredAgents) { var isInstalled = installed.Any(i => string.Equals(i.Agent.Name, agent.Name, StringComparison.OrdinalIgnoreCase)); list.AddItem($"{(isInstalled ? "✓ " : "○ ")}{Escape(ToAlias(agent.Name))} [dim]{Escape(CompactDescription(agent.Description))}[/]", agent); @@ -1407,6 +1568,196 @@ void Close() .BuildAndShow(); } + /// + /// Opens a small modal hosting a PromptControl. Pressing Enter sets the active page's + /// _searchFilter and rebuilds it. Esc dismisses without changing the filter. Triggered by + /// `/` from any list-bearing page. + /// + private void ShowSearchOverlay() + { + if (_ws is null) return; + Window? modal = null; + + void Close() + { + if (modal is not null) _ws.CloseWindow(modal); + } + + var prompt = Controls.Prompt($" / ") + .UnfocusOnEnter(false) + .OnEntered((_, query) => + { + _searchFilter = (query ?? string.Empty).Trim(); + Close(); + RebuildActivePage(); + }) + .Build(); + + var hint = new MarkupControl(new List + { + "[grey50]Type to filter the current list. [bold]Enter[/] applies, [bold]Esc[/] cancels.[/]", + string.IsNullOrEmpty(_searchFilter) ? string.Empty : $"[grey50]current:[/] [yellow]{Escape(_searchFilter)}[/]", + }); + + modal = new WindowBuilder(_ws) + .WithTitle("search") + .WithSize(Math.Clamp(SafeConsole(() => Console.WindowWidth, 100) - 20, 50, 80), 9) + .Centered() + .AsModal() + .Minimizable(false) + .Maximizable(false) + .WithBorderStyle(BorderStyle.Rounded) + .WithBorderColor(AccentYellow) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + Close(); + e.Handled = true; + } + }) + .AddControl(hint) + .AddControl(prompt) + .BuildAndShow(); + } + + /// + /// Global command palette (Ctrl+P). Opens a centered modal hosting a PromptControl + a + /// ListControl pre-populated with every catalog skill, bundle, and agent. Enter on the prompt + /// filters the list; Enter on the list activates the entry (skill/bundle/agent detail modal). + /// + private void ShowCommandPalette(ConsoleWindowSystem ws) + { + Window? modal = null; + var allEntries = BuildPaletteEntries(); + + void Close() + { + if (modal is not null) ws.CloseWindow(modal); + } + + var listControl = StyledList(null).MaxVisibleItems(14).WithScrollbarVisibility(ScrollbarVisibility.Auto).Build(); + listControl.ItemActivated += (_, item) => + { + if (item.Tag is PaletteEntry entry) + { + Close(); + entry.Activate(); + } + }; + void FillList(string filter) + { + listControl.ClearItems(); + var f = filter.Trim(); + var matches = string.IsNullOrEmpty(f) + ? allEntries + : allEntries.Where(e => e.SearchHaystack.Contains(f, StringComparison.OrdinalIgnoreCase)).ToArray(); + foreach (var entry in matches.Take(80)) + { + listControl.AddItem(new ListItem($"[{entry.AccentMarkup}]{entry.IconLabel}[/] {Escape(entry.Label)} [grey50]{Escape(entry.Detail)}[/]") { Tag = entry }); + } + } + + FillList(string.Empty); + + var prompt = Controls.Prompt(" > ") + .UnfocusOnEnter(false) + .OnEntered((_, query) => + { + FillList(query ?? string.Empty); + }) + .Build(); + + modal = new WindowBuilder(ws) + .WithTitle("command palette · Esc to close") + .WithSize(Math.Clamp(SafeConsole(() => Console.WindowWidth, 120) - 10, 64, 100), 22) + .Centered() + .AsModal() + .Minimizable(false) + .Maximizable(false) + .WithBorderStyle(BorderStyle.Rounded) + .WithBorderColor(AccentDeepSkyBlue) + .OnKeyPressed((_, e) => + { + if (e.KeyInfo.Key == ConsoleKey.Escape) + { + Close(); + e.Handled = true; + } + }) + .AddControl(prompt) + .AddControl(listControl) + .BuildAndShow(); + } + + /// + /// Builds the union of every searchable entry — skills, bundles, agents, settings actions — + /// used to populate the command palette. Each entry knows how to activate itself. + /// + private IReadOnlyList BuildPaletteEntries() + { + var entries = new List(); + + foreach (var skill in skillCatalog.Skills) + { + entries.Add(new PaletteEntry( + IconLabel: "◇ skill", + AccentMarkup: "turquoise2", + Label: ToAlias(skill.Name), + Detail: $"{skill.Stack} / {skill.Lane}", + SearchHaystack: $"{skill.Name} {skill.Stack} {skill.Lane}", + Activate: () => { if (_ws is not null && _activePanel is not null) ShowSkillDetailModal(_ws, _activePanel, skill); })); + } + + foreach (var bundle in skillCatalog.Packages) + { + var b = bundle; + entries.Add(new PaletteEntry( + IconLabel: "□ bundle", + AccentMarkup: "springgreen3", + Label: b.Name, + Detail: $"{b.Skills.Count} skill(s)", + SearchHaystack: $"{b.Name} {b.Title} bundle package", + Activate: () => { if (_ws is not null && _activePanel is not null) ShowBundleModal(_ws, _activePanel, b, primaryOnly: false); })); + } + + foreach (var agent in agentCatalog.Agents) + { + var a = agent; + entries.Add(new PaletteEntry( + IconLabel: "△ agent", + AccentMarkup: "mediumpurple2", + Label: ToAlias(a.Name), + Detail: CompactDescription(a.Description), + SearchHaystack: $"{a.Name} agent orchestration {a.Description}", + Activate: () => { if (_ws is not null && _activePanel is not null) ShowAgentModal(_ws, _activePanel, a); })); + } + + // Settings actions and page jumps. + entries.Add(new PaletteEntry("⚙ settings", "deepskyblue1", "Settings", "open workspace settings", "settings platform scope refresh workspace", () => NavigateTo(HomeAction.Workspace))); + entries.Add(new PaletteEntry("↻ refresh", "deepskyblue1", "Refresh catalog", "pull the latest catalog", "refresh catalog reload pull", () => RefreshCatalogFromUi())); + entries.Add(new PaletteEntry("◈ home", "deepskyblue1", "Home", "session and telemetry", "home session telemetry", () => { if (_ws is not null && _activePanel is not null) BuildHomePage(_ws, _activePanel); })); + entries.Add(new PaletteEntry("→ skills", "turquoise2", "Skills", "browse the catalog", "skills browse catalog", () => NavigateTo(HomeAction.BrowseSkills))); + entries.Add(new PaletteEntry("→ installed","green", "Installed", "manage installed skills", "installed manage update remove", () => NavigateTo(HomeAction.ManageInstalled))); + entries.Add(new PaletteEntry("→ collections","deepskyblue1","Collections", "browse collections", "collections browse", () => NavigateTo(HomeAction.BrowseCollections))); + entries.Add(new PaletteEntry("→ bundles", "springgreen3", "Bundles", "focused bundles", "bundles focused", () => NavigateTo(HomeAction.BrowseBundles))); + entries.Add(new PaletteEntry("→ packages", "turquoise2", "Packages", "NuGet signals", "packages nuget signals", () => NavigateTo(HomeAction.BrowsePackages))); + entries.Add(new PaletteEntry("→ agents", "mediumpurple2","Agents", "orchestration agents", "agents orchestration", () => NavigateTo(HomeAction.BrowseAgents))); + entries.Add(new PaletteEntry("→ project", "deepskyblue1", "Project", "scan and install", "project scan recommend", () => NavigateTo(HomeAction.SyncProject))); + entries.Add(new PaletteEntry("→ analysis", "deepskyblue1", "Analysis", "catalog analysis", "analysis stats heaviest", () => NavigateTo(HomeAction.Analysis))); + entries.Add(new PaletteEntry("→ about", "grey", "About", "version and surface map", "about version", () => NavigateTo(HomeAction.About))); + + return entries; + } + + private sealed record PaletteEntry( + string IconLabel, + string AccentMarkup, + string Label, + string Detail, + string SearchHaystack, + Action Activate); + private static ITheme BuildTheme() => new ModernGrayTheme { ListHoverBackgroundColor = SelectionBg, @@ -1443,8 +1794,12 @@ private void RebuildStatusBar(HomeAction? page) bar.ClearAll(); bar.AddLeft("↑↓", "Move"); - bar.AddLeft("←→", "Switch pane"); bar.AddLeft("Enter", page is HomeAction.SyncProject ? "Install" : page is HomeAction.Workspace ? "Change" : "Open"); + if (IsListBearingPage(page)) + { + bar.AddLeft("/", "Search", ShowSearchOverlay); + } + bar.AddLeft("Ctrl+P", "Palette", () => { if (_ws is not null) ShowCommandPalette(_ws); }); foreach (var (key, label, action) in PageShortcuts(page)) { bar.AddLeft(key, label, action); @@ -1538,6 +1893,37 @@ private void ToastResult(object? result, string failureMessage, string successMe Toast(successMessage, NotificationSeverity.Success); } + /// + /// Case-insensitive substring test against the current search filter. Empty filter matches + /// everything. Tokens (any of the supplied parts) are matched independently — a row is kept + /// if ANY token contains the filter so callers can pass name + collection + lane as separate + /// tokens and get the expected "OR" behavior. + /// + private bool MatchesFilter(params string?[] tokens) + { + if (string.IsNullOrWhiteSpace(_searchFilter)) return true; + var needle = _searchFilter; + foreach (var token in tokens) + { + if (token is not null && token.Contains(needle, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + /// + /// Renders a small "filter: …" chip at the top of a list-bearing page so the user knows + /// the visible list is filtered. Caller is responsible for only emitting it when filter is set. + /// + private void AddSearchChip(ScrollablePanelControl panel) + { + if (string.IsNullOrWhiteSpace(_searchFilter)) return; + panel.AddControl(BuildNotePanel( + "filter", + $"[yellow]matching “{Escape(_searchFilter)}”[/] [grey50]· press[/] [bold]Esc[/] [grey50]to clear[/]", + AccentYellow)); + } + private async Task ClockLoopAsync(Window window, CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) From 83d41dd2a7e57bfa00d6448509e06437f7b09981 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Thu, 14 May 2026 00:26:48 +0300 Subject: [PATCH 6/7] feat(cli): sortable TableControl + native BarGraph charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installed and Analysis pages drop the markup-formatted ListControl rows in favor of native sortable TableControls. Installed columns: status, skill, collection, lane, installed version, latest, tokens. Analysis "heaviest skills" columns: skill, collection, lane, tokens. Click a header to sort; Enter or double-click activates the row's detail modal. Outdated rows render in yellow via TableRow.ForegroundColor — no per-cell markup needed. Analysis grows two BarGraphControl sections below the table: tokens by skill (top 12, standard threshold gradient — green/yellow/red) and skills per collection (top 8, turquoise). Bars are static visualization; the table below covers drill-down. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InteractiveConsoleApp.Shell.cs | 130 +++++++++++++++--- 1 file changed, 113 insertions(+), 17 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index fd75990..c91d48b 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -707,22 +707,46 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p return; } - var list = StyledList("Installed skills (Enter for details)") - .MaxVisibleItems(14) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); + // Real sortable TableControl — columns can be sorted by clicking the header. Per-row + // foreground color flags outdated rows yellow without needing markup escaping per cell. + var table = Controls.Table() + .WithTitle("Installed skills (Enter for details)") + .AddColumn("Status", TextJustification.Center, width: 8) + .AddColumn("Skill") + .AddColumn("Collection") + .AddColumn("Lane") + .AddColumn("Installed", TextJustification.Right) + .AddColumn("Latest", TextJustification.Right) + .AddColumn("Tokens", TextJustification.Right) + .WithSorting() + .Rounded() + .WithBorderColor(AccentGreen); foreach (var record in filtered) { - // Escape: the label contains "[stack / lane]" which would otherwise be parsed as markup. - list.AddItem((record.IsCurrent ? "✓ " : "↻ ") + Escape(BuildInstalledSkillChoiceLabel(record)), record); + var row = new TableRow( + record.IsCurrent ? "✓ current" : "↻ update", + ToAlias(record.Skill.Name), + record.Skill.Stack, + record.Skill.Lane, + record.InstalledVersion, + record.Skill.Version, + FormatTokenCount(record.Skill.TokenCount)) + { + Tag = record, + ForegroundColor = record.IsCurrent ? null : AccentYellow, + }; + table.AddRow(row); } - list.OnItemActivated((_, item) => + // RowActivated fires on Enter or double-click; index is into the filtered array because + // we appended rows in the same order. + table.OnRowActivated((_, idx) => { - if (item.Tag is InstalledSkillRecord record) + if (idx >= 0 && idx < filtered.Length) { - ShowInstalledSkillModal(ws, panel, record); + ShowInstalledSkillModal(ws, panel, filtered[idx]); } }); - panel.AddControl(list.Build()); + panel.AddControl(table.Build()); if (outdated.Length > 0) { @@ -1277,21 +1301,63 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa $"[grey50]skills[/] {view.SkillCount} [grey50]installed[/] {view.InstalledCount} [grey50]tokens[/] {FormatTokenCount(view.TokenCount)}")).ToList(); panel.AddControl(BuildCardGrid(collectionCards, maxColumns: 3)); - var heavyList = StyledList("Heaviest skills (Enter for details)") - .MaxVisibleItems(12) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); + var heavyTable = Controls.Table() + .WithTitle("Heaviest skills (Enter for details)") + .AddColumn("Skill") + .AddColumn("Collection") + .AddColumn("Lane") + .AddColumn("Tokens", TextJustification.Right) + .WithSorting() + .Rounded() + .WithBorderColor(AccentDeepSkyBlue); foreach (var skill in heaviest) { - heavyList.AddItem($"{FormatTokenCount(skill.TokenCount)} tokens · {Escape(ToAlias(skill.Name))} [dim]{Escape(skill.Stack)}[/]", skill); + heavyTable.AddRow(new TableRow(ToAlias(skill.Name), skill.Stack, skill.Lane, FormatTokenCount(skill.TokenCount)) { Tag = skill }); } - heavyList.OnItemActivated((_, item) => + heavyTable.OnRowActivated((_, idx) => { - if (item.Tag is SkillEntry skill) + if (idx >= 0 && idx < heaviest.Length) { - ShowSkillDetailModal(ws, panel, skill); + ShowSkillDetailModal(ws, panel, heaviest[idx]); } }); - panel.AddControl(heavyList.Build()); + panel.AddControl(heavyTable.Build()); + + // Native bar charts: skills sorted by tokens (heaviest 12), then collections sorted by + // skill count (top 8). Each bar uses the standard threshold gradient so the eye picks + // up "big" entries immediately. + if (heaviest.Length > 0) + { + var maxTokens = heaviest.Max(s => s.TokenCount); + var chart1 = new ScrollablePanelControl + { + ShowScrollbar = false, + EnableMouseWheel = false, + }; + foreach (var skill in heaviest) + { + chart1.AddControl(BuildSkillTokenBar(skill, maxTokens)); + } + panel.AddControl(BuildSectionPanel("tokens by skill (top 12)", string.Empty, AccentDeepSkyBlue)); + panel.AddControl(chart1); + } + + var topCollections = views.Take(8).ToArray(); + if (topCollections.Length > 0) + { + var maxCount = topCollections.Max(v => v.SkillCount); + var chart2 = new ScrollablePanelControl + { + ShowScrollbar = false, + EnableMouseWheel = false, + }; + foreach (var view in topCollections) + { + chart2.AddControl(BuildCollectionCountBar(view, maxCount)); + } + panel.AddControl(BuildSectionPanel("skills per collection (top 8)", string.Empty, AccentTurquoise)); + panel.AddControl(chart2); + } if (signals.Count > 0) { @@ -1301,6 +1367,36 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa } } + /// + /// A horizontal bar showing one skill's token weight against the chart's max. Color follows + /// a green→yellow→red threshold gradient so heavy skills stand out visually. + /// + private static BarGraphControl BuildSkillTokenBar(SkillEntry skill, int maxTokens) + => Controls.BarGraph() + .WithLabel($"{ToAlias(skill.Name)}") + .WithLabelWidth(28) + .WithValue(skill.TokenCount) + .WithMaxValue(maxTokens == 0 ? 1 : maxTokens) + .WithValueFormat("N0") + .ShowValue(true) + .WithStandardGradient() + .Build(); + + /// + /// A horizontal bar showing one collection's skill count against the chart's max. Uses the + /// turquoise accent for the filled portion. + /// + private static BarGraphControl BuildCollectionCountBar(CollectionCatalogView view, int maxCount) + => Controls.BarGraph() + .WithLabel(view.Collection) + .WithLabelWidth(28) + .WithValue(view.SkillCount) + .WithMaxValue(maxCount == 0 ? 1 : maxCount) + .WithValueFormat("0") + .ShowValue(true) + .WithFilledColor(AccentTurquoise) + .Build(); + // ------------------------------------------------------------------------- // Remove all / Update all action pages // ------------------------------------------------------------------------- From e772bbd4e111d074998dd1c11ae8fff6338fea55 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Thu, 14 May 2026 17:14:26 +0300 Subject: [PATCH 7/7] Resync external catalog to skill-validator nightly-22 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../exp-assertion-quality/manifest.json | 5 - .../exp-dotnet-test-frameworks/SKILL.md | 118 --------- .../exp-dotnet-test-frameworks/manifest.json | 5 - .../exp-test-gap-analysis/manifest.json | 5 - .../skills/exp-test-maintainability/SKILL.md | 2 +- .../exp-test-smell-detection/manifest.json | 5 - .../skills/exp-test-tagging/manifest.json | 5 - .../migrate-dotnet10-to-dotnet11/SKILL.md | 58 ++++- .../references/aspnetcore-dotnet10to11.md | 27 ++ .../references/core-libraries-dotnet10to11.md | 54 ++++ .../references/cryptography-dotnet10to11.md | 11 + .../references/efcore-dotnet10to11.md | 68 ++++- .../references/runtime-jit-dotnet10to11.md | 8 + .../references/sdk-msbuild-dotnet10to11.md | 16 ++ .../skills/csharp-scripts/SKILL.md | 125 +++++++-- .../agents/code-testing-fixer/AGENT.md | 14 +- .../agents/code-testing-generator/AGENT.md | 243 ++++++++++++++---- .../agents/code-testing-implementer/AGENT.md | 39 ++- .../agents/test-quality-auditor/AGENT.md | 18 +- .../skills/assertion-quality}/SKILL.md | 4 +- .../skills/assertion-quality/manifest.json | 5 + .../skills/code-testing-agent/SKILL.md | 4 +- .../skills/code-testing-extensions/SKILL.md | 3 + .../extensions/dotnet.md | 12 + .../extensions/powershell.md | 110 ++++++++ .../extensions/python.md | 132 ++++++++++ .../extensions/typescript.md | 136 ++++++++++ .../detect-static-dependencies/SKILL.md | 6 + .../skills/migrate-mstest-v1v2-to-v3/SKILL.md | 2 +- .../skills/migrate-vstest-to-mtp/SKILL.md | 59 ++++- .../skills/migrate-xunit-to-xunit-v3/SKILL.md | 83 +++--- .../skills/test-anti-patterns/SKILL.md | 4 +- .../skills/test-gap-analysis}/SKILL.md | 6 +- .../skills/test-gap-analysis/manifest.json | 5 + .../skills/test-smell-detection}/SKILL.md | 14 +- .../skills/test-smell-detection/manifest.json | 5 + .../references/test-smell-catalog.md | 0 .../skills/test-tagging}/SKILL.md | 4 +- .../skills/test-tagging/manifest.json | 5 + .../skills/writing-mstest-tests/SKILL.md | 75 +++--- .../exp-dotnet-test-frameworks/SKILL.md | 118 --------- .../skills/exp-test-maintainability/SKILL.md | 2 +- .../dotnet-skills/dotnet-test/README.md | 103 ++++++++ .../agents/code-testing-fixer.agent.md | 14 +- .../agents/code-testing-generator.agent.md | 243 ++++++++++++++---- .../agents/code-testing-implementer.agent.md | 39 ++- .../agents/test-quality-auditor.agent.md | 18 +- .../skills/assertion-quality}/SKILL.md | 4 +- .../skills/code-testing-agent/SKILL.md | 4 +- .../skills/code-testing-extensions/SKILL.md | 3 + .../extensions/dotnet.md | 12 + .../extensions/powershell.md | 110 ++++++++ .../extensions/python.md | 132 ++++++++++ .../extensions/typescript.md | 136 ++++++++++ .../detect-static-dependencies/SKILL.md | 6 + .../skills/migrate-mstest-v1v2-to-v3/SKILL.md | 2 +- .../skills/migrate-vstest-to-mtp/SKILL.md | 59 ++++- .../skills/migrate-xunit-to-xunit-v3/SKILL.md | 83 +++--- .../skills/test-anti-patterns/SKILL.md | 4 +- .../skills/test-gap-analysis}/SKILL.md | 6 +- .../skills/test-smell-detection}/SKILL.md | 14 +- .../references/test-smell-catalog.md | 0 .../skills/test-tagging}/SKILL.md | 4 +- .../skills/writing-mstest-tests/SKILL.md | 75 +++--- .../migrate-dotnet10-to-dotnet11/SKILL.md | 58 ++++- .../references/aspnetcore-dotnet10to11.md | 27 ++ .../references/core-libraries-dotnet10to11.md | 54 ++++ .../references/cryptography-dotnet10to11.md | 11 + .../references/efcore-dotnet10to11.md | 68 ++++- .../references/runtime-jit-dotnet10to11.md | 8 + .../references/sdk-msbuild-dotnet10to11.md | 16 ++ .../dotnet/skills/csharp-scripts/SKILL.md | 125 +++++++-- external-sources/vendir.lock.yml | 8 +- 73 files changed, 2375 insertions(+), 691 deletions(-) delete mode 100644 catalog/Platform/Official-DotNet-Experimental/skills/exp-assertion-quality/manifest.json delete mode 100644 catalog/Platform/Official-DotNet-Experimental/skills/exp-dotnet-test-frameworks/SKILL.md delete mode 100644 catalog/Platform/Official-DotNet-Experimental/skills/exp-dotnet-test-frameworks/manifest.json delete mode 100644 catalog/Platform/Official-DotNet-Experimental/skills/exp-test-gap-analysis/manifest.json delete mode 100644 catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/manifest.json delete mode 100644 catalog/Platform/Official-DotNet-Experimental/skills/exp-test-tagging/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/aspnetcore-dotnet10to11.md rename catalog/{Platform/Official-DotNet-Experimental/skills/exp-assertion-quality => Testing/Official-DotNet-Test/skills/assertion-quality}/SKILL.md (98%) create mode 100644 catalog/Testing/Official-DotNet-Test/skills/assertion-quality/manifest.json create mode 100644 catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/powershell.md create mode 100644 catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/python.md create mode 100644 catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/typescript.md rename catalog/{Platform/Official-DotNet-Experimental/skills/exp-test-gap-analysis => Testing/Official-DotNet-Test/skills/test-gap-analysis}/SKILL.md (98%) create mode 100644 catalog/Testing/Official-DotNet-Test/skills/test-gap-analysis/manifest.json rename {external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-smell-detection => catalog/Testing/Official-DotNet-Test/skills/test-smell-detection}/SKILL.md (94%) create mode 100644 catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/manifest.json rename catalog/{Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection => Testing/Official-DotNet-Test/skills/test-smell-detection}/references/test-smell-catalog.md (100%) rename catalog/{Platform/Official-DotNet-Experimental/skills/exp-test-tagging => Testing/Official-DotNet-Test/skills/test-tagging}/SKILL.md (98%) create mode 100644 catalog/Testing/Official-DotNet-Test/skills/test-tagging/manifest.json delete mode 100644 external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-dotnet-test-frameworks/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-test/README.md rename external-sources/upstreams/dotnet-skills/{dotnet-experimental/skills/exp-assertion-quality => dotnet-test/skills/assertion-quality}/SKILL.md (98%) create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/powershell.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/python.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/typescript.md rename external-sources/upstreams/dotnet-skills/{dotnet-experimental/skills/exp-test-gap-analysis => dotnet-test/skills/test-gap-analysis}/SKILL.md (98%) rename {catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection => external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-smell-detection}/SKILL.md (94%) rename external-sources/upstreams/dotnet-skills/{dotnet-experimental/skills/exp-test-smell-detection => dotnet-test/skills/test-smell-detection}/references/test-smell-catalog.md (100%) rename external-sources/upstreams/dotnet-skills/{dotnet-experimental/skills/exp-test-tagging => dotnet-test/skills/test-tagging}/SKILL.md (98%) create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/aspnetcore-dotnet10to11.md diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-assertion-quality/manifest.json b/catalog/Platform/Official-DotNet-Experimental/skills/exp-assertion-quality/manifest.json deleted file mode 100644 index 5fff1ba..0000000 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-assertion-quality/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "0.1.0", - "category": "Testing", - "compatibility": "Requires a .NET repository where experimental upstream dotnet/skills guidance is acceptable." -} diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-dotnet-test-frameworks/SKILL.md b/catalog/Platform/Official-DotNet-Experimental/skills/exp-dotnet-test-frameworks/SKILL.md deleted file mode 100644 index fe4b2dd..0000000 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-dotnet-test-frameworks/SKILL.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: exp-dotnet-test-frameworks -description: "Reference data for .NET test framework detection patterns, assertion APIs, skip annotations, setup/teardown methods, and common test smell indicators across MSTest, xUnit, NUnit, and TUnit. Loaded by test analysis skills (exp-test-smell-detection, exp-assertion-quality, exp-test-maintainability, exp-test-tagging) as framework-specific lookup tables." -user-invocable: false -license: MIT ---- - -# .NET Test Framework Reference - -Language-specific detection patterns for .NET test frameworks (MSTest, xUnit, NUnit, TUnit). - -## Test File Identification - -| Framework | Test class markers | Test method markers | -| --------- | ------------------ | ------------------- | -| MSTest | `[TestClass]` | `[TestMethod]`, `[DataTestMethod]` | -| xUnit | _(none — convention-based)_ | `[Fact]`, `[Theory]` | -| NUnit | `[TestFixture]` | `[Test]`, `[TestCase]`, `[TestCaseSource]` | -| TUnit | `[ClassDataSource]` | `[Test]` | - -## Assertion APIs by Framework - -| Category | MSTest | xUnit | NUnit | -| -------- | ------ | ----- | ----- | -| Equality | `Assert.AreEqual` | `Assert.Equal` | `Assert.That(x, Is.EqualTo(y))` | -| Boolean | `Assert.IsTrue` / `Assert.IsFalse` | `Assert.True` / `Assert.False` | `Assert.That(x, Is.True)` | -| Null | `Assert.IsNull` / `Assert.IsNotNull` | `Assert.Null` / `Assert.NotNull` | `Assert.That(x, Is.Null)` | -| Exception | `Assert.Throws()` / `Assert.ThrowsExactly()` | `Assert.Throws()` | `Assert.That(() => ..., Throws.TypeOf())` | -| Collection | `CollectionAssert.Contains` | `Assert.Contains` | `Assert.That(col, Has.Member(x))` | -| String | `StringAssert.Contains` | `Assert.Contains(str, sub)` | `Assert.That(str, Does.Contain(sub))` | -| Type | `Assert.IsInstanceOfType` | `Assert.IsAssignableFrom` | `Assert.That(x, Is.InstanceOf())` | -| Inconclusive | `Assert.Inconclusive()` | _skip via `[Fact(Skip)]`_ | `Assert.Inconclusive()` | -| Fail | `Assert.Fail()` | `Assert.Fail()` (.NET 10+) | `Assert.Fail()` | - -Third-party assertion libraries: `Should*` (Shouldly), `.Should()` (FluentAssertions / AwesomeAssertions), `Verify()` (Verify). - -## Sleep/Delay Patterns - -| Pattern | Example | -| ------- | ------- | -| Thread sleep | `Thread.Sleep(2000)` | -| Task delay | `await Task.Delay(1000)` | -| SpinWait | `SpinWait.SpinUntil(() => condition, timeout)` | - -## Skip/Ignore Annotations - -| Framework | Annotation | With reason | -| --------- | ---------- | ----------- | -| MSTest | `[Ignore]` | `[Ignore("reason")]` | -| xUnit | `[Fact(Skip = "reason")]` | _(reason is required)_ | -| NUnit | `[Ignore("reason")]` | _(reason is required)_ | -| TUnit | `[Skip("reason")]` | _(reason is required)_ | -| Conditional | `#if false` / `#if NEVER` | _(no reason possible)_ | - -## Exception Handling — Idiomatic Alternatives - -When a test uses `try`/`catch` to verify exceptions, suggest the framework-native alternative: - -**MSTest:** - -```csharp -// Instead of try/catch (matches exact type): -var ex = Assert.ThrowsExactly( - () => processor.ProcessOrder(emptyOrder)); -Assert.AreEqual("Order must contain at least one item", ex.Message); - -// Or (also matches derived types): -var ex = Assert.Throws( - () => processor.ProcessOrder(emptyOrder)); -Assert.AreEqual("Order must contain at least one item", ex.Message); -``` - -**xUnit:** - -```csharp -var ex = Assert.Throws( - () => processor.ProcessOrder(emptyOrder)); -Assert.Equal("Order must contain at least one item", ex.Message); -``` - -**NUnit:** - -```csharp -var ex = Assert.Throws( - () => processor.ProcessOrder(emptyOrder)); -Assert.That(ex.Message, Is.EqualTo("Order must contain at least one item")); -``` - -## Mystery Guest — Common .NET Patterns - -| Smell indicator | What to look for | -| --------------- | ---------------- | -| File system | `File.ReadAllText`, `File.Exists`, `File.WriteAllBytes`, `Directory.GetFiles`, `Path.Combine` with hard-coded paths | -| Database | `SqlConnection`, `DbContext` (without in-memory provider), `SqlCommand` | -| Network | `HttpClient` without `HttpMessageHandler` override, `WebRequest`, `TcpClient` | -| Environment | `Environment.GetEnvironmentVariable`, `Environment.CurrentDirectory` | -| Acceptable | `MemoryStream`, `StringReader`, `InMemory` database providers, custom `DelegatingHandler` | - -## Integration Test Markers - -Recognize these as integration tests (adjust smell severity accordingly): - -- Class name contains `Integration`, `E2E`, `EndToEnd`, or `Acceptance` -- `[TestCategory("Integration")]` (MSTest) -- `[Trait("Category", "Integration")]` (xUnit) -- `[Category("Integration")]` (NUnit) -- Project name ending in `.IntegrationTests` or `.E2ETests` - -## Setup/Teardown Methods - -| Framework | Setup | Teardown | -| --------- | ----- | -------- | -| MSTest | `[TestInitialize]` or constructor | `[TestCleanup]` or `IDisposable.Dispose` / `IAsyncDisposable.DisposeAsync` | -| xUnit | constructor | `IDisposable.Dispose` / `IAsyncDisposable.DisposeAsync` | -| NUnit | `[SetUp]` | `[TearDown]` | -| MSTest (class) | `[ClassInitialize]` | `[ClassCleanup]` | -| NUnit (class) | `[OneTimeSetUp]` | `[OneTimeTearDown]` | -| xUnit (class) | `IClassFixture` | fixture's `Dispose` | diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-dotnet-test-frameworks/manifest.json b/catalog/Platform/Official-DotNet-Experimental/skills/exp-dotnet-test-frameworks/manifest.json deleted file mode 100644 index 5fff1ba..0000000 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-dotnet-test-frameworks/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "0.1.0", - "category": "Testing", - "compatibility": "Requires a .NET repository where experimental upstream dotnet/skills guidance is acceptable." -} diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-gap-analysis/manifest.json b/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-gap-analysis/manifest.json deleted file mode 100644 index 5fff1ba..0000000 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-gap-analysis/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "0.1.0", - "category": "Testing", - "compatibility": "Requires a .NET repository where experimental upstream dotnet/skills guidance is acceptable." -} diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-maintainability/SKILL.md b/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-maintainability/SKILL.md index 4a6352e..64d780b 100644 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-maintainability/SKILL.md +++ b/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-maintainability/SKILL.md @@ -36,7 +36,7 @@ Analyze .NET test code for maintainability issues: duplicated boilerplate, copy- ### Step 1: Gather the test code -Read all test files the user provides or references. If the user points to a directory or project, scan for all test files — see the `exp-dotnet-test-frameworks` skill for framework-specific markers. +Read all test files the user provides or references. If the user points to a directory or project, scan for all test files — see the `dotnet-test-frameworks` skill for framework-specific markers. ### Step 2: Identify maintainability issues diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/manifest.json b/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/manifest.json deleted file mode 100644 index 5fff1ba..0000000 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "0.1.0", - "category": "Testing", - "compatibility": "Requires a .NET repository where experimental upstream dotnet/skills guidance is acceptable." -} diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-tagging/manifest.json b/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-tagging/manifest.json deleted file mode 100644 index 5fff1ba..0000000 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-tagging/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "0.1.0", - "category": "Testing", - "compatibility": "Requires a .NET repository where experimental upstream dotnet/skills guidance is acceptable." -} diff --git a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/SKILL.md b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/SKILL.md index 2844e88..5660c48 100644 --- a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/SKILL.md +++ b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/SKILL.md @@ -11,7 +11,7 @@ description: > Dockerfiles for .NET 11. DO NOT USE FOR: .NET Framework migrations, upgrading from .NET 9 or earlier, greenfield .NET 11 projects, or cosmetic modernization unrelated to the upgrade. - NOTE: .NET 11 is in preview. Covers breaking changes through Preview 1. + NOTE: .NET 11 is in preview. Covers breaking changes through Preview 3. license: MIT --- @@ -19,7 +19,7 @@ license: MIT Migrate a .NET 10 project or solution to .NET 11, systematically resolving all breaking changes. The outcome is a project targeting `net11.0` that builds cleanly, passes tests, and accounts for every behavioral, source-incompatible, and binary-incompatible change introduced in .NET 11. -> **Note:** .NET 11 is currently in preview. This skill covers breaking changes documented through Preview 1. It will be updated as additional previews ship. +> **Note:** .NET 11 is currently in preview. This skill covers breaking changes documented through Preview 3. ## When to Use @@ -59,10 +59,14 @@ Migrate a .NET 10 project or solution to .NET 11, systematically resolving all b - **SDK attribute**: `Microsoft.NET.Sdk.Web` → ASP.NET Core; `Microsoft.NET.Sdk.WindowsDesktop` with `` or `` → WPF/WinForms - **PackageReferences**: `Microsoft.EntityFrameworkCore.*` → EF Core; `Microsoft.EntityFrameworkCore.Cosmos` → Cosmos DB provider - **Dockerfile presence** → Container changes relevant - - **Cryptography API usage** → DSA on macOS affected + - **Cryptography API usage** → DSA on macOS affected; AIA cert download changes relevant - **Compression API usage** → DeflateStream/GZipStream/ZipArchive changes relevant - - **TAR API usage** → Header checksum validation change relevant + - **TAR API usage** → Header checksum validation and HardLink entry changes relevant - **`NamedPipeClientStream` usage with `SafePipeHandle`** → SYSLIB0063 constructor obsoletion relevant + - **`BackgroundService` usage** → Unhandled exceptions now stop the host + - **`Microsoft.OpenApi` direct usage** → v3 API breaking changes in ASP.NET Core OpenAPI + - **EF Core SQL Server with Entra ID auth** → SqlClient 7.0 auth dependency changes + - **NativeAOT native libraries on Unix** → Output filename prefix changed 4. Record which reference documents are relevant (see the reference loading table in Step 3). 5. Do a **clean build** (`dotnet build --no-incremental` or delete `bin`/`obj`) on the current `net10.0` target to establish a clean baseline. Record any pre-existing warnings. @@ -93,9 +97,10 @@ Load reference documents based on the project's technology areas: | `references/csharp-compiler-dotnet10to11.md` | Always (C# 15 compiler breaking changes) | | `references/core-libraries-dotnet10to11.md` | Always (applies to all .NET 11 projects) | | `references/sdk-msbuild-dotnet10to11.md` | Always (SDK and build tooling changes) | -| `references/efcore-dotnet10to11.md` | Project uses Entity Framework Core (especially Cosmos DB provider) | -| `references/cryptography-dotnet10to11.md` | Project uses cryptography APIs or targets macOS | -| `references/runtime-jit-dotnet10to11.md` | Deploying to older hardware or embedded devices | +| `references/aspnetcore-dotnet10to11.md` | Project uses ASP.NET Core (OpenAPI, Blazor) | +| `references/efcore-dotnet10to11.md` | Project uses Entity Framework Core | +| `references/cryptography-dotnet10to11.md` | Project uses cryptography APIs, mTLS, or targets macOS | +| `references/runtime-jit-dotnet10to11.md` | Deploying to older hardware, embedded devices, or using NativeAOT | Work through each build error systematically. Common patterns: @@ -115,6 +120,12 @@ Work through each build error systematically. Common patterns: 8. **`when` switch-expression-arm parsing** — `(X.Y) when` is now parsed as a constant pattern with a `when` clause instead of a cast expression, which can cause existing code to fail to compile or change meaning. Review switch expressions using `when` and adjust syntax as needed. +9. **Microsoft.OpenApi v3 breaking changes** — `Microsoft.AspNetCore.OpenApi` now depends on `Microsoft.OpenApi` 3.x. Code using `Microsoft.OpenApi` types directly (`OpenApiDocument`, `OpenApiSchema`, etc.) will have compile errors. Follow the v3 upgrade guide. + +10. **EF Core Design package no longer transitive** — `Microsoft.EntityFrameworkCore.Tools` and `.Tasks` no longer depend on `.Design`. Add an explicit `PackageReference` if needed. + +11. **EFOptimizeContext MSBuild property removed** — Replace with `` and ``. + ### Step 4: Address behavioral changes These changes compile successfully but alter runtime behavior. Review each one and determine impact: @@ -137,6 +148,24 @@ These changes compile successfully but alter runtime behavior. Review each one a 9. **Mono launch target for .NET Framework** — No longer set automatically. If using Mono for .NET Framework apps on Linux, specify explicitly. +10. **Unhandled BackgroundService exceptions stop the host** — Exceptions from `ExecuteAsync()` now propagate and crash the host. Add try/catch in background services that should not bring down the application. + +11. **ZipArchive CRC32 validation** — ZIP reads now validate CRC32 checksums. Corrupt or truncated archives that previously succeeded will now throw `InvalidDataException`. + +12. **TarWriter emits HardLink entries** — Hard-linked files are now written as `HardLink` entries instead of duplicated data. Consumers of .NET-produced tar archives must handle `HardLink` entries. + +13. **AIA certificate downloads disabled** — Server-side client-certificate validation no longer downloads intermediate CAs via AIA by default. Pre-install the full chain or have clients send intermediates. + +14. **Blazor Virtualize OverscanCount default changed** — Default `OverscanCount` changed from 3 to 15. Set explicitly if performance-sensitive. + +15. **Microsoft.Data.SqlClient 7.0 — Entra ID auth separated** — Azure/Entra ID authentication dependencies removed from the core SqlClient package. Add `Microsoft.Data.SqlClient.Extensions.Azure` if using Entra ID auth. + +16. **SqlVector<T> excluded from SELECT** — Vector properties are no longer auto-loaded. Use explicit projections to include vector values. + +17. **SQLitePCLRaw encryption bundles removed** — `bundle_e_sqlcipher` and other encryption bundle packages removed in SQLitePCLRaw 3.0. + +18. **NativeAOT Unix native library `lib` prefix** — Output filenames now include `lib` prefix on Linux/macOS (e.g., `libMyLib.so`). + ### Step 5: Update infrastructure 1. **Dockerfiles**: Update base images from 10.0 to 11.0: @@ -155,7 +184,7 @@ These changes compile successfully but alter runtime behavior. Review each one a "sdk": { - "version": "10.0.100", - "rollForward": "latestFeature" - + "version": "11.0.100-preview.1", + + "version": "11.0.100-preview.3", + "rollForward": "latestFeature" }, "otherSettings": { @@ -173,11 +202,15 @@ These changes compile successfully but alter runtime behavior. Review each one a 3. If the application is containerized, build and test the container image 4. Smoke-test the application, paying special attention to: - Compression behavior with empty streams - - TAR file reading + - TAR file reading (checksum validation and HardLink entries) - EF Core Cosmos DB operations (must be async) - DSA usage on macOS - Memory-intensive MemoryStream usage - Span collection expression assignments + - BackgroundService exception handling + - mTLS / client certificate chain validation + - EF Core SQL Server with Entra ID authentication + - NativeAOT output filenames on Unix 5. Review the diff and ensure no unintended behavioral changes were introduced ## Reference Documents @@ -189,6 +222,7 @@ The `references/` folder contains detailed breaking change information organized | `references/csharp-compiler-dotnet10to11.md` | Always (C# 15 compiler breaking changes) | | `references/core-libraries-dotnet10to11.md` | Always (applies to all .NET 11 projects) | | `references/sdk-msbuild-dotnet10to11.md` | Always (SDK and build tooling changes) | -| `references/efcore-dotnet10to11.md` | Project uses Entity Framework Core (especially Cosmos DB provider) | -| `references/cryptography-dotnet10to11.md` | Project uses cryptography APIs or targets macOS | -| `references/runtime-jit-dotnet10to11.md` | Deploying to older hardware or embedded devices | +| `references/aspnetcore-dotnet10to11.md` | Project uses ASP.NET Core (OpenAPI, Blazor) | +| `references/efcore-dotnet10to11.md` | Project uses Entity Framework Core | +| `references/cryptography-dotnet10to11.md` | Project uses cryptography APIs, mTLS, or targets macOS | +| `references/runtime-jit-dotnet10to11.md` | Deploying to older hardware, embedded devices, or using NativeAOT | diff --git a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/aspnetcore-dotnet10to11.md b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/aspnetcore-dotnet10to11.md new file mode 100644 index 0000000..bc97680 --- /dev/null +++ b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/aspnetcore-dotnet10to11.md @@ -0,0 +1,27 @@ +# ASP.NET Core Breaking Changes (.NET 11) + +These breaking changes affect ASP.NET Core projects. Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/11 + +> **Note:** .NET 11 is in preview. Additional ASP.NET Core breaking changes are expected in later previews. + +## Source-Incompatible Changes + +### Microsoft.OpenApi updated to v3 with OpenAPI 3.2.0 support (Preview 2) + +**Impact: Medium.** `Microsoft.AspNetCore.OpenApi` updated its dependency from `Microsoft.OpenApi` 2.x to 3.x, adding OpenAPI 3.2.0 document generation. The underlying `Microsoft.OpenApi` library has breaking API changes in the v2→v3 transition. + +Code that directly uses `Microsoft.OpenApi` types (`OpenApiDocument`, `OpenApiSchema`, `OpenApiOperation`, etc.) will have compile errors. + +**Fix:** Follow the [Microsoft.OpenApi v3 upgrade guide](https://github.com/microsoft/OpenAPI.NET/blob/main/docs/upgrade-guide-3.md). If you only use the ASP.NET Core OpenAPI integration (`.WithOpenApi()`, `MapOpenApi()`) without touching the object model directly, no changes are needed. + +Source: https://github.com/dotnet/aspnetcore/pull/65415 + +## Behavioral Changes + +### Blazor Virtualize<T> default OverscanCount changed from 3 to 15 (Preview 3) + +**Impact: Low.** The default `OverscanCount` on the `Virtualize` component changed from `3` to `15` to support variable-height item measurement. `QuickGrid` retains its own default of `3`. + +**Fix:** If performance-sensitive, set `OverscanCount` explicitly: ``. + +Source: https://github.com/dotnet/aspnetcore/pull/64964 diff --git a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/core-libraries-dotnet10to11.md b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/core-libraries-dotnet10to11.md index d5a7122..ad45fbb 100644 --- a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/core-libraries-dotnet10to11.md +++ b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/core-libraries-dotnet10to11.md @@ -85,3 +85,57 @@ Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-librari **Impact: Low.** The minimum supported date for the Japanese Calendar has been corrected. Code using very early dates in the Japanese Calendar may be affected. Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/globalization/11/japanese-calendar-min-date + +### ZipArchive now validates CRC32 when reading entries (Preview 3) + +**Impact: Low–Medium.** ZIP archive reads now validate the CRC32 checksum of each entry. Previously, corrupt or truncated archives were silently accepted; they now throw `InvalidDataException`. + +**Fix:** Ensure ZIP files are not corrupted. If processing partially-written or legacy archives, add error handling for `InvalidDataException`. + +Source: https://github.com/dotnet/runtime/pull/124766 + +### Unhandled BackgroundService exceptions now stop the host (Preview 3) + +**Impact: Medium.** Unhandled exceptions thrown from `BackgroundService.ExecuteAsync()` now propagate and stop the host application. Previously they were silently swallowed. + +```csharp +// .NET 10: exception silently swallowed, host continues +// .NET 11: exception propagates, host stops +protected override async Task ExecuteAsync(CancellationToken stoppingToken) +{ + throw new InvalidOperationException("oops"); // now kills the host +} + +// FIX: Add proper exception handling +protected override async Task ExecuteAsync(CancellationToken stoppingToken) +{ + try + { + // ... work ... + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Background service failed"); + } +} +``` + +**Fix:** Add try/catch in `ExecuteAsync()` for any `BackgroundService` that should not crash the host on failure. + +Source: https://github.com/dotnet/runtime/pull/124863 + +### TarWriter emits HardLink entries for hard-linked files (Preview 3) + +**Impact: Low.** When `TarWriter` archives a directory containing hard links, the same inode encountered more than once is now written as a `HardLink` entry pointing back to the first occurrence, rather than duplicating the file data. + +**Fix:** If consuming tar archives produced by .NET code, ensure the reader handles `HardLink` entry types. + +Source: https://github.com/dotnet/runtime/pull/123874 + +### Zstandard APIs moved from preview package to System.IO.Compression (Preview 3) + +**Impact: Low.** `ZstandardStream` and related APIs that were previously in the `System.IO.Compression.Zstandard` preview NuGet package are now in-box in `System.IO.Compression`. + +**Fix:** Remove the `` preview package if present. The APIs are now available without any additional package reference. + +Source: https://github.com/dotnet/runtime/pull/114545 diff --git a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/cryptography-dotnet10to11.md b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/cryptography-dotnet10to11.md index 9f88243..6ed0516 100644 --- a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/cryptography-dotnet10to11.md +++ b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/cryptography-dotnet10to11.md @@ -26,3 +26,14 @@ var signature = ecdsa.SignData(data, HashAlgorithmName.SHA256); - **Ed25519** — if available in your scenario This change only affects macOS. DSA continues to work on Windows and Linux (though it is generally considered a legacy algorithm). + +### AIA certificate downloads disabled by default during client-certificate validation (Preview 3) + +**Impact: Medium.** AIA (Authority Information Access) certificate downloads are now disabled by default when performing server-side client-certificate chain validation. Previously the runtime would attempt to fetch intermediate CA certificates online. + +**Fix:** If using mTLS where client certificates rely on AIA URLs for intermediate CAs, either: +- Pre-install the full certificate chain on the server +- Have clients send the full chain including intermediates +- Re-enable AIA downloads via `X509ChainPolicy.DisableCertificateDownloads = false` + +Source: https://github.com/dotnet/runtime/pull/125049 diff --git a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/efcore-dotnet10to11.md b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/efcore-dotnet10to11.md index 4c417d2..d2e9cc5 100644 --- a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/efcore-dotnet10to11.md +++ b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/efcore-dotnet10to11.md @@ -2,7 +2,7 @@ These breaking changes affect projects using Entity Framework Core 11. Source: https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/breaking-changes -> **Note:** .NET 11 is in preview. The changes below were introduced in **Preview 1**. Additional EF Core breaking changes are expected in later previews. +> **Note:** .NET 11 is in preview. The changes below were introduced in **Preview 1 through Preview 3**. Additional EF Core breaking changes are expected in later previews. ## Medium-Impact Changes @@ -36,3 +36,69 @@ await context.SaveChangesAsync(); - `Any()` → `await AnyAsync()` Tracking issue: https://github.com/dotnet/efcore/issues/37059 + +### Cosmos: empty owned collections return empty collection instead of null (Preview 1) + +**Impact: Low.** When a Cosmos-backed entity has an owned collection with no items, the property now returns an empty collection rather than `null`. + +**Fix:** Update null checks to empty-collection checks: `if (entity.Items is null)` → `if (entity.Items.Count == 0)`. + +Tracking issue: https://github.com/dotnet/efcore/issues/36577 + +## Preview 3 Changes + +### RelationalEventId.MigrationsNotFound now throws by default (Preview 3) + +**Impact: Low.** Calling `Migrate()` or `MigrateAsync()` when no migrations exist in the assembly now throws an exception rather than silently logging. + +**Fix:** If intentional, suppress with: `options.ConfigureWarnings(w => w.Ignore(RelationalEventId.MigrationsNotFound))`. + +Source: https://github.com/dotnet/efcore/pull/37839 + +### EF Core Tools and Tasks no longer transitively depend on Design (Preview 3) + +**Impact: Low.** The `Microsoft.EntityFrameworkCore.Tools` and `Microsoft.EntityFrameworkCore.Tasks` NuGet packages no longer have a transitive dependency on `Microsoft.EntityFrameworkCore.Design`. + +**Fix:** If your project relied on this transitive reference, add it explicitly: + +```xml + +``` + +Source: https://github.com/dotnet/efcore/pull/37837 + +### EFOptimizeContext MSBuild property removed (Preview 3) + +**Impact: Low.** The `true` MSBuild property no longer exists. Code generation is now controlled by `` and ``. + +**Fix:** Replace `` with the two new properties. With `PublishAOT=true`, generation is automatic during publish. + +Source: https://github.com/dotnet/efcore/pull/37838 + +### SqlVector<T> properties excluded from SELECT by default (Preview 3) + +**Impact: Low.** `SqlVector` properties are now excluded from `SELECT` statements when materializing entities (they return `null`). They can still be used in `WHERE`/`ORDER BY` for vector search. + +**Fix:** Use explicit projections to include vector values: `.Select(b => new { b.Id, b.Embedding })`. + +Source: https://github.com/dotnet/efcore/pull/37829 + +### Microsoft.Data.SqlClient updated to 7.0 (Preview 3) + +**Impact: Medium.** EF Core's SQL Server provider now depends on `Microsoft.Data.SqlClient` 7.0. In v7, Azure/Entra ID authentication dependencies (`Azure.Core`, `Azure.Identity`, `Microsoft.Identity.Client`) have been removed from the core package. + +**Fix:** If using Entra ID authentication (e.g., `ActiveDirectoryDefault`, `ActiveDirectoryManagedIdentity`), add: + +```xml + +``` + +Source: https://github.com/dotnet/efcore/pull/37949 + +### Encryption-enabled SQLite packages removed (Preview 3) + +**Impact: Medium.** `SQLitePCLRaw 3.0` (used by `Microsoft.Data.Sqlite` 11) removed `bundle_e_sqlcipher` and several other bundle packages. + +**Fix:** Switch to SQLite Encryption Extension (SEE), SQLCipher from Zetetic, or `SQLite3MultipleCiphers-NuGet`. + +Source: https://github.com/dotnet/efcore/issues/37059 diff --git a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/runtime-jit-dotnet10to11.md b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/runtime-jit-dotnet10to11.md index a290592..b2ded5f 100644 --- a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/runtime-jit-dotnet10to11.md +++ b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/runtime-jit-dotnet10to11.md @@ -49,3 +49,11 @@ For ReadyToRun-capable assemblies, there may be additional startup overhead on s **Fix:** Verify all deployment targets meet the new minimum requirements. For x86/x64, any CPU from ~2013 or later should be fine. For Windows Arm64, ensure `LSE` support (all Windows 11 compatible Arm64 devices). Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/jit/11/minimum-hardware-requirements + +### NativeAOT native-library outputs use `lib` prefix on Unix (Preview 3) + +**Impact: Low.** NativeAOT shared/native library outputs on Linux and macOS now follow Unix conventions and include the `lib` prefix (e.g., `libMyLib.so` instead of `MyLib.so`). + +**Fix:** Update build scripts, deployment pipelines, or P/Invoke declarations that reference output filenames by the old name without the `lib` prefix. + +Source: https://github.com/dotnet/runtime/pull/124611 diff --git a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/sdk-msbuild-dotnet10to11.md b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/sdk-msbuild-dotnet10to11.md index 16377e7..e256ea6 100644 --- a/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/sdk-msbuild-dotnet10to11.md +++ b/catalog/Platform/Official-DotNet-Upgrade/skills/migrate-dotnet10-to-dotnet11/references/sdk-msbuild-dotnet10to11.md @@ -11,3 +11,19 @@ These changes affect the .NET SDK, CLI tooling, NuGet, and MSBuild behavior. Sou **Impact: Low.** The mono launch target is no longer set automatically for .NET Framework apps. If you require Mono for execution on Linux, you need to specify it explicitly in the configuration. Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/11/mono-launch-target-removed + +### NETSDK1235 warning for PackAsTool with custom .nuspec (Preview 2) + +**Impact: Low.** A new build warning `NETSDK1235` is emitted when a project has both `PackAsTool=true` and a custom `NuspecFile` property, which violates .NET Tool packaging requirements. Projects with `TreatWarningsAsErrors=true` will fail. + +**Fix:** Remove the custom `NuspecFile` property when packaging as a .NET Tool, or suppress the warning if the .nuspec is compatible. + +Source: https://github.com/dotnet/sdk/pull/52810 + +### `dotnet publish --self-contained` now parses the passed value (Preview 3) + +**Impact: Low.** `dotnet publish --self-contained` previously always interpreted the flag as `true` regardless of the passed value. It now correctly parses the value (e.g., `--self-contained false` actually produces a framework-dependent publish). + +**Fix:** Review build scripts that pass `--self-contained` to ensure the intended value is correct. + +Source: https://github.com/dotnet/sdk/pull/52333 diff --git a/catalog/Platform/Official-DotNet/skills/csharp-scripts/SKILL.md b/catalog/Platform/Official-DotNet/skills/csharp-scripts/SKILL.md index dad39fe..8f5c41b 100644 --- a/catalog/Platform/Official-DotNet/skills/csharp-scripts/SKILL.md +++ b/catalog/Platform/Official-DotNet/skills/csharp-scripts/SKILL.md @@ -1,41 +1,44 @@ --- name: csharp-scripts -description: Run single-file C# programs as scripts (file-based apps) for quick experimentation, prototyping, and concept testing. Use when the user wants to write and execute a small C# program without creating a full project. +description: "Run file-based C# apps with the .NET CLI when the user explicitly wants C#/.NET code without creating a project. Use for C# language/API experiments, one-file C# apps, small multi-file C# apps composed with `#:include`/`#:exclude`, or C# file-based apps linked with `#:ref`. Do not use for language-agnostic throwaway scripts, generic computations, Python/PowerShell-style automation, full projects, or existing app integration." license: MIT --- -# C# Scripts +# File-Based C# Apps ## When to Use -- Testing a C# concept, API, or language feature with a quick one-file program +- Testing a C# concept, API, or language feature with a quick file-based app - Prototyping logic before integrating it into a larger project +- Building a small utility from one entry-point file and a few helper `.cs` files ## When Not to Use -- The user needs a full project with multiple files or project references +- The user asks for a language-agnostic quick script, throwaway computation, or shell/Python/PowerShell-style automation +- The user needs a full project, solution integration, or project references in an existing app - The user is working inside an existing .NET solution and wants to add code there -- The program is too large or complex for a single file +- The app is large enough that project structure, build customization, tests, or publish configuration should live in a `.csproj` ## Inputs | Input | Required | Description | |-------|----------|-------------| -| C# code or intent | Yes | The code to run, or a description of what the script should do | +| C# code or intent | Yes | The code to run, or a description of what the file-based app should do | ## Workflow ### Step 1: Check the .NET SDK version -Run `dotnet --version` to verify the SDK is installed and note the major version number. File-based apps require .NET 10 or later. If the version is below 10, follow the [fallback for older SDKs](#fallback-for-net-9-and-earlier) instead. +Run `dotnet --version` to verify the SDK is installed and note the full version, including the feature band. File-based apps require .NET 10 or later. `#:include`, `#:exclude`, and transitive directive processing require SDK 10.0.300 or later; SDK 10.0.100/10.0.200 builds can run single-file apps but do not support those multi-file directives. If the version is below 10, follow the [fallback for older SDKs](#fallback-for-net-9-and-earlier) instead. -### Step 2: Write the script file +### Step 2: Write the app file -Create a single `.cs` file using top-level statements. Place it outside any existing project directory to avoid conflicts with `.csproj` files. +Create an entry-point `.cs` file using top-level statements. Place it outside any existing project directory to avoid conflicts with `.csproj` files. ```csharp +#!/usr/bin/env dotnet // hello.cs -Console.WriteLine("Hello from a C# script!"); +Console.WriteLine("Hello from a file-based app!"); var numbers = new[] { 1, 2, 3, 4, 5 }; Console.WriteLine($"Sum: {numbers.Sum()}"); @@ -47,7 +50,7 @@ Guidelines: - Place `using` directives at the top of the file (after the `#!` line and any `#:` directives if present) - Place type declarations (classes, records, enums) after all top-level statements -### Step 3: Run the script +### Step 3: Run the app ```bash dotnet hello.cs @@ -65,7 +68,7 @@ Place directives at the top of the file (immediately after an optional shebang l #### `#:package` — NuGet package references -Always specify a version: +Specify a version unless the app intentionally uses central package management. Use `@*` when the latest available package is acceptable (or `@*-*` for pre-release): ```csharp #:package Humanizer@2.14.1 @@ -109,6 +112,26 @@ Reference another project by relative path: #:project ../MyLibrary/MyLibrary.csproj ``` +#### `#:ref` — File-based app references + +Reference another `.cs` file as a separate file-based app project when it should compile into a separate assembly instead of being included in the same compilation. Use `#:include` for ordinary helper files that should share the same assembly as the entry point; use `#:ref` when you want project-reference-like boundaries. + +```csharp +#:property ExperimentalFileBasedProgramEnableRefDirective=true +#:ref ../Shared/Formatter.cs + +Console.WriteLine(Formatter.Title("hello world")); +``` + +Guidelines: + +- The referenced file is compiled as its own virtual project and added as a project reference. +- If the referenced file is a library without top-level statements, put `#:property OutputType=Library` in that referenced file. +- Members that must be consumed by the referencing app should be public; internal members are not visible across the assembly boundary. +- `#:ref` is transitive: a referenced file can contain its own `#:ref` and other `#:` directives. +- Relative paths are resolved relative to the file containing the directive. +- Some SDK builds require `#:property ExperimentalFileBasedProgramEnableRefDirective=true`; remove that property if the SDK accepts `#:ref` without it. + #### `#:sdk` — SDK selection Override the default SDK (`Microsoft.NET.Sdk`): @@ -117,9 +140,65 @@ Override the default SDK (`Microsoft.NET.Sdk`): #:sdk Microsoft.NET.Sdk.Web ``` +#### `#:include` and `#:exclude` — Multi-file apps + +In .NET SDK 10.0.300 and later, file-based apps can include additional files in the same virtual project. Check the full `dotnet --version` output before using these directives; a 10.0.100 or 10.0.200 SDK is still .NET 10 but does not support them. Use `#:include` for helper source files and supported assets, and `#:exclude` to remove files from an include pattern or default item set. + +```csharp +#!/usr/bin/env dotnet +#:include Helpers.cs +#:include Models/*.cs +#:exclude Models/Generated/*.cs + +Console.WriteLine(Formatter.Title("hello world")); +``` + +Guidelines: + +- Treat the file passed to `dotnet` as the entry point; put top-level statements there. +- Put declarations such as classes, records, and enums in included `.cs` files. +- Prefer explicit globs such as `Helpers.cs` or `Models/*.cs` over broad recursive globs. +- Paths are resolved relative to the file containing the directive. +- Include directives from non-entry-point C# files are processed too, so a helper file can declare its own `#:package`, `#:property`, `#:sdk`, `#:project`, `#:ref`, `#:include`, or `#:exclude` directives. +- Avoid duplicate directives across included files unless the directive kind explicitly supports duplicates; duplicate `#:package`, `#:property`, `#:sdk`, `#:include`, and `#:exclude` entries can fail. +- When an app uses `#:include`, add a shebang (`#!/usr/bin/env dotnet`) to the entry-point file on Unix-like systems to make the entry point clear to tools. Use `LF` line endings and no BOM for shebang files. + +Example layout: + +```text +scratch/ + hello.cs + Helpers.cs + Models/ + Person.cs +``` + +```csharp +#!/usr/bin/env dotnet +// hello.cs +#:include Helpers.cs +#:include Models/*.cs + +var person = new Person("Ada"); +Console.WriteLine(Formatter.Title(person.Name)); +``` + +```csharp +// Helpers.cs +static class Formatter +{ + public static string Title(string value) => value.ToUpperInvariant(); +} +``` + +```csharp +// Models/Person.cs +record Person(string Name); +``` + ### Step 5: Clean up -Remove the script file when the user is done. To clear cached build artifacts: +Remove the app files when the user is done. To clear cached build artifacts: ```bash dotnet clean hello.cs @@ -173,7 +252,7 @@ partial class AppJsonContext : JsonSerializerContext; ## Converting to a project -When a script outgrows a single file, convert it to a full project: +When a file-based app outgrows this workflow, convert it to a full project: ```bash dotnet project convert hello.cs @@ -184,29 +263,35 @@ dotnet project convert hello.cs If the .NET SDK version is below 10, file-based apps are not available. Use a temporary console project instead: ```bash -mkdir -p /tmp/csharp-script && cd /tmp/csharp-script +mkdir -p /tmp/csharp-file-based-app && cd /tmp/csharp-file-based-app dotnet new console -o . --force ``` -Replace the generated `Program.cs` with the script content and run with `dotnet run`. Add NuGet packages with `dotnet add package `. Remove the directory when done. +Replace the generated `Program.cs` with the app content and run with `dotnet run`. Add NuGet packages with `dotnet add package `. Remove the directory when done. ## Validation - [ ] `dotnet --version` reports 10.0 or later (or fallback path is used) -- [ ] The script compiles without errors (can be checked explicitly with `dotnet build .cs`) +- [ ] If the app uses `#:include`, `#:exclude`, or transitive directives from included files, `dotnet --version` reports SDK 10.0.300 or later +- [ ] The app compiles without errors (can be checked explicitly with `dotnet build .cs`) - [ ] `dotnet .cs` produces the expected output -- [ ] Script file and cached artifacts are cleaned up after the session +- [ ] Multi-file apps include every required helper file and exclude unintended matches +- [ ] App files and cached artifacts are cleaned up after the session ## Common Pitfalls | Pitfall | Solution | |---------|----------| -| `.cs` file is inside a directory with a `.csproj` | Move the script outside the project directory, or use `dotnet run --file file.cs` | +| `.cs` file is inside a directory with a `.csproj` | Move the app outside the project directory, or use `dotnet run --file file.cs` | | `#:package` without a version | Specify a version: `#:package PackageName@1.2.3` or `@*` for latest | | `#:property` with wrong syntax | Use `PropertyName=Value` with no spaces around `=` and no quotes: `#:property AllowUnsafeBlocks=true` | | Directives placed after C# code | All `#:` directives must appear immediately after an optional shebang line (if present) and before any `using` directives or other C# statements | +| Helper file is not compiled | Add `#:include Helper.cs` or an appropriate glob to the entry-point file | +| Shared file needs an assembly boundary | Use `#:ref Shared.cs` instead of `#:include Shared.cs`, and set `#:property OutputType=Library` in the referenced file if it has no entry point | +| Broad include pulls in unrelated files | Prefer narrow include patterns and use `#:exclude` for generated, backup, or experimental files | +| Duplicate directives in included files | Keep package, property, SDK, include, and exclude directives unique across the entry point and included C# files | | Reflection-based JSON serialization fails | Use source-generated JSON with `JsonSerializerContext` (see [Source-generated JSON](#source-generated-json)) | -| Unexpected build behavior or version errors | File-based apps inherit `global.json`, `Directory.Build.props`, `Directory.Build.targets`, and `nuget.config` from parent directories. Move the script to an isolated directory if the inherited settings conflict | +| Unexpected build behavior or version errors | File-based apps inherit `global.json`, `Directory.Build.props`, `Directory.Build.targets`, and `nuget.config` from parent directories. Move the app to an isolated directory if the inherited settings conflict | ## More info diff --git a/catalog/Testing/Official-DotNet-Test/agents/code-testing-fixer/AGENT.md b/catalog/Testing/Official-DotNet-Test/agents/code-testing-fixer/AGENT.md index 192c7ad..e0d8264 100644 --- a/catalog/Testing/Official-DotNet-Test/agents/code-testing-fixer/AGENT.md +++ b/catalog/Testing/Official-DotNet-Test/agents/code-testing-fixer/AGENT.md @@ -1,9 +1,10 @@ --- description: >- - Fixes compilation errors in source or test files. + Fixes compilation errors and failing tests in source or test files. Use when: resolving build errors, fixing CS/TS error codes, adding missing - imports, correcting type mismatches, fixing compilation failures. + imports, correcting type mismatches, fixing compilation failures, OR + correcting failing test assertions against production source. name: code-testing-fixer user-invocable: false license: MIT @@ -11,19 +12,22 @@ license: MIT # Fixer Agent -You fix compilation errors in code files. You are polyglot — you work with any programming language. +You fix compilation errors **and failing tests** in code files. You are polyglot — you work with any programming language. > **Language-specific guidance**: Call the `code-testing-extensions` skill to discover available extension files, then read the relevant file for the target language (e.g., `dotnet.md` for .NET). ## Your Mission -Given error messages and file paths, analyze and fix the compilation errors. +Given error messages or test failures and file paths, analyze and fix the issue. Two failure modes are in scope: + +1. **Compilation errors** — read the failing file around the error location and apply the smallest correct fix (missing `using`/`import`, wrong type, missing parameter, etc.). +2. **Failing test assertions** — when a freshly generated test fails because its expected value does not match production behavior, read the production source the test is exercising and correct the test's expected value to match. Never `[Ignore]` / `[Skip]` / delete a test to make it pass; never modify production code to match a wrong test. ## Process ### 1. Parse Error Information -Extract from the error message: file path, line number, error code, error message. +Extract from the error message: file path, line number, error code (for compilation), or test name and assertion difference (for test failures). ### 2. Read the File diff --git a/catalog/Testing/Official-DotNet-Test/agents/code-testing-generator/AGENT.md b/catalog/Testing/Official-DotNet-Test/agents/code-testing-generator/AGENT.md index ab4722a..d19b4e9 100644 --- a/catalog/Testing/Official-DotNet-Test/agents/code-testing-generator/AGENT.md +++ b/catalog/Testing/Official-DotNet-Test/agents/code-testing-generator/AGENT.md @@ -14,6 +14,92 @@ You coordinate test generation using the Research-Plan-Implement (RPI) pipeline. > **Language-specific guidance**: Call the `code-testing-extensions` skill to discover available extension files, then read the relevant file for the target language (e.g., `dotnet.md` for .NET). +## Dispatch Discipline (read first — applies to every dispatch) + +### Rule 1: Every `task` call MUST have `agent_type: "dotnet-test:code-testing-…"` + +```text +✅ task({ agent_type: "dotnet-test:code-testing-researcher", name: "researcher", prompt: "..." }) +❌ task({ name: "explore-tests", prompt: "..." }) // generic, no agent_type +❌ task({ agent_type: "explore", prompt: "..." }) // generic built-in +❌ task({ agent_type: "general-purpose", prompt: "..." }) // generic built-in +``` + +A `task` call without the `dotnet-test:code-testing-…` prefix dispatches a generic built-in agent (`task`, `explore`, or `general-purpose`) that does **not** load the CTA prompt or the language extension. Generic dispatches are forbidden in this pipeline. + +If a sub-task is too small to warrant a CTA sub-agent, **do it yourself** with `read` / `search` (subject to Rules 4 and 5 below). Do not dispatch a generic helper. + +### Rule 2: Specific routing — when to dispatch which named agent + +| You need to… | Dispatch this named agent (NOT a generic helper) | +|---|---| +| Initial scoping research (every run, in Step 1b) | `dotnet-test:code-testing-researcher` | +| Diagnose an unfamiliar test failure | `dotnet-test:code-testing-researcher` (additional dispatch with narrow scope) | +| Read codebase structure / find test framework / discover existing tests | `dotnet-test:code-testing-researcher` | +| Translate research into a per-phase plan (every run, in Step 4) | `dotnet-test:code-testing-planner` | +| Write tests for one phase / file / function | `dotnet-test:code-testing-implementer` | +| Run a workspace build and report errors | `dotnet-test:code-testing-builder` | +| Run a test suite and parse failures | `dotnet-test:code-testing-tester` | +| Fix any test failure (mandatory — never fix tests inline yourself, dispatch the fixer) | `dotnet-test:code-testing-fixer` | +| Lint / format generated code (mandatory after every implementer dispatch finishes if a lint command exists) | `dotnet-test:code-testing-linter` | + +If the work matches one of these rows, dispatch the named CTA agent. Do not call generic `explore` / `general-purpose` / `task` for these jobs. + +### Rule 3: Prefer one named-agent dispatch over many tool calls + +Dispatching `code-testing-tester` once with a rich prompt is preferable to running 5+ `terminal` test commands yourself. Dispatching `code-testing-researcher` once is preferable to chaining 10+ `read` / `search` / `glob` calls. The CTA agents are tuned for these jobs. + +### Rule 4: You MUST NOT write or modify test files yourself + +The `edit` tool is available to you, but you are forbidden from using it to create or modify any source or test file. Every test-file write goes through `code-testing-implementer`. Every fix to a failing test goes through `code-testing-fixer`. This applies to ALL strategies including Direct. + +```text +✅ task({ agent_type: "dotnet-test:code-testing-implementer", name: "implementer", prompt: "Write tests for ..." }) +❌ edit("tests/test_foo.py", "...") // direct edit of a test file — forbidden +❌ terminal("cat > tests/test_foo.py <0 failures → dispatch fixer → re-dispatch tester (mandatory) +❌ tester reports 5 failures → orchestrator writes summary and returns // forbidden — silent acceptance +❌ orchestrator decides failures look "minor" and skips fixer // forbidden +``` + +You may stop the fixer loop early only if the **same test name fails identically across two consecutive fixer attempts** (genuine non-flaky deadlock — log it in the final report). + ## Pipeline Overview 1. **Research** — Understand the codebase structure, testing patterns, and what needs testing @@ -28,15 +114,34 @@ Understand what the user wants: scope (project, files, classes), priority areas, **Read the language-specific extension** for the target codebase by calling the `code-testing-extensions` skill (e.g., read `dotnet.md` for .NET/C# projects). This contains critical build commands, project registration steps, and error-handling guidance that apply to ALL strategies including Direct. You MUST read this file before writing any code. +### Step 1b: Mandatory initial researcher dispatch (every strategy, no exceptions) + +Before any other CTA dispatch, dispatch the researcher once to populate `.testagent/research.md`: + +```text +task({ + agent_type: "dotnet-test:code-testing-researcher", + name: "researcher", + prompt: "Initial scoping research for test generation. Identify project structure, existing tests, source files to test, testing framework, build/test commands. Then explicitly answer two questions in `.testagent/research.md`: (1) Which unit (function/class/method) is under test, with a `file:line` citation. (2) Which behaviors need exercising — positive paths, negative/error paths, and edge cases relevant to the request. Write findings to .testagent/research.md." +}) +``` + +After the researcher returns, **verify `.testagent/research.md` answers two questions explicitly**: + +1. *Which unit (function/class/method) is under test*, with a file:line citation. +2. *Which behaviors need exercising* (positive paths, negative/error paths, edge cases relevant to the request). + +If either is missing or vague, dispatch the researcher one more time with narrow scope to fill the gap. If both are present, proceed to Step 2 — do not dispatch the researcher again unless `.testagent/research.md` itself is later proven wrong (e.g., implementer cannot find the unit). + ### Step 2: Choose Execution Strategy Based on the request scope, pick exactly one strategy and follow it: | Strategy | When to use | What to do | | ---------- | ------------- | ------------ | -| **Direct** | A small, self-contained request (e.g., tests for a single function or class) that you can complete without sub-agents | Write the tests immediately. **Run them right away** — if any test fails, read the production code, fix the assertion, and re-run before writing more tests. Skip Steps 3-5 (research, plan, implement sub-agents). Then proceed to Steps 6-9 for validation and reporting. | -| **Single pass** | A moderate scope (couple projects or modules) that a single Research → Plan → Implement cycle can cover | Execute Steps 3-8 once, then proceed to Step 9. | -| **Iterative** | A large scope or ambitious coverage target that one pass cannot satisfy | Execute Steps 3-8, then re-evaluate coverage. If the target is not met, repeat Steps 3-8 with a narrowed focus on remaining gaps. Use unique names for each iteration's `.testagent/` documents (e.g., `research-2.md`, `plan-2.md`) so earlier results are not overwritten. Continue until the target is met or all reasonable targets are exhausted, then proceed to Step 9. | +| **Direct** | A small, self-contained request (e.g., tests for a single function or class) that you can complete without the full pipeline | "Direct" means **one phase**, NOT "do it inline" — Rules 4, 5, and 6 still apply: NO `edit` for test files, NO `terminal` for build/test, NO skipping the planner. Dispatch the named CTA pipeline with **narrow scope**: (1) dispatch `code-testing-planner` once with `[scope=single-phase]` hint to produce a one-phase plan. (2) dispatch `code-testing-implementer` once, scoped to just the requested function/class. (3) dispatch `code-testing-builder` to compile. (4) dispatch `code-testing-tester` to run. (5) **MANDATORY**: if any failure surfaced, dispatch `code-testing-fixer`; then re-dispatch `code-testing-tester`. (6) **MANDATORY** at end: dispatch `code-testing-linter` to format and lint generated test files (if a lint command exists). Then proceed to Steps 6-10 for validation, cleanup, and reporting (which also dispatch builder/tester/fixer). Step 3 (deep Research Phase) is skipped for Direct — Step 1b already produced sufficient `.testagent/research.md`. | +| **Single pass** | A moderate scope (couple projects or modules) that a single Research → Plan → Implement cycle can cover | Execute Steps 3-8 once, then proceed to Steps 9-10. | +| **Iterative** | A large scope or ambitious coverage target that one pass cannot satisfy | Execute Steps 3-8, then re-evaluate coverage. If the target is not met, repeat Steps 3-8 with a narrowed focus on remaining gaps. Use unique names for each iteration's `.testagent/` documents (e.g., `research-2.md`, `plan-2.md`) so earlier results are not overwritten. Continue until the target is met or all reasonable targets are exhausted, then proceed to Steps 9-10. | **Default to Direct** unless the request explicitly mentions multiple files, modules, or an entire project. Most test generation requests — including "generate tests for function X", "add tests covering these scenarios", and "write unit tests for this class" — should use Direct strategy. The full Research → Plan → Implement pipeline is only needed when the scope spans multiple unrelated source files. @@ -51,16 +156,17 @@ Based on the request scope, pick exactly one strategy and follow it: | "Generate comprehensive tests for my ASP.NET app" | Single pass | If the app has fewer than 10 controllers/services/files in scope, one R→P→I cycle should cover it | | "Generate comprehensive tests for my large ASP.NET app" | Iterative | If the app has 10 or more controllers/services/files in scope, use repeated passes to close remaining gaps | -**All strategies MUST execute Steps 6-9** (final build validation, final test validation, coverage gap iteration, and reporting). These steps are never skipped. +**All strategies MUST execute Steps 6-10** (final build validation, final test validation, coverage gap iteration, diff validation/cleanup, and reporting). These steps are never skipped. -### Step 3: Research Phase +### Step 3: Deep Research Phase (Single pass and Iterative only — skipped for Direct) -Call the `code-testing-researcher` subagent: +Step 1b already produced `.testagent/research.md` with the unit-under-test contract and behaviors. For broader scopes, dispatch the researcher again to **extend** that file with cross-file analysis. Do not overwrite the Step 1b findings; append or update in place. ```text -runSubagent({ - agent: "code-testing-researcher", - prompt: "Research the codebase at [PATH] for test generation. Identify: project structure, existing tests, source files to test, testing framework, build/test commands. Build a dependency graph and estimate preexisting coverage." +task({ + agent_type: "dotnet-test:code-testing-researcher", + name: "researcher-deep", + prompt: "Extend .testagent/research.md (already populated in Step 1b with unit-under-test contract and behaviors). Add: (1) dependency graph for in-scope files, (2) preexisting test coverage estimate, (3) any cross-project build/test details not already captured. Preserve the unit-under-test and behaviors sections from Step 1b — append to research.md rather than rewriting it." }) ``` @@ -68,12 +174,13 @@ Output: `.testagent/research.md` ### Step 4: Planning Phase -Call the `code-testing-planner` subagent: +**Mandatory for every strategy** (Rule 6). Even for Direct (single-function) scope, the planner runs and produces a one-phase plan. ```text -runSubagent({ - agent: "code-testing-planner", - prompt: "Create a test implementation plan based on .testagent/research.md. Create phased approach with specific files and test cases." +task({ + agent_type: "dotnet-test:code-testing-planner", + name: "planner", + prompt: "Create a phased test implementation plan based on .testagent/research.md. Create phased approach with specific files and test cases. Write the plan to .testagent/plan.md." }) ``` @@ -81,37 +188,64 @@ Output: `.testagent/plan.md` ### Step 5: Implementation Phase -Execute each phase by calling the `code-testing-implementer` subagent — once per phase, sequentially: +Execute each phase by dispatching the implementer once, sequentially: ```text -runSubagent({ - agent: "code-testing-implementer", - prompt: "Implement Phase N from .testagent/plan.md: [phase description]. Ensure tests compile and pass." +task({ + agent_type: "dotnet-test:code-testing-implementer", + name: "implementer", + prompt: "Implement Phase N from .testagent/plan.md: [phase description]. Apply the language-specific guidance from the relevant code-testing-extensions file. Ensure tests compile and pass." }) ``` +Wait for each implementer dispatch to return before dispatching the next phase. Do not parallelize phases — implementers may modify the same project files. + ### Step 6: Final Build Validation Run a **full workspace build** (not just individual test projects). This catches cross-project errors invisible in scoped builds — including multi-target framework issues. -- **.NET**: `dotnet build MySolution.sln --no-incremental` (no `--framework` flag — must build ALL target frameworks) -- **TypeScript**: `npx tsc --noEmit` from workspace root -- **Go**: `go build ./...` from module root -- **Rust**: `cargo build` +Always dispatch the builder (Rule 5 — never run the build inline via `terminal`). This applies to ALL strategies including Direct: + +```text +task({ + agent_type: "dotnet-test:code-testing-builder", + name: "builder", + prompt: "Run a full, non-incremental workspace build. .NET: 'dotnet build --no-incremental' from the repo root with NO --framework flag (must build all target frameworks). If the repo contains a .sln/.slnx, use 'dotnet build .sln --no-incremental'. TypeScript: 'npx tsc --noEmit' from workspace root. Go: 'go build ./...' from module root. Rust: 'cargo build'. Report any errors." +}) +``` + +If it fails, **Rule 7 applies — you MUST dispatch the fixer; do not skip and do not declare success with build errors.** Rebuild after the fixer returns; retry up to 3 times. -If it fails, call the `code-testing-fixer`, rebuild, retry up to 3 times. +```text +task({ + agent_type: "dotnet-test:code-testing-fixer", + name: "fixer", + prompt: "Fix the following build failures: [paste failures]. Read production code and correct the expected values; never use [Ignore]/[Skip]. Do not delete or overwrite pre-existing tests." +}) +``` ### Step 7: Final Test Validation -Run tests from the **full workspace scope** with a fresh build (never use `--no-build` for final validation). If tests fail: +Run tests from the **full workspace scope** with a fresh build (never use `--no-build` for final validation). -- **Wrong assertions** — read production code, fix the expected value. Never `[Ignore]` or `[Skip]` a test just to pass. -- **Environment-dependent** — remove tests that call external URLs, bind ports, or depend on timing. Prefer mocked unit tests. -- **Pre-existing failures** — note them but don't block. +Always dispatch the tester (Rule 5 — never run tests inline via `terminal`). This applies to ALL strategies including Direct: -**Verify tests are implementation-specific:** +```text +task({ + agent_type: "dotnet-test:code-testing-tester", + name: "tester", + prompt: "Run the full workspace test suite from a fresh build (do not use --no-build). Report failures with reasons and stack traces." +}) +``` -- Each test should assert on **concrete values** returned by the function — not just type checks, non-null checks, or other assertions that would still pass if the function body were empty or returned a default value. If a test wouldn't catch the deletion of the function's core logic, rewrite it with specific value assertions. +If tests fail: + +- **Rule 7 applies — you MUST dispatch the fixer; do not silently accept failed tests as 'good enough'.** Even one failed test triggers a fixer dispatch. Re-run the tester after each fixer return. Repeat up to 3 cycles. +- **Wrong assertions** — the fixer will read production code and correct the expected value. Never `[Ignore]` or `[Skip]` a test just to pass. +- **Environment-dependent** — the fixer can remove tests that call external URLs, bind ports, or depend on timing. Prefer mocked unit tests. +- **Pre-existing failures** — note them in the final report but they still must go through fixer (so the fixer can confirm they are pre-existing, not regressions caused by this run). + +You may stop the fixer→tester loop early ONLY if the same test name fails identically across two consecutive fixer attempts (genuine deadlock — log it in the final report). ### Step 8: Coverage Gap Iteration @@ -120,10 +254,32 @@ After the previous phases complete, check for uncovered source files: 1. List all source files in scope. 2. List all test files created. 3. Identify source files with no corresponding test file. -4. Generate tests for each uncovered file, build, test, and fix. -5. Repeat until every non-trivial source file has tests or all reasonable targets are exhausted. +4. If gaps remain, dispatch a focused researcher → planner → implementer cycle: + +```text +task({ + agent_type: "dotnet-test:code-testing-researcher", + name: "researcher-gap", + prompt: "Re-research scoped to: [specific uncovered files/functions]. Write findings to .testagent/research-2.md." +}) +``` + +Then re-run planner (writing `.testagent/plan-2.md`) and implementer for the gap phase, followed by builder/tester/fixer cycles. Do this at most once per run; if the second iteration also leaves gaps, list them in the final report rather than looping further. + +### Step 9: Validate Diff and Clean Up + +Before reporting, verify the patch contains only legitimate test changes and remove pipeline scratch state. These are file/git operations that the orchestrator performs directly — do not dispatch a CTA agent for cleanup (the build/test agents have narrower missions and cleanup is not in their charter; Rule 5 forbids inline `terminal` for build/test only, not for git or filesystem hygiene). + +Perform these steps in order: + +1. Remove the `.testagent/` directory if it exists. +2. Run `git status --porcelain` and `git diff --name-only HEAD` to list every file the pipeline touched. +3. For any modified file outside test directories that was not part of the original task, revert it. +4. Do NOT commit; the harness captures the working tree. + +If a modified non-test file was a deliberate part of the task (e.g., adding `[InternalsVisibleTo]` for test access), keep it and note it in the Step 10 report. -### Step 9: Report Results +### Step 10: Report Results Summarize tests created, report any failures or issues, suggest next steps if needed. @@ -168,15 +324,16 @@ All state is stored in `.testagent/` folder: ## Rules -1. **Sequential phases** — complete one phase before starting the next -2. **Polyglot** — detect the language and use appropriate patterns -3. **Verify** — each phase must produce compiling, passing tests -4. **Don't skip** — report failures rather than skipping phases -5. **Clean git first** — stash pre-existing changes before starting -6. **Scoped builds during phases, full build at the end** — build specific test projects during implementation for speed; run a full-workspace non-incremental build after all phases to catch cross-project errors -7. **No environment-dependent tests** — mock all external dependencies; never call external URLs, bind ports, or depend on timing -8. **Fix assertions, don't skip tests** — when tests fail, read production code and fix the expected value; never `[Ignore]` or `[Skip]` -9. **Clean up `.testagent/`** — after pipeline completion, delete the `.testagent/` folder or advise the user to add it to `.gitignore` so ephemeral state is not committed -10. **Read language extensions first** — always call the `code-testing-extensions` skill and read the relevant extension file before writing any code; it contains critical project registration and build validation steps -11. **Always validate** — final build, final test, coverage-gap review, and reporting are mandatory for ALL strategies including Direct; never skip final validation -12. **Preserve existing tests** — never delete or overwrite existing test files; create new files or append to existing ones +1. **Every `task` dispatch MUST use `agent_type: "dotnet-test:code-testing-…"`** — bare `task({...})` calls and calls with `agent_type: "explore"`, `agent_type: "general-purpose"`, or `agent_type: "task"` dispatch generic built-in agents that do NOT load the CTA prompt, skills, or language extension. Generic dispatches are forbidden in this pipeline. +2. **Sequential phases** — complete one phase before starting the next. +3. **Polyglot** — detect the language and use appropriate patterns; load `code-testing-extensions` first. +4. **Verify** — each phase must produce compiling, passing tests. +5. **Don't skip** — report failures rather than skipping phases. +6. **Clean git first** — stash pre-existing changes before starting. +7. **Scoped builds during phases, full build at the end** — build specific test projects during implementation for speed; run a full-workspace non-incremental build after all phases to catch cross-project errors. +8. **No environment-dependent tests** — mock all external dependencies; never call external URLs, bind ports, or depend on timing. +9. **Fix assertions, don't skip tests** — when tests fail, dispatch the fixer; never `[Ignore]` or `[Skip]`. +10. **Step 9 validate + cleanup is mandatory** — for ALL strategies including Direct. Skipping it leaves leftover `.testagent/` files in the patch. +11. **Read language extensions first** — always call the `code-testing-extensions` skill and read the relevant extension file before writing any code. +12. **Always validate** — final build, final test, coverage-gap review, and reporting are mandatory for ALL strategies including Direct; never skip final validation. +13. **Preserve existing tests** — never delete or overwrite existing test files; create new files or append to existing ones. diff --git a/catalog/Testing/Official-DotNet-Test/agents/code-testing-implementer/AGENT.md b/catalog/Testing/Official-DotNet-Test/agents/code-testing-implementer/AGENT.md index 26e0416..2a50585 100644 --- a/catalog/Testing/Official-DotNet-Test/agents/code-testing-implementer/AGENT.md +++ b/catalog/Testing/Official-DotNet-Test/agents/code-testing-implementer/AGENT.md @@ -55,7 +55,15 @@ For each test file in your phase: Call the `code-testing-builder` sub-agent to compile. Build only the specific test project, not the full solution. -If build fails: call `code-testing-fixer`, rebuild, retry up to 3 times. +If build fails: **you MUST dispatch `code-testing-fixer`** — do not edit/create test files inline to make the build pass. Rebuild after the fixer returns. Retry up to 3 times. + +```text +✅ builder fails → code-testing-fixer → builder retry (correct) +❌ builder fails → edit("tests/test_foo.py", ...) → builder retry (forbidden — band-aid) +❌ builder fails → create("tests/test_bar.py", ...) → builder retry (forbidden — band-aid) +``` + +The reason: when the implementer "patches" a test file inline to make the build pass, it tends to remove problematic assertions, comment out failing branches, or weaken types — none of which the fixer would do. Inline-fix is the classic band-aid anti-pattern: the build goes green, but the test no longer exercises what was specified. ### 6. Verify with Tests @@ -63,19 +71,24 @@ Call the `code-testing-tester` sub-agent to run tests. If tests fail: -- Read the actual test output — note expected vs actual values -- Read the production code to understand correct behavior -- Update the assertion to match actual behavior. Common mistakes: - - Hardcoded IDs that don't match derived values - - Asserting counts in async scenarios without waiting for delivery - - Assuming constructor defaults that differ from implementation -- For async/event-driven tests: add explicit waits before asserting -- Never mark a test `[Ignore]`, `[Skip]`, or `[Inconclusive]` -- Retry the fix-test cycle up to 5 times +- **You MUST dispatch the fixer.** Even one failed test triggers a fixer dispatch — never declare `STATUS: SUCCESS` with failing tests, and never silently accept failures as "minor". +- **You MUST NOT use `edit` or `create` on test files between a failed tester dispatch and the next fixer dispatch.** The fixer is the only sub-agent allowed to modify a failing test file: + +```text +✅ tester reports failure → code-testing-fixer → tester retry (correct) +❌ tester reports failure → edit("tests/test_foo.py", ...) → tester retry (forbidden — band-aid) +❌ tester reports failure → mark test [Skip] / pytest.skip / t.Skip(...) (forbidden — silent acceptance) +❌ tester reports failure → delete the failing test method (forbidden — silent acceptance) +``` + +- Pass the actual test output (expected vs actual values) to the fixer in the dispatch prompt +- Cite the relevant `:` of the production code in the fixer dispatch prompt +- Never mark a test `[Ignore]`, `[Skip]`, `[Inconclusive]`, `pytest.skip`, `t.Skip`, `it.skip`, or any language-equivalent skip mechanism — neither the implementer nor the fixer may do this +- Retry the fix-test cycle up to 5 times. You may stop early ONLY if the same test name fails identically across two consecutive fixer attempts (genuine deadlock — log it in the report). -### 7. Format Code (Optional) +### 7. Format Code (mandatory if a lint command exists) -If a lint command is available, call the `code-testing-linter` sub-agent. +If the project has a lint or format command, call the `code-testing-linter` sub-agent. Skip only if no lint command exists in the project. ### 8. Report Results @@ -99,3 +112,5 @@ ISSUES: 3. **Match patterns** — follow existing test style 4. **Be thorough** — cover edge cases 5. **Report clearly** — state what was done and any issues +6. **Never declare SUCCESS while build or tests fail** — any build error or failed test triggers a fixer dispatch. The implementer never silently accepts failures as "minor" or "good enough" — dispatch the fixer, re-run, and only declare SUCCESS when build is clean and all tests pass (or document a genuine deadlock after 2+ identical fixer attempts). +7. **No inline test-file edits between a failed dispatch and the fixer** — once `code-testing-builder` returns an error or `code-testing-tester` returns a failure, the next dispatch on a test file MUST be `code-testing-fixer`. The implementer MUST NOT use `edit`/`create` on test source files between the failed dispatch and the fixer dispatch, MUST NOT add `Skip`/`Ignore`/`Inconclusive` markers, and MUST NOT delete the failing test. The fixer is the only sub-agent allowed to mutate a test file in this state. diff --git a/catalog/Testing/Official-DotNet-Test/agents/test-quality-auditor/AGENT.md b/catalog/Testing/Official-DotNet-Test/agents/test-quality-auditor/AGENT.md index 0913620..338269f 100644 --- a/catalog/Testing/Official-DotNet-Test/agents/test-quality-auditor/AGENT.md +++ b/catalog/Testing/Official-DotNet-Test/agents/test-quality-auditor/AGENT.md @@ -60,13 +60,13 @@ Classify the user's request and route to the appropriate skill: | User Intent | Route To | Plugin | |---|---|---| -| "Are my assertions good enough?" / shallow testing / assertion diversity | `exp-assertion-quality` skill | dotnet-experimental | -| "Find test smells" / comprehensive formal audit | `exp-test-smell-detection` skill | dotnet-experimental | +| "Are my assertions good enough?" / shallow testing / assertion diversity | `assertion-quality` skill | dotnet-test | +| "Find test smells" / comprehensive formal audit | `test-smell-detection` skill | dotnet-test | | "Pragmatic anti-pattern check" within a broader audit context | `test-anti-patterns` skill | dotnet-test | | "Find test duplication" / boilerplate / DRY up tests | `exp-test-maintainability` skill | dotnet-experimental | | "Are my mocks needed?" / over-mocking / mock audit | `exp-mock-usage-analysis` skill | dotnet-experimental | -| "Would my tests catch bugs?" / mutation analysis / test gaps | `exp-test-gap-analysis` skill | dotnet-experimental | -| "Categorize my tests" / tag tests / trait distribution | `exp-test-tagging` skill | dotnet-experimental | +| "Would my tests catch bugs?" / mutation analysis / test gaps | `test-gap-analysis` skill | dotnet-test | +| "Categorize my tests" / tag tests / trait distribution | `test-tagging` skill | dotnet-test | | "Coverage report" / risk hotspots / CRAP score | `coverage-analysis` skill (use `crap-score` only for explicitly targeted method/class CRAP analysis or narrow-scope Cobertura data) | dotnet-test | | "Find untestable code" / static dependencies | `detect-static-dependencies` skill → hand off to `testability-migration` agent for fixes | dotnet-test | | "Full health check" / "audit my tests" / broad quality request | Run the **Comprehensive Audit Pipeline** below | multiple | @@ -83,11 +83,11 @@ Run these in order. Each step builds context for the next. Stop early if the use - Quick pragmatic scan for the most impactful issues - Produces severity-ranked findings (Critical → Low) -2. **Assertion quality** — `exp-assertion-quality` skill +2. **Assertion quality** — `assertion-quality` skill - Measures assertion variety and depth - Reveals whether tests actually verify meaningful behavior -3. **Test gaps** — `exp-test-gap-analysis` skill +3. **Test gaps** — `test-gap-analysis` skill - Pseudo-mutation analysis to find blind spots - Answers "would tests catch a bug here?" @@ -97,10 +97,10 @@ Run these in order. Each step builds context for the next. Stop early if the use ### Optional follow-ups (offer but don't run automatically) -5. **Test smells** — `exp-test-smell-detection` skill (if step 1 found many issues and the user wants a deeper formal audit) +5. **Test smells** — `test-smell-detection` skill (if step 1 found many issues and the user wants a deeper formal audit) 6. **Maintainability** — `exp-test-maintainability` skill (if the test suite is large and duplication is suspected) 7. **Mock audit** — `exp-mock-usage-analysis` skill (if over-mocking was flagged in step 1) -8. **Test tagging** — `exp-test-tagging` skill (if the user wants to understand test type distribution) +8. **Test tagging** — `test-tagging` skill (if the user wants to understand test type distribution) ### Synthesizing results @@ -152,4 +152,4 @@ Prioritize findings by impact: - **Always start with detection**: Identify test framework, test project paths, and approximate test count before diving into analysis - **Lead with actionable findings**: Put the most impactful issues first - **Distinguish analysis from action**: This agent produces reports. If the user wants to fix issues, point them to the appropriate skill or agent (e.g., `testability-migration` for static dependencies, `code-testing-generator` for writing new tests) -- **Be honest about experimental skills**: Skills from `dotnet-experimental` are being refined — mention this context when presenting their results +- **Be honest about experimental skills**: Skills from `dotnet-experimental` (`exp-test-maintainability`, `exp-mock-usage-analysis`) are being refined — mention this context when presenting their results diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-assertion-quality/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/assertion-quality/SKILL.md similarity index 98% rename from catalog/Platform/Official-DotNet-Experimental/skills/exp-assertion-quality/SKILL.md rename to catalog/Testing/Official-DotNet-Test/skills/assertion-quality/SKILL.md index 551fa71..a5f7ac0 100644 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-assertion-quality/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/assertion-quality/SKILL.md @@ -1,5 +1,5 @@ --- -name: exp-assertion-quality +name: assertion-quality description: "Analyzes the variety and depth of assertions across .NET test suites. Use when the user asks to evaluate assertion quality, find shallow testing, identify tests with only trivial assertions, measure assertion coverage diversity, or audit whether tests verify different facets of correctness. Produces metrics and actionable recommendations. Works with MSTest, xUnit, NUnit, and TUnit. DO NOT USE FOR: writing new tests (use writing-mstest-tests), detecting anti-patterns (use test-anti-patterns), or fixing existing assertions." license: MIT --- @@ -47,7 +47,7 @@ Low assertion diversity signals shallow testing. Tests may pass while bugs hide ### Step 1: Gather the test code -Read all test files the user provides. If the user points to a directory or project, scan for all test files — see the `exp-dotnet-test-frameworks` skill for framework-specific markers. +Read all test files the user provides. If the user points to a directory or project, scan for all test files — see the `dotnet-test-frameworks` skill for framework-specific markers. ### Step 2: Classify every assertion diff --git a/catalog/Testing/Official-DotNet-Test/skills/assertion-quality/manifest.json b/catalog/Testing/Official-DotNet-Test/skills/assertion-quality/manifest.json new file mode 100644 index 0000000..69b5926 --- /dev/null +++ b/catalog/Testing/Official-DotNet-Test/skills/assertion-quality/manifest.json @@ -0,0 +1,5 @@ +{ + "version": "0.1.0", + "category": "Testing", + "compatibility": "Requires a .NET test project or solution." +} diff --git a/catalog/Testing/Official-DotNet-Test/skills/code-testing-agent/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/code-testing-agent/SKILL.md index 1250a47..6dac9ce 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/code-testing-agent/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/code-testing-agent/SKILL.md @@ -9,7 +9,9 @@ description: >- tests that compile, pass, and follow project conventions. DO NOT USE FOR: running existing tests, executing dotnet test, applying test filters, detecting test platforms, or troubleshooting test execution - (use run-tests for all of these). + (use run-tests for all of these); MSTest-specific assertion guidance, + MSTest test pattern modernization, or fixing existing MSTest test code + (use writing-mstest-tests for those). license: MIT --- diff --git a/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/SKILL.md index cd74bfe..5d75109 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/SKILL.md @@ -18,6 +18,9 @@ This skill provides access to language-specific guidance files used by the code- | File | Language | Contents | |------|----------|----------| | [extensions/dotnet.md](extensions/dotnet.md) | .NET (C#/F#/VB) | Build commands, test commands, project reference validation, common CS error codes, MSTest template | +| [extensions/python.md](extensions/python.md) | Python | Framework-adaptive test commands (pytest, custom runners), project layout detection, mocking guidelines, common errors | +| [extensions/typescript.md](extensions/typescript.md) | TypeScript/JavaScript | Build/test commands (Jest/Vitest/Mocha), framework detection, mocking, TS-specific considerations | +| [extensions/powershell.md](extensions/powershell.md) | PowerShell | Test commands (Pester v5), module import patterns, discovery/run pitfalls, mocking, common errors | | [extensions/cpp.md](extensions/cpp.md) | C++ | Testing internals with friend declarations | | [extensions/dotnet-examples.md](extensions/dotnet-examples.md) | .NET (C#/F#/VB) | Concrete pipeline examples: sample research output, plan, generated tests, fix cycles, final report | diff --git a/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/dotnet.md b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/dotnet.md index 7c30a78..019c76b 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/dotnet.md +++ b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/dotnet.md @@ -71,6 +71,18 @@ If a new test project was created, register it with the solution so `dotnet test 4. Skip this if the project is already included in the solution or solution filter used for testing. 5. Prefer the researched test command. If you need to run the solution directly, use `dotnet test --solution ` only for repos on .NET SDK 10+ with MTP-style syntax; otherwise use the standard positional form `dotnet test `. +## Test Framework Detection + +Detect the framework from the test project's `.csproj` package references and match its conventions: + +| Package Reference | Framework | Attributes | Assertion Style | +|-------------------|-----------|------------|-----------------| +| `MSTest.Sdk` or `MSTest.TestFramework` | MSTest | `[TestClass]`, `[TestMethod]`, `[DataRow]` | `Assert.AreEqual(expected, actual)` | +| `xunit` | xUnit | `[Fact]`, `[Theory]`, `[InlineData]` | `Assert.Equal(expected, actual)` | +| `NUnit` | NUnit | `[TestFixture]`, `[Test]`, `[TestCase]` | `Assert.That(actual, Is.EqualTo(expected))` | + +Use the repo's existing framework — do not introduce a different one. + ## MSTest Template ```csharp diff --git a/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/powershell.md b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/powershell.md new file mode 100644 index 0000000..e0b1201 --- /dev/null +++ b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/powershell.md @@ -0,0 +1,110 @@ +# PowerShell Extension + +Language-specific guidance for PowerShell test generation using Pester v5. + +## Rule #1: Investigate the Repo First + +Before writing any test or running any command, read: + +1. **Existing tests** — find `*.Tests.ps1` files and copy their style (structure, assertions, mock approach, import method) +2. **Module structure** — look for `.psd1` (manifest), `.psm1` (root module), `Public/`/`Private/` organization +3. **Build/test scripts** — check for `build.ps1`, `Invoke-Build` (`*.build.ps1`), `psake`, or CI scripts +4. **Shell target** — check `.psd1` for `PowerShellVersion`/`CompatiblePSEditions`, CI matrix for `pwsh` vs `powershell.exe` + +Use the repo's existing test conventions. Only add Pester if the repo has no tests at all. + +## Build Commands + +PowerShell is interpreted — no build step. If the repo has a build script, use it. Otherwise validate with: + +- **Module loads**: `Import-Module ./MyModule.psd1 -Force -ErrorAction Stop` +- **Script analyzer**: `Invoke-ScriptAnalyzer -Path ./src -Recurse` (if PSScriptAnalyzer is available) +- **Lint**: `Invoke-ScriptAnalyzer -Path path/to/file.ps1 -Fix` + +## Test Commands + +| Scope | Command | +|-------|---------| +| All tests | `Invoke-Pester` | +| Specific file | `Invoke-Pester -Path ./Tests/Get-Widget.Tests.ps1` | +| Filter by name | `Invoke-Pester -FullNameFilter '*Get-Widget*'` | +| Filter by tag | `Invoke-Pester -TagFilter 'Unit'` | +| Non-interactive (CI) | `Invoke-Pester -CI` | +| Detailed output | `Invoke-Pester -Output Detailed` | + +- Prefer the repo's build/test script over raw `Invoke-Pester` +- Use `-Output Detailed` during fix cycles, `-Output Minimal` for final validation + +## Project Layout and Imports + +| Layout | Import in `BeforeAll` | +|--------|-----------------------| +| Module (`.psd1`) | `Import-Module "$PSScriptRoot/../MyModule.psd1" -Force` | +| Library script (defines functions) | `. $PSScriptRoot/Get-Widget.ps1` | +| Co-located test | `. $PSCommandPath.Replace('.Tests.ps1', '.ps1')` | +| Executable script (has `param()`) | Do **not** dot-source — invoke with `& $PSScriptRoot/script.ps1 -Param value` and assert on output/errors | + +- **All imports go in `BeforeAll`** — never at script top level +- **Use `$PSScriptRoot` or `$PSCommandPath`** — never `$MyInvocation.MyCommand.Path` (returns empty in `BeforeAll`) +- Use `-Force` on `Import-Module` to pick up changes between runs + +## Test File Naming + +- Files: `*.Tests.ps1` — match existing convention (co-located vs `Tests/` directory) + +## Pester v5 Discovery vs Run (Critical) + +Pester v5 runs in **two phases**: Discovery (collects test metadata) then Run (executes tests). This is the #1 source of agent errors. + +**Rules:** +- All setup code goes in `BeforeAll` or `BeforeEach` — never at script top level or loose inside `Describe`/`Context` +- Code directly inside `Describe`/`Context` (but outside `It`/`Before*`/`After*`) runs during **Discovery** — do not put setup, imports, or variable assignments there +- Data for `-ForEach` / `-TestCases` must be set in `BeforeDiscovery`, not `BeforeAll` (BeforeAll runs after discovery) +- `-Skip:$condition` evaluates at Discovery time — conditions from `BeforeAll` will be `$null` +- Use `foreach` loops for dynamic test generation only with `BeforeDiscovery` data +- Use `TestDrive:` for file-based tests instead of touching repo files — Pester cleans it up automatically + +## Common Errors + +| Error | Fix | +|-------|-----| +| Variable is `$null` in `It` block | Move assignment into `BeforeAll` — variables set there are visible to child `It` blocks without `$script:` | +| `-ForEach` data is empty | Move data setup from `BeforeAll` to `BeforeDiscovery` | +| `CommandNotFoundException` for Mock target | The function must exist before mocking — import the module in `BeforeAll` first | +| `$MyInvocation.MyCommand.Path` returns empty | Use `$PSCommandPath` or `$PSScriptRoot` instead | +| `Should Be` (no dash) fails | Use v5 syntax: `Should -Be` (with dash prefix) | +| `Assert-MockCalled` not recognized | Use v5 syntax: `Should -Invoke` | +| Mock has no effect | Check scope — mocks in `It` only apply to that `It`; use `BeforeAll`/`BeforeEach` for broader scope | +| `Should -Throw` doesn't catch cmdlet errors | Most cmdlet errors are non-terminating — wrap with `{ cmd -ErrorAction Stop }` or set `$ErrorActionPreference = 'Stop'` in `BeforeEach` | +| Tests pass on Windows but fail on Linux | Use `Join-Path` not string concatenation; match exact file casing; avoid Windows-only cmdlets (Registry, EventLog) | + +## Mocking Rules + +- Place mocks in `BeforeAll` (shared) or `BeforeEach` (reset per test) +- Mock where the command is **called from** — use `-ModuleName` to mock inside a module's scope +- Use `-ParameterFilter` for selective mocking (no `param()` block needed in v5) +- Verify calls with `Should -Invoke` — default scope inside `It` counts only that test's calls +- Use `InModuleScope` sparingly and as narrowly as possible — prefer `Mock -ModuleName` for testing via public API +- Inside mock bodies, use `$PesterBoundParameters` not `$PSBoundParameters` +- If a test needs more than 3 mocks, flag it as a design smell + +## Non-Obvious Assertions + +Most `Should` operators are self-explanatory. These are the ones agents get wrong: + +- `Should -Throw` requires a **scriptblock**: `{ risky-op } | Should -Throw` — not a direct call +- `Should -Contain` is for **collections** — use `Should -Be` for scalar equality +- `Should -HaveParameter` validates cmdlet signatures: `Get-Command X | Should -HaveParameter 'Name' -Mandatory` +- `Should -Invoke` verifies mock calls: `Should -Invoke Get-Item -Times 1 -Exactly` + +## Cross-Platform + +- Prefer `pwsh` (PowerShell 7+) unless the repo explicitly targets Windows PowerShell 5.1 +- Use `Join-Path` for paths — never string concatenation with `\` +- Linux/macOS file systems are **case-sensitive** — match exact casing in imports and paths +- Windows ships Pester 3.4.0 — if v5 is needed: `Install-Module Pester -Force -SkipPublisherCheck` +- Check `$PSVersionTable.PSEdition` to detect Core vs Desktop + +## Skip Coverage Tools + +Do not configure or run coverage tools (Pester CodeCoverage, JaCoCo export). Coverage is measured separately by the evaluation harness. diff --git a/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/python.md b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/python.md new file mode 100644 index 0000000..6584e52 --- /dev/null +++ b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/python.md @@ -0,0 +1,132 @@ +# Python Extension + +Language-specific guidance for Python test generation. + +## Rule #1: Investigate the Repo First + +Before writing any test or running any command, discover what the repo already does: + +1. **Find ALL existing test files** — search broadly: `test_*.py`, `*_test.py`, `*.uts`, `test/*.sh`, or any other test format. Do not assume pytest. +2. **Identify the test framework** — look for: + - Custom test runners (e.g. `UTscapy` for scapy, project-specific harnesses) + - Standard frameworks (`pytest`, `unittest`, `nose2`) + - Test runner scripts in `Makefile`, `tox.ini`, `nox`, `scripts/` + - Config entries in `pyproject.toml`, `setup.cfg`, `pytest.ini`, `conftest.py` +3. **Read existing tests thoroughly** — copy their exact style: file format, imports, fixtures, assertion patterns, helper utilities, setup/teardown conventions +4. **Package layout** — determine import paths from existing code, not guesswork + +**Use whatever framework and conventions the repo already uses.** If the repo uses a custom test framework (custom file formats, custom runners, domain-specific test utilities), adopt it fully — do not layer pytest on top. Only introduce pytest if the repo has no tests at all. + +## Environment Detection + +Detect the runner from lockfiles/config and prefix all commands accordingly: + +| Indicator | Prefix | +|-----------|--------| +| `poetry.lock` / `[tool.poetry]` in `pyproject.toml` | `poetry run` | +| `pdm.lock` / `[tool.pdm]` in `pyproject.toml` | `pdm run` | +| `uv.lock` / `[tool.uv]` in `pyproject.toml` | `uv run` | +| `Pipfile.lock` | `pipenv run` | +| `hatch.toml` / `[tool.hatch]` in `pyproject.toml` | `hatch run` | +| None of the above | `python -m` | + +If `Makefile`, `tox.ini`, or `nox` config exists, prefer those scripts over raw commands. + +## Build Commands + +Python has no separate build step. Validate with the type checker if one is configured: + +| Scope | Command | +|-------|---------| +| Syntax check | ` py_compile path/to/file.py` | +| Type check | ` mypy path/to/file.py` or ` pyright path/to/file.py` | + +## Test Commands + +If the repo uses a **custom test framework** (custom file formats, custom runner), use its native commands — do not wrap them in pytest. Examples: + +| Framework | Command | +|-----------|---------| +| UTscapy (`.uts` files) | ` scapy.tools.UTscapy -f test/test_file.uts` | +| Custom runner script | `make test`, `./run_tests.sh`, `tox` | +| Repo-defined script | Whatever `scripts.test` in Makefile/tox/nox specifies | + +For **pytest** projects (the most common case), use the detected ``: + +| Scope | Command | +|-------|---------| +| All tests | ` pytest` | +| Specific file | ` pytest tests/test_module.py` | +| Specific test | ` pytest tests/test_module.py::TestClass::test_method` | +| Keyword filter | ` pytest -k "keyword"` | +| Stop on first failure | ` pytest -x --tb=short` | + +- Prefer `python -m pytest` over bare `pytest` to ensure the correct interpreter +- If the project uses `unittest` only (no pytest in deps), use `python -m unittest discover` + +## Lint Command + +Use the repo's existing lint script first (`make lint`, `tox -e lint`). Otherwise detect tools from config: + +- `ruff.toml` or `[tool.ruff]` → ` ruff check --fix && ruff format` +- `[tool.black]` → ` black` +- `.flake8` → ` flake8` + +## Project Layout and Imports + +| Layout | Import Style | +|--------|-------------| +| `src/package/module.py` | `from package.module import X` | +| `package/module.py` at root | `from package.module import X` | +| `module.py` at root | `from module import X` | + +- **Match existing test imports exactly** — do not invent `src.` prefixes unless existing tests use them +- Check `pyproject.toml` `[tool.setuptools.package-dir]` for layout hints +- Default test placement: `tests/` mirroring source structure (`src/billing/service.py` → `tests/billing/test_service.py`) + +## Test File Naming + +Match the repo's existing conventions. Common patterns: + +- **pytest**: Files `test_*.py` or `*_test.py`, functions `test_` prefix, classes `Test` prefix +- **Custom frameworks**: Use whatever format existing tests use (e.g. `.uts` for UTscapy, custom extensions) + +If writing new tests in a repo with no tests, default to pytest conventions. + +## Common Errors + +| Error | Fix | +|-------|-----| +| `ModuleNotFoundError: No module named 'src'` | Import from the package name used by the repo, not from `src` | +| `ModuleNotFoundError: No module named 'X'` | Check existing imports for the correct package name; if editable install needed: ` pip install -e .` | +| `ImportError: attempted relative import` | Convert to absolute imports matching existing test patterns | +| `fixture 'X' not found` | Check `conftest.py` for existing fixtures; reuse them instead of creating new ones | +| `TypeError: missing required argument` | Read the full `__init__`/function signature; pass all required parameters | +| `async def functions are not natively supported` | Use `@pytest.mark.asyncio` only if `pytest-asyncio` is already in deps; check for `asyncio_mode = "auto"` in config | +| `SyntaxError` | Fix syntax at the indicated line | + +## Mocking Rules + +- Use `unittest.mock` (stdlib) — no extra dependency needed +- **Patch where the name is looked up**, not where it is defined: `@patch("mypackage.module.datetime")` not `@patch("datetime.datetime")` +- Use `Mock(spec=RealClass)` to catch attribute errors +- Use `AsyncMock` for async functions +- Prefer dependency injection over `@patch` +- If a test needs more than 3 mocks, flag it as a design smell + +## Dependency Installation (Last Resort) + +Only install packages after investigation confirms they are missing. Use the detected prefix: + +| Manager | Install command | +|---------|----------------| +| Poetry | `poetry add --group dev pytest` | +| PDM | `pdm add -dG test pytest` | +| uv | `uv add --dev pytest` | +| pip | `python -m pip install -e ".[dev]"` | + +Never run bare `pip install` in a Poetry/PDM/uv project — it bypasses the lockfile. + +## Skip Coverage Tools + +Do not configure or run coverage tools (coverage.py, pytest-cov). Coverage is measured separately by the evaluation harness. diff --git a/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/typescript.md b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/typescript.md new file mode 100644 index 0000000..de26a70 --- /dev/null +++ b/catalog/Testing/Official-DotNet-Test/skills/code-testing-extensions/extensions/typescript.md @@ -0,0 +1,136 @@ +# TypeScript Extension + +Language-specific guidance for TypeScript (and JavaScript) test generation. + +## Rule #1: Investigate the Repo First + +Before writing any test or running any command, read: + +1. **Existing tests** — find `*.test.ts` / `*.spec.ts` files and copy their style (imports, describe/it vs test, assertion patterns, mock approach) +2. **`package.json`** — `scripts.test`, `devDependencies`, `type` field +3. **Config files** — `tsconfig.json`, `jest.config.*`, `vitest.config.*`, `eslint.config.*` + +Use the repo's existing test runner and conventions — do not switch frameworks. If multiple runners are configured, follow whichever `scripts.test` invokes. Only introduce a framework if the repo has no tests at all. + +## Package Manager Detection + +Detect the package manager from lockfiles and use it consistently for **all** commands: + +| Indicator | Manager | Run script | Execute binary | +|-----------|---------|------------|----------------| +| `pnpm-lock.yaml` | pnpm | `pnpm test` | `pnpm exec ` | +| `yarn.lock` | Yarn | `yarn test` | `yarn ` | +| `bun.lockb` / `bun.lock` | Bun | `bun test` | `bunx ` | +| `package-lock.json` or none | npm | `npm test` | `npx ` | + +Use `` below as shorthand for the detected exec command. + +## Build Commands + +| Scope | Command | +|-------|---------| +| Type check | ` tsc --noEmit` or the repo's `typecheck` script | +| Build (if configured) | The repo's `build` script | + +Many projects don't need an explicit build step — the test runner handles transpilation. + +## Test Commands + +Detect the runner from `devDependencies` and `scripts.test`. Always prefer the repo's test script first. + +| Runner | Run once | Filter by file | Filter by name | +|--------|----------|----------------|----------------| +| **Jest** | ` jest` | ` jest path/to/file` | ` jest -t "name"` | +| **Vitest** | ` vitest run` | ` vitest run path/to/file` | ` vitest run -t "name"` | +| **Mocha** | ` mocha` | (use config or positional args) | ` mocha --grep "name"` | + +- **Always use `vitest run`** (not bare `vitest`) — bare `vitest` starts watch mode +- **Never use `--watch`** — the agent must not start interactive/watch mode +- For Jest: `--bail` to stop on first failure, `--verbose` for detail +- Mocha `--grep` filters by **test name**, not file path + +## Lint Command + +Use the repo's lint script first. Otherwise detect from `devDependencies` and config: + +- `eslint.config.*` or `.eslintrc.*` → ` eslint --fix path/to/file.ts` +- `prettier` → ` prettier --write path/to/file.ts` +- `biome.json` → ` biome check --write path/to/file.ts` + +## Project Layout and Imports + +| Layout | Import Style | +|--------|-------------| +| Colocated (`src/module.test.ts`) | `import { X } from './module'` | +| `__tests__/` dir | `import { X } from '../module'` | +| Top-level `tests/` | `import { X } from '../src/module'` | + +- **Match existing test imports** — copy path style from neighboring tests +- If `tsconfig.json` has `paths` aliases (e.g., `@/`), use them in tests too +- For monorepos: import from the package name, not relative cross-package paths +- For monorepo workspaces (Nx, Turborepo, Lerna): run tests via the workspace tool (`nx test `, `turbo test`), not from a random package directory + +## Test File Naming + +- Match existing convention — check for `.test.ts` vs `.spec.ts` +- Jest/Vitest default: `*.test.ts`, `*.spec.ts`, or files inside `__tests__/` +- Place test files to mirror the existing project pattern + +## Common Errors + +| Error | Fix | +|-------|-----| +| `Cannot find module 'X'` | Check existing imports for correct paths; verify `tsconfig.json` `paths`; check `moduleNameMapper` (Jest) or `resolve.alias` (Vitest) | +| `TS2305: has no exported member` | Verify the exact export name from the source file | +| `TS2345: type not assignable` | Match the expected type; use type assertion only for mock objects | +| `SyntaxError: Unexpected token` / `Jest encountered an unexpected token` | Verify TS transform config (`ts-jest`, `@swc/jest`, or Vitest handles natively) | +| `ReferenceError: describe is not defined` | Vitest: import from `vitest` or set `globals: true` in config; Jest: ensure tests run under Jest not bare `node` | +| `Cannot use import statement outside a module` / `ERR_REQUIRE_ESM` | ESM/CJS mismatch — align runner config with the project's module system (see ESM section); do **not** blindly set `"type": "module"` | +| `ReferenceError: document is not defined` | Set test environment: `testEnvironment: 'jsdom'` (Jest) or `environment: 'jsdom'` (Vitest) | +| `jest.mock() ... out-of-scope variables` | Keep `jest.mock()` at top level; don't reference variables declared after the mock call (Jest hoists mocks) | +| `Cannot find module '@/...'` | Mirror the project's alias config in the test runner's module resolution | +| `Warning: not wrapped in act(...)` | Await async UI updates using the repo's existing pattern (`waitFor`, `act`) | + +## ESM vs CommonJS + +Check these signals to determine the project's module system: + +- `"type": "module"` in `package.json` → ESM +- `"module": "ESNext"` or `"NodeNext"` in `tsconfig.json` → ESM output (but not sufficient alone) +- `.mjs`/`.mts` extensions → ESM files + +If the test runner fails with ESM errors, align the runner's config with the project's module system. **Do not change `package.json` `type` field** — align the test runner to match whatever the project uses: + +- **Jest**: `--experimental-vm-modules` + `ts-jest` with `useESM: true`, or `@swc/jest` +- **Vitest**: handles ESM natively +- **Mocha**: `--loader ts-node/esm` + +## Mocking Rules + +- Prefer dependency injection over module mocking +- Use typed mocks: `jest.Mocked`, `vi.mocked(obj)`, or `Partial` with `as T` +- Jest: `jest.mock()` is hoisted — keep at top level, don't close over local variables +- Vitest: `vi.mock()` follows the same hoisting rules +- If a test needs more than 3–4 mocks, flag it as a design smell +- Mock reset: rely on `clearMocks`/`restoreMocks` config if present; otherwise reset in `beforeEach` + +## Framework-Specific Notes + +- **React/Preact**: use `@testing-library/react`, wrap with necessary providers (router, query client, theme) matching existing test setup +- **Express/Koa**: use `supertest` for HTTP testing if the repo already uses it +- **NestJS**: build testing module with `Test.createTestingModule` — don't instantiate controllers directly + +## Dependency Installation (Last Resort) + +Only install packages after investigation confirms they are missing. Use the detected package manager: + +``` + add --save-dev jest ts-jest @types/jest + add --save-dev vitest +``` + +Never install test infrastructure that conflicts with what the repo already uses. + +## Skip Coverage Tools + +Do not configure or run coverage tools (istanbul, c8, `vitest --coverage`). Coverage is measured separately by the evaluation harness. diff --git a/catalog/Testing/Official-DotNet-Test/skills/detect-static-dependencies/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/detect-static-dependencies/SKILL.md index 385b612..46bda03 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/detect-static-dependencies/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/detect-static-dependencies/SKILL.md @@ -24,6 +24,12 @@ Scan a C# codebase for calls to hard-to-test static APIs and produce a ranked re - Prioritizing which statics to wrap first (highest-frequency wins) - Creating a migration plan for incremental testability improvements +## Response Guidelines + +- Scale the response to the user's request. A question about a specific category (e.g., "find time statics") should focus on that category with file locations and counts, not produce a full report across all categories. +- When the user provides a specific file or directory path, scan only that scope — do not expand to the entire solution unless asked. +- The full structured report format in Step 4 is for comprehensive audit requests. For focused questions, return only the relevant subset (e.g., category summary + affected files for the requested category). + ## When Not to Use - The user wants wrappers generated (hand off to `generate-testability-wrappers`) diff --git a/catalog/Testing/Official-DotNet-Test/skills/migrate-mstest-v1v2-to-v3/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/migrate-mstest-v1v2-to-v3/SKILL.md index 5667a0f..ca7fea4 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/migrate-mstest-v1v2-to-v3/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/migrate-mstest-v1v2-to-v3/SKILL.md @@ -34,7 +34,7 @@ Migrate a test project from MSTest v1 (assembly references) or MSTest v2 (NuGet ## When Not to Use -- Project already uses MSTest v3 (3.x packages) +- Project already on MSTest v3 with no migration-related build errors (fully migrated) - Upgrading v3 to v4 -- use `migrate-mstest-v3-to-v4` - Migrating between frameworks (MSTest to xUnit/NUnit) diff --git a/catalog/Testing/Official-DotNet-Test/skills/migrate-vstest-to-mtp/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/migrate-vstest-to-mtp/SKILL.md index 1e002d3..d1d8928 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/migrate-vstest-to-mtp/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/migrate-vstest-to-mtp/SKILL.md @@ -4,17 +4,17 @@ description: > Migrates .NET test projects from VSTest to Microsoft.Testing.Platform (MTP). Use when user asks to "migrate to MTP", "switch from VSTest", "enable Microsoft.Testing.Platform", "use MTP runner", or mentions EnableMSTestRunner, - EnableNUnitRunner, UseMicrosoftTestingPlatformRunner, dotnet test exit - code 8, zero tests discovered, or MTP behavioral differences - (--ignore-exit-code, TESTINGPLATFORM_EXITCODE_IGNORE). + EnableNUnitRunner, or UseMicrosoftTestingPlatformRunner. + USE FOR: MTP behavioral differences vs VSTest (exit code 8, zero tests + discovered), --ignore-exit-code, TESTINGPLATFORM_EXITCODE_IGNORE. Supports MSTest, NUnit, xUnit.net v2 (via YTest.MTP.XUnit2), and xUnit.net v3 (native MTP). Covers runner enablement, CLI argument - translation, xUnit.net v3 filter syntax, Directory.Build.props and - global.json configuration, CI/CD pipeline updates, and MTP extension - packages. DO NOT USE FOR: migrating between test frameworks - (MSTest/xUnit/NUnit), xUnit.net v2 to v3 API migration, MSTest version - upgrades (use migrate-mstest-* skills), TFM upgrades, or UWP/WinUI test - projects. + translation, xUnit.net v3 filter migration (--filter-class, + --filter-trait, --filter-query), Directory.Build.props and global.json + configuration, CI/CD pipeline updates, and MTP extension packages. + DO NOT USE FOR: migrating between test frameworks (MSTest/xUnit/NUnit), + xUnit.net v2 to v3 API migration, MSTest version upgrades (use + migrate-mstest-* skills), TFM upgrades, or UWP/WinUI test projects. license: MIT --- @@ -35,7 +35,7 @@ Migrate a .NET test solution from VSTest to Microsoft.Testing.Platform (MTP). Th ## When Not to Use -- The project already runs on Microsoft.Testing.Platform -- migration is done +- The project already runs on Microsoft.Testing.Platform and there is no remaining MTP behavioral difference to resolve (e.g., exit code 8 for zero tests discovered) - Migrating between test frameworks (e.g., MSTest to xUnit.net) -- different effort entirely - The project builds UWP or packaged WinUI test projects -- MTP does not support these yet - The solution mixes .NET and non-.NET test adapters (e.g., JavaScript or C++ adapters) -- VSTest is required @@ -208,7 +208,44 @@ VSTest-specific arguments must be translated to MTP equivalents. Build-related a **MSTest, NUnit, and xUnit.net v2 (with `YTest.MTP.XUnit2`)**: The VSTest `--filter` syntax is identical on both VSTest and MTP. No changes needed. -**xUnit.net v3 (native MTP)**: xUnit.net v3 does NOT support the VSTest `--filter` syntax on MTP. See the **VSTest → MTP filter translation** section in the `filter-syntax` skill for the complete translation table. Key translation example: +**xUnit.net v3 (native MTP)**: xUnit.net v3 does NOT support the VSTest `--filter` syntax on MTP. You must translate filters to xUnit.net v3's native filter options. + +#### xUnit.net v3 filter flags + +| Flag | Description | +|------|-------------| +| `--filter-class "name"` | Run all tests in a given class. Supports wildcards (`*`). | +| `--filter-not-class "name"` | Exclude all tests in a given class | +| `--filter-method "name"` | Run a specific test method | +| `--filter-not-method "name"` | Exclude a specific test method | +| `--filter-namespace "name"` | Run all tests in a namespace | +| `--filter-not-namespace "name"` | Exclude all tests in a namespace | +| `--filter-trait "name=value"` | Run tests with a matching trait | +| `--filter-not-trait "name=value"` | Exclude tests with a matching trait | + +Multiple values can be specified with a single flag: `--filter-class Foo Bar`. + +#### VSTest → xUnit.net v3 filter translation table + +| VSTest `--filter` syntax | xUnit.net v3 MTP equivalent | Notes | +|---|---|---| +| `FullyQualifiedName~ClassName` | `--filter-class *ClassName*` | Wildcards required for substring match | +| `FullyQualifiedName=Ns.Class.Method` | `--filter-method Ns.Class.Method` | Exact match on fully qualified method | +| `Name=MethodName` | `--filter-method *MethodName*` | Wildcards for substring match | +| `Category=Value` (trait) | `--filter-trait "Category=Value"` | Filter by trait name/value pair | +| Complex expressions | `--filter-query "expr"` | Uses xUnit.net query filter language (see below) | + +#### xUnit.net v3 query filter language + +For complex expressions, use `--filter-query` with a path-segment syntax: + +```text +////[traitName=traitValue] +``` + +Each segment matches against: assembly name, namespace, class name, method name. Use `*` for "match all" in any segment. Documentation: + +#### Translation example ```shell # VSTest diff --git a/catalog/Testing/Official-DotNet-Test/skills/migrate-xunit-to-xunit-v3/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/migrate-xunit-to-xunit-v3/SKILL.md index 6d2beb6..759ce64 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/migrate-xunit-to-xunit-v3/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/migrate-xunit-to-xunit-v3/SKILL.md @@ -5,7 +5,9 @@ description: > USE FOR: upgrading xunit to xunit.v3. DO NOT USE FOR: migrating between test frameworks (MSTest/NUnit to xUnit.net), migrating from VSTest to Microsoft.Testing.Platform - (use migrate-vstest-to-mtp). + (use migrate-vstest-to-mtp). For xUnit v3 MTP filter syntax + (--filter-class, --filter-trait, --filter-query), also load + migrate-vstest-to-mtp. license: MIT --- @@ -34,7 +36,9 @@ Migrate .NET test projects from xUnit.net v2 to xUnit.net v3. The outcome is a s > **Commit strategy:** Commit after each major step so the migration is reviewable and bisectable. Separate project file changes from code changes. -### Step 1: Identify xUnit.net projects +> **Prioritization:** Steps 1-5 are required for every migration. Steps 6-12 are conditional — only apply the ones relevant to the project's code patterns. Skip steps that don't apply. + +### Step 1: Identify xUnit.net projects and verify compatibility Search for test projects referencing xUnit.net v2 packages: @@ -48,20 +52,9 @@ Search for test projects referencing xUnit.net v2 packages: Make sure to check the package references in project files, MSBuild props and targets files, like `Directory.Build.props`, `Directory.Build.targets`, and `Directory.Packages.props`. -### Step 2: Verify compatibility - -1. Verify target framework compatibility: xUnit.net v3 requires **.NET 8+** or **.NET Framework 4.7.2+**. For test library projects, .NET Standard 2.0 is also supported. -2. If any of the test projects have non-compatible target frameworks, STOP here and DON'T do anything. Only tell the user to upgrade the target framework first before migrating xUnit.net. -3. Verify project compatibility: xUnit.net v3 only supports SDK-style projects. If any test projects are non-SDK-style, STOP here and DON'T do anything. Only tell the user to migrate to SDK-style projects first before migrating xUnit.net. - -### Step 3: Establish a baseline - -Run `dotnet test` to establish a baseline of test pass/fail counts. When running `dotnet test`, ensure that: - -- You run `dotnet test` without any additional arguments (i.e., don't pass `--no-restore` or `--no-build`). -- Ensure you redirect the command output to a file and read the output from that file. +Verify target framework compatibility: xUnit.net v3 requires **.NET 8+** or **.NET Framework 4.7.2+**. For test library projects, .NET Standard 2.0 is also supported. If any test projects have non-compatible target frameworks, STOP here — tell the user to upgrade the target framework first. Also verify the project uses SDK-style format. -### Step 4: Update package references +### Step 2: Update package references 1. Update any `PackageReference` or `PackageVersion` items for the new package names, based on the following mapping: @@ -73,7 +66,7 @@ Run `dotnet test` to establish a baseline of test pass/fail counts. When running 2. Update all `xunit.v3.*` packages to the latest correct version available on NuGet. Also update `xunit.runner.visualstudio` to the latest version. -### Step 5: Set `OutputType` to `Exe` +### Step 3: Set `OutputType` to `Exe` In each test project (excluding test library projects), set `OutputType` to `Exe` in the project file: @@ -89,15 +82,25 @@ Depending on the solution in hand, there might be a centralized place where this - If all test projects share a name pattern (e.g., `*.Tests.csproj`), add a conditional property group in `Directory.Build.props` that applies only to those projects, like `Exe`. Adjust the condition as needed to target only test projects. - Otherwise, add the `Exe` property to each test project file individually. -### Step 6: Remove `Xunit.Abstractions` usings +### Step 4: Configure test platform + +Preserve the same test platform that was used with xUnit.net v2. xUnit.net v2 always uses VSTest except if the project used `YTest.MTP.XUnit2`. + +- If the project had a reference to `YTest.MTP.XUnit2`: + - Remove the reference to `YTest.MTP.XUnit2` completely. + - Add `true` to `Directory.Build.props` under an unconditional `PropertyGroup`. +- If the project did NOT reference `YTest.MTP.XUnit2` (the common case): + - Add `false` to `Directory.Build.props` under an unconditional `PropertyGroup`. If `Directory.Build.props` doesn't exist, create it. This keeps the project on VSTest. + +### Step 5: Remove `Xunit.Abstractions` usings Find any `using Xunit.Abstractions;` directives in C# files and remove them completely. -### Step 7: Address `async void` breaking change +### Step 6: Address `async void` breaking change (if applicable) In xUnit.net v3, `async void` test methods are no longer supported and will fail to compile. Search for any test methods declared with `async void` and change them to `async Task`. Test methods can be identified via the `[Fact]` or `[Theory]` attributes or other test attributes. -### Step 8: Address breaking change of attributes +### Step 7: Address breaking change of attributes (if applicable) In xUnit.net v3, some attributes were updated so that they accept a `System.Type` instead of two strings (fully qualified type name and assembly name). These attributes are: @@ -108,7 +111,7 @@ In xUnit.net v3, some attributes were updated so that they accept a `System.Type For example, `[assembly: CollectionBehavior("MyNamespace.MyCollectionFactory", "MyAssembly")]` must be converted to `[assembly: CollectionBehavior(typeof(MyNamespace.MyCollectionFactory))]`. -### Step 9: Inheriting from FactAttribute or TheoryAttribute +### Step 8: Inheriting from FactAttribute or TheoryAttribute (if applicable) Identify if there are any custom attributes that inherit from `FactAttribute` or `TheoryAttribute`. These custom user-defined attributes must now provide source information. For example, if the attribute looked like this: @@ -135,7 +138,7 @@ internal sealed class MyFactAttribute : FactAttribute } ``` -### Step 10: Inheriting from BeforeAfterTestAttribute +### Step 9: Inheriting from BeforeAfterTestAttribute (if applicable) Identify if there are any custom attributes that inherit from `BeforeAfterTestAttribute`. These custom user-defined attributes must update their method signatures. Previously, they would have `Before`/`After` overrides that look like this: @@ -173,25 +176,11 @@ it must be changed to this: } ``` -### Step 11: Address new xUnit analyzer warnings - -xunit.v3 introduced new analyzer warnings. You should attempt to address them. +### Step 10: Address new xUnit analyzer warnings (if applicable) -One of the most notable warnings is [xUnit1051: Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken](https://xunit.net/xunit.analyzers/rules/xUnit1051). Identify the calls to such methods, if any, and pass the cancellation token. +xunit.v3 introduced new analyzer warnings. The most notable is xUnit1051 (use `TestContext.Current.CancellationToken` for methods accepting `CancellationToken`). Address these if present. -### Step 12: Test platform selection - -You should keep the same test platform that was used with xunit 2. - -Note that xunit 2 is always VSTest except if the user used YTest.MTP.XUnit2. - -- If user had a reference to YTest.MTP.XUnit2: - - Remove the reference to YTest.MTP.XUnit2 completely. - - Add `true` to Directory.Build.props under an unconditional PropertyGroup. -- If user didn't have a reference to YTest.MTP.XUnit2: - - Add `false` to Directory.Build.props under an unconditional PropertyGroup. - -### Step 13: Migrate `Xunit.SkippableFact` +### Step 11: Migrate `Xunit.SkippableFact` (if applicable) If there are any package references to `Xunit.SkippableFact`, remove all these package references entirely. @@ -202,19 +191,11 @@ Then, follow these steps to eliminate usages of APIs coming from the removed pac - Change `Skip.If` method calls to `Assert.SkipWhen`. - Change `Skip.IfNot` method calls to `Assert.SkipUnless`. -### Step 14: Update `Xunit.Combinatorial` NuGet package - -Find package references of `Xunit.Combinatorial` and update them from 1.x to the latest 2.x version available. - -### Step 15: Update `Xunit.StaFact` NuGet package - -Find package references of `Xunit.StaFact` and update them from 1.x to the latest 3.x version available. - -### Step 16: Build the solution +### Step 12: Update companion packages (if applicable) -Now, build the solution to identify any remaining compilation errors that might not have been addressed by previous instructions. -Fix any straightforward errors that show up, and keep iterating and fixing more. +- `Xunit.Combinatorial` 1.x → latest 2.x +- `Xunit.StaFact` 1.x → latest 3.x -You can also look into and to help with the remaining compilation errors. +### Step 13: Build and verify -You can fix as much as you can, and it's okay if not everything is fixed. Just tell the user that there are remaining errors that need to be manually addressed. +Build the solution and fix any remaining compilation errors. Run `dotnet test` to verify all tests pass with the same results as before migration. diff --git a/catalog/Testing/Official-DotNet-Test/skills/test-anti-patterns/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/test-anti-patterns/SKILL.md index 6cbbbfa..44a0886 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/test-anti-patterns/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/test-anti-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: test-anti-patterns -description: "Quick pragmatic detection-focused review of .NET test code for anti-patterns that undermine reliability and diagnostic value. Use when asked to audit test quality, investigate flaky or coupled tests, find duplication or magic values, or when tests pass but don't actually verify anything. Best for identifying and prioritizing issues in existing tests with severity-ranked findings and targeted remediation guidance. Catches assertion gaps, swallowed exceptions, always-true assertions, flakiness indicators, test coupling, over-mocking, naming issues, magic values, duplicate tests, and structural problems. Do NOT use for direct MSTest API rewrites or implementation-only fixes (for example swapped Assert.AreEqual argument order or converting `DynamicData` from `IEnumerable` to `ValueTuple`) — use writing-mstest-tests instead. For a deep formal audit based on academic test smell taxonomy, use exp-test-smell-detection instead. Works with MSTest, xUnit, NUnit, and TUnit." +description: "Quick pragmatic detection-focused review of .NET test code for anti-patterns that undermine reliability and diagnostic value. Use when asked to audit test quality, investigate flaky or coupled tests, find duplication or magic values, or when tests pass but don't actually verify anything. Best for identifying and prioritizing issues in existing tests with severity-ranked findings and targeted remediation guidance. Catches assertion gaps, swallowed exceptions, always-true assertions, flakiness indicators, test coupling, over-mocking, naming issues, magic values, duplicate tests, and structural problems. Do NOT use for direct MSTest API rewrites or implementation-only fixes (for example swapped Assert.AreEqual argument order or converting `DynamicData` from `IEnumerable` to `ValueTuple`) — use writing-mstest-tests instead. For a deep formal audit based on academic test smell taxonomy, use test-smell-detection instead. Works with MSTest, xUnit, NUnit, and TUnit." license: MIT --- @@ -25,7 +25,7 @@ Quick, pragmatic analysis of .NET test code for anti-patterns and quality issues - User wants to run or execute tests (use `run-tests`) - User wants to migrate between test frameworks or versions (use migration skills) - User wants to measure code coverage (out of scope) -- User wants a deep formal test smell audit with academic taxonomy and extended catalog (use `exp-test-smell-detection`) +- User wants a deep formal test smell audit with academic taxonomy and extended catalog (use `test-smell-detection`) ## Inputs diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-gap-analysis/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/test-gap-analysis/SKILL.md similarity index 98% rename from catalog/Platform/Official-DotNet-Experimental/skills/exp-test-gap-analysis/SKILL.md rename to catalog/Testing/Official-DotNet-Test/skills/test-gap-analysis/SKILL.md index b5580be..734b1e3 100644 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-gap-analysis/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/test-gap-analysis/SKILL.md @@ -1,6 +1,6 @@ --- -name: exp-test-gap-analysis -description: "Performs pseudo-mutation analysis on .NET production code to find gaps in existing test suites. Use when the user asks to find weak tests, discover untested edge cases, check if tests would catch a bug, or evaluate test effectiveness through mutation-style reasoning. Analyzes production code for mutation points (boundary conditions, boolean flips, null returns, exception removal, arithmetic changes) and checks whether existing tests would detect each mutation. Works with MSTest, xUnit, NUnit, and TUnit. DO NOT USE FOR: writing new tests (use writing-mstest-tests), detecting test anti-patterns (use test-anti-patterns), measuring assertion diversity (use exp-assertion-quality), or running actual mutation testing tools." +name: test-gap-analysis +description: "Performs pseudo-mutation analysis on .NET production code to find gaps in existing test suites. Use when the user asks to find weak tests, discover untested edge cases, check if tests would catch a bug, or evaluate test effectiveness through mutation-style reasoning. Analyzes production code for mutation points (boundary conditions, boolean flips, null returns, exception removal, arithmetic changes) and checks whether existing tests would detect each mutation. Works with MSTest, xUnit, NUnit, and TUnit. DO NOT USE FOR: writing new tests (use writing-mstest-tests), detecting test anti-patterns (use test-anti-patterns), measuring assertion diversity (use assertion-quality), or running actual mutation testing tools." license: MIT --- @@ -35,7 +35,7 @@ This skill performs **static pseudo-mutation** — reasoning about mutations wit - User wants to write new tests from scratch (use `writing-mstest-tests`) - User wants to detect test anti-patterns like flakiness or poor naming (use `test-anti-patterns`) -- User wants to measure assertion variety (use `exp-assertion-quality`) +- User wants to measure assertion variety (use `assertion-quality`) - User wants to run an actual mutation testing framework like Stryker (help them directly) - User only wants code coverage numbers (out of scope) diff --git a/catalog/Testing/Official-DotNet-Test/skills/test-gap-analysis/manifest.json b/catalog/Testing/Official-DotNet-Test/skills/test-gap-analysis/manifest.json new file mode 100644 index 0000000..69b5926 --- /dev/null +++ b/catalog/Testing/Official-DotNet-Test/skills/test-gap-analysis/manifest.json @@ -0,0 +1,5 @@ +{ + "version": "0.1.0", + "category": "Testing", + "compatibility": "Requires a .NET test project or solution." +} diff --git a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-smell-detection/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/SKILL.md similarity index 94% rename from external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-smell-detection/SKILL.md rename to catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/SKILL.md index 546cd98..148f85f 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-smell-detection/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/SKILL.md @@ -1,6 +1,6 @@ --- -name: exp-test-smell-detection -description: "Deep formal test smell audit based on academic research taxonomy (testsmells.org). Detects 19 categorized smell types — conditional logic, mystery guests, sensitive equality, eager tests, and more — with calibrated severity and research-backed remediation. Use for comprehensive test suite health assessments. For a quick pragmatic review, use test-anti-patterns instead. DO NOT USE FOR: writing new tests (use writing-mstest-tests), evaluating assertion quality specifically (use exp-assertion-quality), or finding test duplication and boilerplate (use exp-test-maintainability)." +name: test-smell-detection +description: "Deep formal test smell audit based on academic research taxonomy (testsmells.org). Detects 19 categorized smell types — conditional logic, mystery guests, sensitive equality, eager tests, and more — with calibrated severity and research-backed remediation. Use for comprehensive test suite health assessments. For a quick pragmatic review, use test-anti-patterns instead. DO NOT USE FOR: writing new tests (use writing-mstest-tests), evaluating assertion quality specifically (use assertion-quality), or finding test duplication and boilerplate (use exp-test-maintainability)." license: MIT --- @@ -34,7 +34,7 @@ Test smells erode confidence in a test suite and inflate maintenance costs: ## When Not to Use - User wants a quick pragmatic test review (use `test-anti-patterns` — faster, covers the most common issues) -- User wants to evaluate assertion diversity specifically (use `exp-assertion-quality`) +- User wants to evaluate assertion diversity specifically (use `assertion-quality`) - User wants to find duplicated boilerplate across tests (use `exp-test-maintainability`) - User wants to write new tests from scratch (help them directly) - User wants to fix a specific failing test (diagnose and fix directly) @@ -50,7 +50,7 @@ Test smells erode confidence in a test suite and inflate maintenance costs: ### Step 1: Gather the test code -Read all test files the user provides. If the user points to a directory or project, scan for all test files by looking for test framework markers — see the `exp-dotnet-test-frameworks` skill for .NET-specific markers. +Read all test files the user provides. If the user points to a directory or project, scan for all test files by looking for test framework markers — see the `dotnet-test-frameworks` skill for .NET-specific markers. For a thorough audit, also consult the [extended smell catalog](references/test-smell-catalog.md) which covers 9 additional smell types beyond the core 10 below. @@ -79,7 +79,7 @@ Tests that depend on external resources — files on disk, databases, network en Tests that call sleep or delay functions to wait for a condition. These introduce non-deterministic timing and slow down the suite. **Severity:** High -**Detection:** Calls to sleep/delay functions inside test methods. See the `exp-dotnet-test-frameworks` skill for .NET-specific patterns. +**Detection:** Calls to sleep/delay functions inside test methods. See the `dotnet-test-frameworks` skill for .NET-specific patterns. #### Smell 4: Assertion-Free Test (Unknown Test) @@ -132,7 +132,7 @@ The test setup method or constructor initializes fields that are not used by eve Tests marked as skipped or disabled. These add overhead and clutter, and the underlying issue they were disabled for may never be addressed. **Severity:** Low -**Detection:** Skip/ignore annotations or conditional compilation that disables a test. See the `exp-dotnet-test-frameworks` skill for framework-specific skip attributes. +**Detection:** Skip/ignore annotations or conditional compilation that disables a test. See the `dotnet-test-frameworks` skill for framework-specific skip attributes. ### Step 3: Apply calibration rules @@ -192,7 +192,7 @@ Present the analysis in this structure: | Flagging integration tests for using real resources | Check for integration test markers and adjust severity accordingly | | Flagging loop-over-collection-assert as conditional logic | Only flag loops with branching or complex logic, not assertion iterations | | Flagging obvious count assertions after adding N items | Consider the immediate context — self-documenting numbers are fine | -| Missing framework-specific assertion syntax | Consult the `exp-dotnet-test-frameworks` skill for .NET framework assertion and skip APIs | +| Missing framework-specific assertion syntax | Consult the `dotnet-test-frameworks` skill for .NET framework assertion and skip APIs | | Over-flagging try/catch that captures for assertion | Distinguish swallowed exceptions from capture-and-assert patterns | | Treating skip annotations with reasons same as bare skips | Note that reasoned skips are less concerning than unexplained ones | | Flagging `DoesNotThrow`-style tests as assertion-free | These implicitly assert no exception — note but acknowledge the intent | diff --git a/catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/manifest.json b/catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/manifest.json new file mode 100644 index 0000000..69b5926 --- /dev/null +++ b/catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/manifest.json @@ -0,0 +1,5 @@ +{ + "version": "0.1.0", + "category": "Testing", + "compatibility": "Requires a .NET test project or solution." +} diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/references/test-smell-catalog.md b/catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/references/test-smell-catalog.md similarity index 100% rename from catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/references/test-smell-catalog.md rename to catalog/Testing/Official-DotNet-Test/skills/test-smell-detection/references/test-smell-catalog.md diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-tagging/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/test-tagging/SKILL.md similarity index 98% rename from catalog/Platform/Official-DotNet-Experimental/skills/exp-test-tagging/SKILL.md rename to catalog/Testing/Official-DotNet-Test/skills/test-tagging/SKILL.md index 0419a08..b423463 100644 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-tagging/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/test-tagging/SKILL.md @@ -1,5 +1,5 @@ --- -name: exp-test-tagging +name: test-tagging description: "Analyzes test suites and tags each test with a standardized set of traits (e.g., positive, negative, critical-path, boundary, smoke, regression). Use when the user wants to categorize, audit, or label tests with traits. Do not use for writing new tests, running tests, or migrating test frameworks." license: MIT --- @@ -57,7 +57,7 @@ A single test may have **multiple traits** (e.g., both `negative` and `boundary` ### Step 1: Detect the test framework -Examine project files and source code to determine the framework — see the `exp-dotnet-test-frameworks` skill for the complete detection table (package references, test markers, assertion APIs, and skip annotations). +Examine project files and source code to determine the framework — see the `dotnet-test-frameworks` skill for the complete detection table (package references, test markers, assertion APIs, and skip annotations). ### Step 2: Scan existing traits diff --git a/catalog/Testing/Official-DotNet-Test/skills/test-tagging/manifest.json b/catalog/Testing/Official-DotNet-Test/skills/test-tagging/manifest.json new file mode 100644 index 0000000..69b5926 --- /dev/null +++ b/catalog/Testing/Official-DotNet-Test/skills/test-tagging/manifest.json @@ -0,0 +1,5 @@ +{ + "version": "0.1.0", + "category": "Testing", + "compatibility": "Requires a .NET test project or solution." +} diff --git a/catalog/Testing/Official-DotNet-Test/skills/writing-mstest-tests/SKILL.md b/catalog/Testing/Official-DotNet-Test/skills/writing-mstest-tests/SKILL.md index b4b32a4..31cc944 100644 --- a/catalog/Testing/Official-DotNet-Test/skills/writing-mstest-tests/SKILL.md +++ b/catalog/Testing/Official-DotNet-Test/skills/writing-mstest-tests/SKILL.md @@ -1,6 +1,19 @@ --- name: writing-mstest-tests -description: "Best practices for writing new MSTest 3.x/4.x unit tests and implementing concrete fixes in existing MSTest code. Use when the user asks to write, create, implement, repair, or modernize tests (including fix-it prompts such as 'something seems off, fix issues'). Primary fit for direct code changes like correcting swapped Assert.AreEqual argument order, replacing outdated assertion patterns, and converting DynamicData from IEnumerable to ValueTuple-based data sets. Covers modern assertions, data-driven tests, test lifecycle, MSTest.Sdk, sealed classes, Assert.Throws, DynamicData with ValueTuples, TestContext, and conditional execution. Do NOT use for broad test quality audits, flaky-test investigations, or test smell detection reports — use test-anti-patterns instead." +description: > + Write new MSTest unit tests and implement concrete fixes in existing MSTest code using + MSTest 3.x/4.x modern APIs and best practices. + USE FOR: write unit tests for a class, write MSTest tests, create test class, + fix test assertions, MSTest assertion APIs (StartsWith, EndsWith, MatchesRegex, + IsGreaterThan, IsInRange, HasCount, IsNull), something seems off with my tests, + review tests and fix issues, + fix swapped Assert.AreEqual arguments, replace ExpectedException with Assert.Throws, modernize + test patterns, convert DynamicData to ValueTuples, data-driven tests, test lifecycle setup, + sealed test classes, async test patterns, cancellation token testing, + test parallelization, Parallelize, DoNotParallelize, MSTest.Sdk project setup. + DO NOT USE FOR: broad test quality audits or test smell detection (use test-anti-patterns), + running tests (use run-tests), MSTest version migration (use migrate-mstest-v1v2-to-v3 or + migrate-mstest-v3-to-v4). license: MIT --- @@ -13,6 +26,8 @@ Help users write effective, modern unit tests with MSTest 3.x/4.x using current - User wants to write new MSTest unit tests - User wants to improve or modernize existing MSTest tests by implementing concrete fixes - User asks about MSTest assertion APIs, data-driven patterns, or test lifecycle +- User asks to replace `Assert.IsTrue` with more specific assertions (collections, nulls, types, comparisons) +- User asks to replace hard casts with type-checking assertions in tests - User needs help fixing a specific MSTest test bug or failing assertion - User asks to fix swapped `Assert.AreEqual` argument order (expected first, actual second) - User asks to convert `DynamicData` from `IEnumerable` to ValueTuple-based data @@ -34,6 +49,12 @@ Help users write effective, modern unit tests with MSTest 3.x/4.x using current | Existing test code | No | Current tests to fix, update, or modernize | | Test scenario description | No | What behavior the user wants to test | +## Response Guidelines + +- **Specific API or pattern questions** (assertions, data-driven, lifecycle): Jump directly to the relevant workflow step. Do not follow the full workflow. +- **Write new tests from scratch**: Follow the full workflow. +- **Review and fix existing tests**: Fix only the issues present. Do not add unrelated improvements. + ## Workflow ### Step 1: Determine project setup @@ -109,13 +130,29 @@ public sealed class OrderServiceTests ### Step 3: Use modern assertion APIs -Use the correct assertion for each scenario. Prefer `Assert` class methods over `StringAssert` or `CollectionAssert` where both exist. +Pick the most specific assertion for each test scenario. More specific assertions produce better failure messages and make the test's intent clear: -#### Equality and null checks +| What you are testing | Assertion | +|---|---| +| Two values are equal | `Assert.AreEqual(expected, actual)` | +| Same object instance (reference identity) | `Assert.AreSame(expected, actual)` | +| Value is null | `Assert.IsNull(value)` | +| Value is not null | `Assert.IsNotNull(value)` | +| Collection is empty | `Assert.IsEmpty(collection)` | +| Collection is not empty | `Assert.IsNotEmpty(collection)` | +| Collection has exactly N items | `Assert.HasCount(N, collection)` | +| Collection contains an item | `Assert.Contains(item, collection)` | +| Collection does not contain an item | `Assert.DoesNotContain(item, collection)` | +| Object is a specific type | `Assert.IsInstanceOfType(value)` | +| Code throws an exception | `Assert.ThrowsExactly(() => ...)` | + +Prefer `Assert` class methods over `StringAssert` or `CollectionAssert` where both exist. + +#### Equality, null, and reference checks ```csharp Assert.AreEqual(expected, actual); // Value equality -Assert.AreSame(expected, actual); // Reference equality +Assert.AreSame(expected, actual); // Reference equality -- same object instance Assert.IsNull(value); Assert.IsNotNull(value); ``` @@ -151,8 +188,12 @@ Replace generic `Assert.IsTrue` with specialized assertions -- they give better | Instead of | Use | |---|---| | `Assert.IsTrue(list.Count > 0)` | `Assert.IsNotEmpty(list)` | +| `Assert.IsTrue(list.Count == 0)` | `Assert.IsEmpty(list)` | | `Assert.IsTrue(list.Count() == 3)` | `Assert.HasCount(3, list)` | | `Assert.IsTrue(x != null)` | `Assert.IsNotNull(x)` | +| `Assert.IsTrue(x == null)` | `Assert.IsNull(x)` | +| `Assert.AreEqual(a, b)` for same instance | `Assert.AreSame(a, b)` -- reference identity | +| `Assert.IsTrue(!list.Contains(item))` | `Assert.DoesNotContain(item, list)` | | `list.Single(predicate)` + `Assert.IsNotNull` | `Assert.ContainsSingle(list)` | | `Assert.IsTrue(list.Contains(item))` | `Assert.Contains(item, list)` | @@ -323,29 +364,3 @@ public void LocalOnly_InteractiveTest() { } [DoNotParallelize] // Opt out specific classes public sealed class DatabaseIntegrationTests { } ``` - -## Validation - -- [ ] Test classes are `sealed` -- [ ] Test methods follow `MethodName_Scenario_ExpectedBehavior` naming -- [ ] `Assert.ThrowsExactly` used instead of `[ExpectedException]` -- [ ] Specialized assertions used instead of `Assert.IsTrue` (e.g., `Assert.IsNotNull`, `Assert.AreEqual`) -- [ ] DynamicData uses ValueTuple return types instead of `IEnumerable` -- [ ] Sync initialization done in the constructor, not `[TestInitialize]` -- [ ] `TestContext.CancellationToken` passed to async calls in tests with `[Timeout]` -- [ ] Project builds with zero errors and all tests pass - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| `Assert.AreEqual(actual, expected)` -- swapped arguments | Always put expected first: `Assert.AreEqual(expected, actual)`. Failure messages show "Expected: X, Actual: Y" so wrong order makes messages confusing | -| `[ExpectedException]` -- obsolete, cannot assert message | Use `Assert.Throws` or `Assert.ThrowsExactly` | -| `items.Single()` -- unclear exception on failure | Use `Assert.ContainsSingle(items)` for better failure messages | -| Hard cast `(MyType)result` -- unclear exception | Use `Assert.IsInstanceOfType(result)` | -| `IEnumerable` for DynamicData | Use `IEnumerable<(T1, T2, ...)>` ValueTuples for type safety | -| Sync setup in `[TestInitialize]` | Initialize in the constructor instead -- enables `readonly` fields and satisfies nullability analyzers | -| `CancellationToken.None` in async tests | Use `TestContext.CancellationToken` for cooperative timeout | -| `public TestContext? TestContext { get; set; }` | Drop the `?` -- MSTest suppresses CS8618 for this property | -| `TestContext TestContext { get; set; } = null!` | Remove `= null!` -- unnecessary, MSTest handles assignment | -| Non-sealed test classes | Seal test classes by default for performance | diff --git a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-dotnet-test-frameworks/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-dotnet-test-frameworks/SKILL.md deleted file mode 100644 index fe4b2dd..0000000 --- a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-dotnet-test-frameworks/SKILL.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: exp-dotnet-test-frameworks -description: "Reference data for .NET test framework detection patterns, assertion APIs, skip annotations, setup/teardown methods, and common test smell indicators across MSTest, xUnit, NUnit, and TUnit. Loaded by test analysis skills (exp-test-smell-detection, exp-assertion-quality, exp-test-maintainability, exp-test-tagging) as framework-specific lookup tables." -user-invocable: false -license: MIT ---- - -# .NET Test Framework Reference - -Language-specific detection patterns for .NET test frameworks (MSTest, xUnit, NUnit, TUnit). - -## Test File Identification - -| Framework | Test class markers | Test method markers | -| --------- | ------------------ | ------------------- | -| MSTest | `[TestClass]` | `[TestMethod]`, `[DataTestMethod]` | -| xUnit | _(none — convention-based)_ | `[Fact]`, `[Theory]` | -| NUnit | `[TestFixture]` | `[Test]`, `[TestCase]`, `[TestCaseSource]` | -| TUnit | `[ClassDataSource]` | `[Test]` | - -## Assertion APIs by Framework - -| Category | MSTest | xUnit | NUnit | -| -------- | ------ | ----- | ----- | -| Equality | `Assert.AreEqual` | `Assert.Equal` | `Assert.That(x, Is.EqualTo(y))` | -| Boolean | `Assert.IsTrue` / `Assert.IsFalse` | `Assert.True` / `Assert.False` | `Assert.That(x, Is.True)` | -| Null | `Assert.IsNull` / `Assert.IsNotNull` | `Assert.Null` / `Assert.NotNull` | `Assert.That(x, Is.Null)` | -| Exception | `Assert.Throws()` / `Assert.ThrowsExactly()` | `Assert.Throws()` | `Assert.That(() => ..., Throws.TypeOf())` | -| Collection | `CollectionAssert.Contains` | `Assert.Contains` | `Assert.That(col, Has.Member(x))` | -| String | `StringAssert.Contains` | `Assert.Contains(str, sub)` | `Assert.That(str, Does.Contain(sub))` | -| Type | `Assert.IsInstanceOfType` | `Assert.IsAssignableFrom` | `Assert.That(x, Is.InstanceOf())` | -| Inconclusive | `Assert.Inconclusive()` | _skip via `[Fact(Skip)]`_ | `Assert.Inconclusive()` | -| Fail | `Assert.Fail()` | `Assert.Fail()` (.NET 10+) | `Assert.Fail()` | - -Third-party assertion libraries: `Should*` (Shouldly), `.Should()` (FluentAssertions / AwesomeAssertions), `Verify()` (Verify). - -## Sleep/Delay Patterns - -| Pattern | Example | -| ------- | ------- | -| Thread sleep | `Thread.Sleep(2000)` | -| Task delay | `await Task.Delay(1000)` | -| SpinWait | `SpinWait.SpinUntil(() => condition, timeout)` | - -## Skip/Ignore Annotations - -| Framework | Annotation | With reason | -| --------- | ---------- | ----------- | -| MSTest | `[Ignore]` | `[Ignore("reason")]` | -| xUnit | `[Fact(Skip = "reason")]` | _(reason is required)_ | -| NUnit | `[Ignore("reason")]` | _(reason is required)_ | -| TUnit | `[Skip("reason")]` | _(reason is required)_ | -| Conditional | `#if false` / `#if NEVER` | _(no reason possible)_ | - -## Exception Handling — Idiomatic Alternatives - -When a test uses `try`/`catch` to verify exceptions, suggest the framework-native alternative: - -**MSTest:** - -```csharp -// Instead of try/catch (matches exact type): -var ex = Assert.ThrowsExactly( - () => processor.ProcessOrder(emptyOrder)); -Assert.AreEqual("Order must contain at least one item", ex.Message); - -// Or (also matches derived types): -var ex = Assert.Throws( - () => processor.ProcessOrder(emptyOrder)); -Assert.AreEqual("Order must contain at least one item", ex.Message); -``` - -**xUnit:** - -```csharp -var ex = Assert.Throws( - () => processor.ProcessOrder(emptyOrder)); -Assert.Equal("Order must contain at least one item", ex.Message); -``` - -**NUnit:** - -```csharp -var ex = Assert.Throws( - () => processor.ProcessOrder(emptyOrder)); -Assert.That(ex.Message, Is.EqualTo("Order must contain at least one item")); -``` - -## Mystery Guest — Common .NET Patterns - -| Smell indicator | What to look for | -| --------------- | ---------------- | -| File system | `File.ReadAllText`, `File.Exists`, `File.WriteAllBytes`, `Directory.GetFiles`, `Path.Combine` with hard-coded paths | -| Database | `SqlConnection`, `DbContext` (without in-memory provider), `SqlCommand` | -| Network | `HttpClient` without `HttpMessageHandler` override, `WebRequest`, `TcpClient` | -| Environment | `Environment.GetEnvironmentVariable`, `Environment.CurrentDirectory` | -| Acceptable | `MemoryStream`, `StringReader`, `InMemory` database providers, custom `DelegatingHandler` | - -## Integration Test Markers - -Recognize these as integration tests (adjust smell severity accordingly): - -- Class name contains `Integration`, `E2E`, `EndToEnd`, or `Acceptance` -- `[TestCategory("Integration")]` (MSTest) -- `[Trait("Category", "Integration")]` (xUnit) -- `[Category("Integration")]` (NUnit) -- Project name ending in `.IntegrationTests` or `.E2ETests` - -## Setup/Teardown Methods - -| Framework | Setup | Teardown | -| --------- | ----- | -------- | -| MSTest | `[TestInitialize]` or constructor | `[TestCleanup]` or `IDisposable.Dispose` / `IAsyncDisposable.DisposeAsync` | -| xUnit | constructor | `IDisposable.Dispose` / `IAsyncDisposable.DisposeAsync` | -| NUnit | `[SetUp]` | `[TearDown]` | -| MSTest (class) | `[ClassInitialize]` | `[ClassCleanup]` | -| NUnit (class) | `[OneTimeSetUp]` | `[OneTimeTearDown]` | -| xUnit (class) | `IClassFixture` | fixture's `Dispose` | diff --git a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-maintainability/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-maintainability/SKILL.md index 4a6352e..64d780b 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-maintainability/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-maintainability/SKILL.md @@ -36,7 +36,7 @@ Analyze .NET test code for maintainability issues: duplicated boilerplate, copy- ### Step 1: Gather the test code -Read all test files the user provides or references. If the user points to a directory or project, scan for all test files — see the `exp-dotnet-test-frameworks` skill for framework-specific markers. +Read all test files the user provides or references. If the user points to a directory or project, scan for all test files — see the `dotnet-test-frameworks` skill for framework-specific markers. ### Step 2: Identify maintainability issues diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/README.md b/external-sources/upstreams/dotnet-skills/dotnet-test/README.md new file mode 100644 index 0000000..731fdd4 --- /dev/null +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/README.md @@ -0,0 +1,103 @@ +# dotnet-test + +Skills and agents for running, generating, analyzing, migrating, and improving .NET tests across all major frameworks (MSTest, xUnit, NUnit, TUnit) and platforms (VSTest, Microsoft.Testing.Platform). + +## When to use this plugin + +- **Run tests** — execute `dotnet test` with automatic platform/framework detection and filter syntax +- **Generate tests** — scaffold comprehensive unit tests for any language via a multi-agent pipeline +- **Migrate tests** — upgrade MSTest v1/v2 → v3 → v4, xUnit v2 → v3, or VSTest → Microsoft.Testing.Platform +- **Audit test quality** — detect anti-patterns, test smells, assertion gaps, and coverage risks +- **Improve testability** — find static dependencies, generate wrappers, and migrate call sites to injectable abstractions +- **Measure coverage** — collect code coverage, compute CRAP scores, and surface risk hotspots + +## Skills + +### Test execution + +| Skill | Description | +|---|---| +| **run-tests** | Run .NET tests via `dotnet test` with platform/framework auto-detection and filter support | +| **mtp-hot-reload** | Rapid test-fix iteration using MTP hot reload (edit code → re-run without rebuilding) | + +### Test generation + +| Skill | Description | +|---|---| +| **code-testing-agent** | Multi-agent pipeline (Research → Plan → Implement → Build → Test → Fix → Lint) that generates tests for any language | +| **writing-mstest-tests** | Best practices and modern APIs for writing MSTest 3.x/4.x tests | + +### Test migration + +| Skill | Description | +|---|---| +| **migrate-mstest-v1v2-to-v3** | Upgrade MSTest v1 (assembly refs) or v2 (NuGet 1.x–2.x) to v3 | +| **migrate-mstest-v3-to-v4** | Upgrade MSTest v3 to v4 — handles all source and behavioral breaking changes | +| **migrate-xunit-to-xunit-v3** | Upgrade xUnit.net v2 to v3 | +| **migrate-vstest-to-mtp** | Migrate from VSTest runner to Microsoft.Testing.Platform | + +### Test quality & analysis + +| Skill | Description | +|---|---| +| **test-anti-patterns** | Quick pragmatic scan for ~15 common test quality issues with severity ranking | +| **test-smell-detection** | Deep formal audit using academic test smell taxonomy (19 smell types) | +| **assertion-quality** | Measure assertion variety and depth — find shallow tests that barely verify anything | +| **test-gap-analysis** | Pseudo-mutation analysis to find test blind spots that coverage numbers miss | +| **test-tagging** | Tag tests with standardized traits (smoke, regression, boundary, critical-path, etc.) | + +### Coverage & risk + +| Skill | Description | +|---|---| +| **coverage-analysis** | Project-wide code coverage collection with CRAP score computation and risk hotspot reporting | +| **crap-score** | Calculate CRAP (Change Risk Anti-Patterns) scores for individual methods, classes, or files | + +### Testability improvement + +| Skill | Description | +|---|---| +| **detect-static-dependencies** | Scan C# code for hard-to-test statics (DateTime.Now, File.*, HttpClient, etc.) | +| **generate-testability-wrappers** | Generate wrapper interfaces or guide adoption of built-in abstractions (TimeProvider, IFileSystem) | +| **migrate-static-to-wrapper** | Bulk-replace static call sites with injected wrapper calls and add constructor injection | + +### Reference data (loaded by other skills) + +| Skill | Description | +|---|---| +| **code-testing-extensions** | Language-specific guidance files loaded by the code-testing pipeline | +| **platform-detection** | Detect VSTest vs MTP and identify the test framework from project files | +| **filter-syntax** | Test filter syntax reference for VSTest and MTP across all frameworks | +| **dotnet-test-frameworks** | Framework detection patterns, assertion APIs, skip annotations, and lifecycle methods | + +## Agents + +### User-facing agents + +These are the entry-point agents you invoke directly: + +| Agent | Purpose | +|---|---| +| **code-testing-generator** | Orchestrates the full test generation pipeline (research → plan → implement → build → test → fix → lint) | +| **test-migration** | Auto-detects framework/version and routes to the correct migration skill | +| **test-quality-auditor** | Runs multi-skill audit pipelines for comprehensive test suite assessment | +| **testability-migration** | End-to-end testability improvement: detect → generate wrappers → migrate call sites | + +### Internal subagents + +These are pipeline stages invoked automatically by the agents above (`user-invocable: false`). You do not need to call them directly: + +| Agent | Called by | Purpose | +|---|---|---| +| **code-testing-researcher** | code-testing-generator | Analyzes codebase structure, testing patterns, and testability | +| **code-testing-planner** | code-testing-generator | Creates phased test implementation plans from research findings | +| **code-testing-implementer** | code-testing-generator | Implements one phase from the plan, runs build-test-fix cycles | +| **code-testing-builder** | code-testing-implementer | Runs build/compile commands and reports results | +| **code-testing-tester** | code-testing-implementer | Runs test commands and reports pass/fail results | +| **code-testing-fixer** | code-testing-implementer | Fixes compilation errors in source or test files | +| **code-testing-linter** | code-testing-implementer | Runs code formatting and linting | + +## Prerequisites + +- .NET SDK installed (`dotnet` on PATH) +- A project with an existing test framework (MSTest, xUnit, NUnit, or TUnit) for execution and analysis skills diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-fixer.agent.md b/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-fixer.agent.md index 192c7ad..e0d8264 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-fixer.agent.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-fixer.agent.md @@ -1,9 +1,10 @@ --- description: >- - Fixes compilation errors in source or test files. + Fixes compilation errors and failing tests in source or test files. Use when: resolving build errors, fixing CS/TS error codes, adding missing - imports, correcting type mismatches, fixing compilation failures. + imports, correcting type mismatches, fixing compilation failures, OR + correcting failing test assertions against production source. name: code-testing-fixer user-invocable: false license: MIT @@ -11,19 +12,22 @@ license: MIT # Fixer Agent -You fix compilation errors in code files. You are polyglot — you work with any programming language. +You fix compilation errors **and failing tests** in code files. You are polyglot — you work with any programming language. > **Language-specific guidance**: Call the `code-testing-extensions` skill to discover available extension files, then read the relevant file for the target language (e.g., `dotnet.md` for .NET). ## Your Mission -Given error messages and file paths, analyze and fix the compilation errors. +Given error messages or test failures and file paths, analyze and fix the issue. Two failure modes are in scope: + +1. **Compilation errors** — read the failing file around the error location and apply the smallest correct fix (missing `using`/`import`, wrong type, missing parameter, etc.). +2. **Failing test assertions** — when a freshly generated test fails because its expected value does not match production behavior, read the production source the test is exercising and correct the test's expected value to match. Never `[Ignore]` / `[Skip]` / delete a test to make it pass; never modify production code to match a wrong test. ## Process ### 1. Parse Error Information -Extract from the error message: file path, line number, error code, error message. +Extract from the error message: file path, line number, error code (for compilation), or test name and assertion difference (for test failures). ### 2. Read the File diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-generator.agent.md b/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-generator.agent.md index ab4722a..d19b4e9 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-generator.agent.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-generator.agent.md @@ -14,6 +14,92 @@ You coordinate test generation using the Research-Plan-Implement (RPI) pipeline. > **Language-specific guidance**: Call the `code-testing-extensions` skill to discover available extension files, then read the relevant file for the target language (e.g., `dotnet.md` for .NET). +## Dispatch Discipline (read first — applies to every dispatch) + +### Rule 1: Every `task` call MUST have `agent_type: "dotnet-test:code-testing-…"` + +```text +✅ task({ agent_type: "dotnet-test:code-testing-researcher", name: "researcher", prompt: "..." }) +❌ task({ name: "explore-tests", prompt: "..." }) // generic, no agent_type +❌ task({ agent_type: "explore", prompt: "..." }) // generic built-in +❌ task({ agent_type: "general-purpose", prompt: "..." }) // generic built-in +``` + +A `task` call without the `dotnet-test:code-testing-…` prefix dispatches a generic built-in agent (`task`, `explore`, or `general-purpose`) that does **not** load the CTA prompt or the language extension. Generic dispatches are forbidden in this pipeline. + +If a sub-task is too small to warrant a CTA sub-agent, **do it yourself** with `read` / `search` (subject to Rules 4 and 5 below). Do not dispatch a generic helper. + +### Rule 2: Specific routing — when to dispatch which named agent + +| You need to… | Dispatch this named agent (NOT a generic helper) | +|---|---| +| Initial scoping research (every run, in Step 1b) | `dotnet-test:code-testing-researcher` | +| Diagnose an unfamiliar test failure | `dotnet-test:code-testing-researcher` (additional dispatch with narrow scope) | +| Read codebase structure / find test framework / discover existing tests | `dotnet-test:code-testing-researcher` | +| Translate research into a per-phase plan (every run, in Step 4) | `dotnet-test:code-testing-planner` | +| Write tests for one phase / file / function | `dotnet-test:code-testing-implementer` | +| Run a workspace build and report errors | `dotnet-test:code-testing-builder` | +| Run a test suite and parse failures | `dotnet-test:code-testing-tester` | +| Fix any test failure (mandatory — never fix tests inline yourself, dispatch the fixer) | `dotnet-test:code-testing-fixer` | +| Lint / format generated code (mandatory after every implementer dispatch finishes if a lint command exists) | `dotnet-test:code-testing-linter` | + +If the work matches one of these rows, dispatch the named CTA agent. Do not call generic `explore` / `general-purpose` / `task` for these jobs. + +### Rule 3: Prefer one named-agent dispatch over many tool calls + +Dispatching `code-testing-tester` once with a rich prompt is preferable to running 5+ `terminal` test commands yourself. Dispatching `code-testing-researcher` once is preferable to chaining 10+ `read` / `search` / `glob` calls. The CTA agents are tuned for these jobs. + +### Rule 4: You MUST NOT write or modify test files yourself + +The `edit` tool is available to you, but you are forbidden from using it to create or modify any source or test file. Every test-file write goes through `code-testing-implementer`. Every fix to a failing test goes through `code-testing-fixer`. This applies to ALL strategies including Direct. + +```text +✅ task({ agent_type: "dotnet-test:code-testing-implementer", name: "implementer", prompt: "Write tests for ..." }) +❌ edit("tests/test_foo.py", "...") // direct edit of a test file — forbidden +❌ terminal("cat > tests/test_foo.py <0 failures → dispatch fixer → re-dispatch tester (mandatory) +❌ tester reports 5 failures → orchestrator writes summary and returns // forbidden — silent acceptance +❌ orchestrator decides failures look "minor" and skips fixer // forbidden +``` + +You may stop the fixer loop early only if the **same test name fails identically across two consecutive fixer attempts** (genuine non-flaky deadlock — log it in the final report). + ## Pipeline Overview 1. **Research** — Understand the codebase structure, testing patterns, and what needs testing @@ -28,15 +114,34 @@ Understand what the user wants: scope (project, files, classes), priority areas, **Read the language-specific extension** for the target codebase by calling the `code-testing-extensions` skill (e.g., read `dotnet.md` for .NET/C# projects). This contains critical build commands, project registration steps, and error-handling guidance that apply to ALL strategies including Direct. You MUST read this file before writing any code. +### Step 1b: Mandatory initial researcher dispatch (every strategy, no exceptions) + +Before any other CTA dispatch, dispatch the researcher once to populate `.testagent/research.md`: + +```text +task({ + agent_type: "dotnet-test:code-testing-researcher", + name: "researcher", + prompt: "Initial scoping research for test generation. Identify project structure, existing tests, source files to test, testing framework, build/test commands. Then explicitly answer two questions in `.testagent/research.md`: (1) Which unit (function/class/method) is under test, with a `file:line` citation. (2) Which behaviors need exercising — positive paths, negative/error paths, and edge cases relevant to the request. Write findings to .testagent/research.md." +}) +``` + +After the researcher returns, **verify `.testagent/research.md` answers two questions explicitly**: + +1. *Which unit (function/class/method) is under test*, with a file:line citation. +2. *Which behaviors need exercising* (positive paths, negative/error paths, edge cases relevant to the request). + +If either is missing or vague, dispatch the researcher one more time with narrow scope to fill the gap. If both are present, proceed to Step 2 — do not dispatch the researcher again unless `.testagent/research.md` itself is later proven wrong (e.g., implementer cannot find the unit). + ### Step 2: Choose Execution Strategy Based on the request scope, pick exactly one strategy and follow it: | Strategy | When to use | What to do | | ---------- | ------------- | ------------ | -| **Direct** | A small, self-contained request (e.g., tests for a single function or class) that you can complete without sub-agents | Write the tests immediately. **Run them right away** — if any test fails, read the production code, fix the assertion, and re-run before writing more tests. Skip Steps 3-5 (research, plan, implement sub-agents). Then proceed to Steps 6-9 for validation and reporting. | -| **Single pass** | A moderate scope (couple projects or modules) that a single Research → Plan → Implement cycle can cover | Execute Steps 3-8 once, then proceed to Step 9. | -| **Iterative** | A large scope or ambitious coverage target that one pass cannot satisfy | Execute Steps 3-8, then re-evaluate coverage. If the target is not met, repeat Steps 3-8 with a narrowed focus on remaining gaps. Use unique names for each iteration's `.testagent/` documents (e.g., `research-2.md`, `plan-2.md`) so earlier results are not overwritten. Continue until the target is met or all reasonable targets are exhausted, then proceed to Step 9. | +| **Direct** | A small, self-contained request (e.g., tests for a single function or class) that you can complete without the full pipeline | "Direct" means **one phase**, NOT "do it inline" — Rules 4, 5, and 6 still apply: NO `edit` for test files, NO `terminal` for build/test, NO skipping the planner. Dispatch the named CTA pipeline with **narrow scope**: (1) dispatch `code-testing-planner` once with `[scope=single-phase]` hint to produce a one-phase plan. (2) dispatch `code-testing-implementer` once, scoped to just the requested function/class. (3) dispatch `code-testing-builder` to compile. (4) dispatch `code-testing-tester` to run. (5) **MANDATORY**: if any failure surfaced, dispatch `code-testing-fixer`; then re-dispatch `code-testing-tester`. (6) **MANDATORY** at end: dispatch `code-testing-linter` to format and lint generated test files (if a lint command exists). Then proceed to Steps 6-10 for validation, cleanup, and reporting (which also dispatch builder/tester/fixer). Step 3 (deep Research Phase) is skipped for Direct — Step 1b already produced sufficient `.testagent/research.md`. | +| **Single pass** | A moderate scope (couple projects or modules) that a single Research → Plan → Implement cycle can cover | Execute Steps 3-8 once, then proceed to Steps 9-10. | +| **Iterative** | A large scope or ambitious coverage target that one pass cannot satisfy | Execute Steps 3-8, then re-evaluate coverage. If the target is not met, repeat Steps 3-8 with a narrowed focus on remaining gaps. Use unique names for each iteration's `.testagent/` documents (e.g., `research-2.md`, `plan-2.md`) so earlier results are not overwritten. Continue until the target is met or all reasonable targets are exhausted, then proceed to Steps 9-10. | **Default to Direct** unless the request explicitly mentions multiple files, modules, or an entire project. Most test generation requests — including "generate tests for function X", "add tests covering these scenarios", and "write unit tests for this class" — should use Direct strategy. The full Research → Plan → Implement pipeline is only needed when the scope spans multiple unrelated source files. @@ -51,16 +156,17 @@ Based on the request scope, pick exactly one strategy and follow it: | "Generate comprehensive tests for my ASP.NET app" | Single pass | If the app has fewer than 10 controllers/services/files in scope, one R→P→I cycle should cover it | | "Generate comprehensive tests for my large ASP.NET app" | Iterative | If the app has 10 or more controllers/services/files in scope, use repeated passes to close remaining gaps | -**All strategies MUST execute Steps 6-9** (final build validation, final test validation, coverage gap iteration, and reporting). These steps are never skipped. +**All strategies MUST execute Steps 6-10** (final build validation, final test validation, coverage gap iteration, diff validation/cleanup, and reporting). These steps are never skipped. -### Step 3: Research Phase +### Step 3: Deep Research Phase (Single pass and Iterative only — skipped for Direct) -Call the `code-testing-researcher` subagent: +Step 1b already produced `.testagent/research.md` with the unit-under-test contract and behaviors. For broader scopes, dispatch the researcher again to **extend** that file with cross-file analysis. Do not overwrite the Step 1b findings; append or update in place. ```text -runSubagent({ - agent: "code-testing-researcher", - prompt: "Research the codebase at [PATH] for test generation. Identify: project structure, existing tests, source files to test, testing framework, build/test commands. Build a dependency graph and estimate preexisting coverage." +task({ + agent_type: "dotnet-test:code-testing-researcher", + name: "researcher-deep", + prompt: "Extend .testagent/research.md (already populated in Step 1b with unit-under-test contract and behaviors). Add: (1) dependency graph for in-scope files, (2) preexisting test coverage estimate, (3) any cross-project build/test details not already captured. Preserve the unit-under-test and behaviors sections from Step 1b — append to research.md rather than rewriting it." }) ``` @@ -68,12 +174,13 @@ Output: `.testagent/research.md` ### Step 4: Planning Phase -Call the `code-testing-planner` subagent: +**Mandatory for every strategy** (Rule 6). Even for Direct (single-function) scope, the planner runs and produces a one-phase plan. ```text -runSubagent({ - agent: "code-testing-planner", - prompt: "Create a test implementation plan based on .testagent/research.md. Create phased approach with specific files and test cases." +task({ + agent_type: "dotnet-test:code-testing-planner", + name: "planner", + prompt: "Create a phased test implementation plan based on .testagent/research.md. Create phased approach with specific files and test cases. Write the plan to .testagent/plan.md." }) ``` @@ -81,37 +188,64 @@ Output: `.testagent/plan.md` ### Step 5: Implementation Phase -Execute each phase by calling the `code-testing-implementer` subagent — once per phase, sequentially: +Execute each phase by dispatching the implementer once, sequentially: ```text -runSubagent({ - agent: "code-testing-implementer", - prompt: "Implement Phase N from .testagent/plan.md: [phase description]. Ensure tests compile and pass." +task({ + agent_type: "dotnet-test:code-testing-implementer", + name: "implementer", + prompt: "Implement Phase N from .testagent/plan.md: [phase description]. Apply the language-specific guidance from the relevant code-testing-extensions file. Ensure tests compile and pass." }) ``` +Wait for each implementer dispatch to return before dispatching the next phase. Do not parallelize phases — implementers may modify the same project files. + ### Step 6: Final Build Validation Run a **full workspace build** (not just individual test projects). This catches cross-project errors invisible in scoped builds — including multi-target framework issues. -- **.NET**: `dotnet build MySolution.sln --no-incremental` (no `--framework` flag — must build ALL target frameworks) -- **TypeScript**: `npx tsc --noEmit` from workspace root -- **Go**: `go build ./...` from module root -- **Rust**: `cargo build` +Always dispatch the builder (Rule 5 — never run the build inline via `terminal`). This applies to ALL strategies including Direct: + +```text +task({ + agent_type: "dotnet-test:code-testing-builder", + name: "builder", + prompt: "Run a full, non-incremental workspace build. .NET: 'dotnet build --no-incremental' from the repo root with NO --framework flag (must build all target frameworks). If the repo contains a .sln/.slnx, use 'dotnet build .sln --no-incremental'. TypeScript: 'npx tsc --noEmit' from workspace root. Go: 'go build ./...' from module root. Rust: 'cargo build'. Report any errors." +}) +``` + +If it fails, **Rule 7 applies — you MUST dispatch the fixer; do not skip and do not declare success with build errors.** Rebuild after the fixer returns; retry up to 3 times. -If it fails, call the `code-testing-fixer`, rebuild, retry up to 3 times. +```text +task({ + agent_type: "dotnet-test:code-testing-fixer", + name: "fixer", + prompt: "Fix the following build failures: [paste failures]. Read production code and correct the expected values; never use [Ignore]/[Skip]. Do not delete or overwrite pre-existing tests." +}) +``` ### Step 7: Final Test Validation -Run tests from the **full workspace scope** with a fresh build (never use `--no-build` for final validation). If tests fail: +Run tests from the **full workspace scope** with a fresh build (never use `--no-build` for final validation). -- **Wrong assertions** — read production code, fix the expected value. Never `[Ignore]` or `[Skip]` a test just to pass. -- **Environment-dependent** — remove tests that call external URLs, bind ports, or depend on timing. Prefer mocked unit tests. -- **Pre-existing failures** — note them but don't block. +Always dispatch the tester (Rule 5 — never run tests inline via `terminal`). This applies to ALL strategies including Direct: -**Verify tests are implementation-specific:** +```text +task({ + agent_type: "dotnet-test:code-testing-tester", + name: "tester", + prompt: "Run the full workspace test suite from a fresh build (do not use --no-build). Report failures with reasons and stack traces." +}) +``` -- Each test should assert on **concrete values** returned by the function — not just type checks, non-null checks, or other assertions that would still pass if the function body were empty or returned a default value. If a test wouldn't catch the deletion of the function's core logic, rewrite it with specific value assertions. +If tests fail: + +- **Rule 7 applies — you MUST dispatch the fixer; do not silently accept failed tests as 'good enough'.** Even one failed test triggers a fixer dispatch. Re-run the tester after each fixer return. Repeat up to 3 cycles. +- **Wrong assertions** — the fixer will read production code and correct the expected value. Never `[Ignore]` or `[Skip]` a test just to pass. +- **Environment-dependent** — the fixer can remove tests that call external URLs, bind ports, or depend on timing. Prefer mocked unit tests. +- **Pre-existing failures** — note them in the final report but they still must go through fixer (so the fixer can confirm they are pre-existing, not regressions caused by this run). + +You may stop the fixer→tester loop early ONLY if the same test name fails identically across two consecutive fixer attempts (genuine deadlock — log it in the final report). ### Step 8: Coverage Gap Iteration @@ -120,10 +254,32 @@ After the previous phases complete, check for uncovered source files: 1. List all source files in scope. 2. List all test files created. 3. Identify source files with no corresponding test file. -4. Generate tests for each uncovered file, build, test, and fix. -5. Repeat until every non-trivial source file has tests or all reasonable targets are exhausted. +4. If gaps remain, dispatch a focused researcher → planner → implementer cycle: + +```text +task({ + agent_type: "dotnet-test:code-testing-researcher", + name: "researcher-gap", + prompt: "Re-research scoped to: [specific uncovered files/functions]. Write findings to .testagent/research-2.md." +}) +``` + +Then re-run planner (writing `.testagent/plan-2.md`) and implementer for the gap phase, followed by builder/tester/fixer cycles. Do this at most once per run; if the second iteration also leaves gaps, list them in the final report rather than looping further. + +### Step 9: Validate Diff and Clean Up + +Before reporting, verify the patch contains only legitimate test changes and remove pipeline scratch state. These are file/git operations that the orchestrator performs directly — do not dispatch a CTA agent for cleanup (the build/test agents have narrower missions and cleanup is not in their charter; Rule 5 forbids inline `terminal` for build/test only, not for git or filesystem hygiene). + +Perform these steps in order: + +1. Remove the `.testagent/` directory if it exists. +2. Run `git status --porcelain` and `git diff --name-only HEAD` to list every file the pipeline touched. +3. For any modified file outside test directories that was not part of the original task, revert it. +4. Do NOT commit; the harness captures the working tree. + +If a modified non-test file was a deliberate part of the task (e.g., adding `[InternalsVisibleTo]` for test access), keep it and note it in the Step 10 report. -### Step 9: Report Results +### Step 10: Report Results Summarize tests created, report any failures or issues, suggest next steps if needed. @@ -168,15 +324,16 @@ All state is stored in `.testagent/` folder: ## Rules -1. **Sequential phases** — complete one phase before starting the next -2. **Polyglot** — detect the language and use appropriate patterns -3. **Verify** — each phase must produce compiling, passing tests -4. **Don't skip** — report failures rather than skipping phases -5. **Clean git first** — stash pre-existing changes before starting -6. **Scoped builds during phases, full build at the end** — build specific test projects during implementation for speed; run a full-workspace non-incremental build after all phases to catch cross-project errors -7. **No environment-dependent tests** — mock all external dependencies; never call external URLs, bind ports, or depend on timing -8. **Fix assertions, don't skip tests** — when tests fail, read production code and fix the expected value; never `[Ignore]` or `[Skip]` -9. **Clean up `.testagent/`** — after pipeline completion, delete the `.testagent/` folder or advise the user to add it to `.gitignore` so ephemeral state is not committed -10. **Read language extensions first** — always call the `code-testing-extensions` skill and read the relevant extension file before writing any code; it contains critical project registration and build validation steps -11. **Always validate** — final build, final test, coverage-gap review, and reporting are mandatory for ALL strategies including Direct; never skip final validation -12. **Preserve existing tests** — never delete or overwrite existing test files; create new files or append to existing ones +1. **Every `task` dispatch MUST use `agent_type: "dotnet-test:code-testing-…"`** — bare `task({...})` calls and calls with `agent_type: "explore"`, `agent_type: "general-purpose"`, or `agent_type: "task"` dispatch generic built-in agents that do NOT load the CTA prompt, skills, or language extension. Generic dispatches are forbidden in this pipeline. +2. **Sequential phases** — complete one phase before starting the next. +3. **Polyglot** — detect the language and use appropriate patterns; load `code-testing-extensions` first. +4. **Verify** — each phase must produce compiling, passing tests. +5. **Don't skip** — report failures rather than skipping phases. +6. **Clean git first** — stash pre-existing changes before starting. +7. **Scoped builds during phases, full build at the end** — build specific test projects during implementation for speed; run a full-workspace non-incremental build after all phases to catch cross-project errors. +8. **No environment-dependent tests** — mock all external dependencies; never call external URLs, bind ports, or depend on timing. +9. **Fix assertions, don't skip tests** — when tests fail, dispatch the fixer; never `[Ignore]` or `[Skip]`. +10. **Step 9 validate + cleanup is mandatory** — for ALL strategies including Direct. Skipping it leaves leftover `.testagent/` files in the patch. +11. **Read language extensions first** — always call the `code-testing-extensions` skill and read the relevant extension file before writing any code. +12. **Always validate** — final build, final test, coverage-gap review, and reporting are mandatory for ALL strategies including Direct; never skip final validation. +13. **Preserve existing tests** — never delete or overwrite existing test files; create new files or append to existing ones. diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-implementer.agent.md b/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-implementer.agent.md index 26e0416..2a50585 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-implementer.agent.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/agents/code-testing-implementer.agent.md @@ -55,7 +55,15 @@ For each test file in your phase: Call the `code-testing-builder` sub-agent to compile. Build only the specific test project, not the full solution. -If build fails: call `code-testing-fixer`, rebuild, retry up to 3 times. +If build fails: **you MUST dispatch `code-testing-fixer`** — do not edit/create test files inline to make the build pass. Rebuild after the fixer returns. Retry up to 3 times. + +```text +✅ builder fails → code-testing-fixer → builder retry (correct) +❌ builder fails → edit("tests/test_foo.py", ...) → builder retry (forbidden — band-aid) +❌ builder fails → create("tests/test_bar.py", ...) → builder retry (forbidden — band-aid) +``` + +The reason: when the implementer "patches" a test file inline to make the build pass, it tends to remove problematic assertions, comment out failing branches, or weaken types — none of which the fixer would do. Inline-fix is the classic band-aid anti-pattern: the build goes green, but the test no longer exercises what was specified. ### 6. Verify with Tests @@ -63,19 +71,24 @@ Call the `code-testing-tester` sub-agent to run tests. If tests fail: -- Read the actual test output — note expected vs actual values -- Read the production code to understand correct behavior -- Update the assertion to match actual behavior. Common mistakes: - - Hardcoded IDs that don't match derived values - - Asserting counts in async scenarios without waiting for delivery - - Assuming constructor defaults that differ from implementation -- For async/event-driven tests: add explicit waits before asserting -- Never mark a test `[Ignore]`, `[Skip]`, or `[Inconclusive]` -- Retry the fix-test cycle up to 5 times +- **You MUST dispatch the fixer.** Even one failed test triggers a fixer dispatch — never declare `STATUS: SUCCESS` with failing tests, and never silently accept failures as "minor". +- **You MUST NOT use `edit` or `create` on test files between a failed tester dispatch and the next fixer dispatch.** The fixer is the only sub-agent allowed to modify a failing test file: + +```text +✅ tester reports failure → code-testing-fixer → tester retry (correct) +❌ tester reports failure → edit("tests/test_foo.py", ...) → tester retry (forbidden — band-aid) +❌ tester reports failure → mark test [Skip] / pytest.skip / t.Skip(...) (forbidden — silent acceptance) +❌ tester reports failure → delete the failing test method (forbidden — silent acceptance) +``` + +- Pass the actual test output (expected vs actual values) to the fixer in the dispatch prompt +- Cite the relevant `:` of the production code in the fixer dispatch prompt +- Never mark a test `[Ignore]`, `[Skip]`, `[Inconclusive]`, `pytest.skip`, `t.Skip`, `it.skip`, or any language-equivalent skip mechanism — neither the implementer nor the fixer may do this +- Retry the fix-test cycle up to 5 times. You may stop early ONLY if the same test name fails identically across two consecutive fixer attempts (genuine deadlock — log it in the report). -### 7. Format Code (Optional) +### 7. Format Code (mandatory if a lint command exists) -If a lint command is available, call the `code-testing-linter` sub-agent. +If the project has a lint or format command, call the `code-testing-linter` sub-agent. Skip only if no lint command exists in the project. ### 8. Report Results @@ -99,3 +112,5 @@ ISSUES: 3. **Match patterns** — follow existing test style 4. **Be thorough** — cover edge cases 5. **Report clearly** — state what was done and any issues +6. **Never declare SUCCESS while build or tests fail** — any build error or failed test triggers a fixer dispatch. The implementer never silently accepts failures as "minor" or "good enough" — dispatch the fixer, re-run, and only declare SUCCESS when build is clean and all tests pass (or document a genuine deadlock after 2+ identical fixer attempts). +7. **No inline test-file edits between a failed dispatch and the fixer** — once `code-testing-builder` returns an error or `code-testing-tester` returns a failure, the next dispatch on a test file MUST be `code-testing-fixer`. The implementer MUST NOT use `edit`/`create` on test source files between the failed dispatch and the fixer dispatch, MUST NOT add `Skip`/`Ignore`/`Inconclusive` markers, and MUST NOT delete the failing test. The fixer is the only sub-agent allowed to mutate a test file in this state. diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/agents/test-quality-auditor.agent.md b/external-sources/upstreams/dotnet-skills/dotnet-test/agents/test-quality-auditor.agent.md index 0913620..338269f 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/agents/test-quality-auditor.agent.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/agents/test-quality-auditor.agent.md @@ -60,13 +60,13 @@ Classify the user's request and route to the appropriate skill: | User Intent | Route To | Plugin | |---|---|---| -| "Are my assertions good enough?" / shallow testing / assertion diversity | `exp-assertion-quality` skill | dotnet-experimental | -| "Find test smells" / comprehensive formal audit | `exp-test-smell-detection` skill | dotnet-experimental | +| "Are my assertions good enough?" / shallow testing / assertion diversity | `assertion-quality` skill | dotnet-test | +| "Find test smells" / comprehensive formal audit | `test-smell-detection` skill | dotnet-test | | "Pragmatic anti-pattern check" within a broader audit context | `test-anti-patterns` skill | dotnet-test | | "Find test duplication" / boilerplate / DRY up tests | `exp-test-maintainability` skill | dotnet-experimental | | "Are my mocks needed?" / over-mocking / mock audit | `exp-mock-usage-analysis` skill | dotnet-experimental | -| "Would my tests catch bugs?" / mutation analysis / test gaps | `exp-test-gap-analysis` skill | dotnet-experimental | -| "Categorize my tests" / tag tests / trait distribution | `exp-test-tagging` skill | dotnet-experimental | +| "Would my tests catch bugs?" / mutation analysis / test gaps | `test-gap-analysis` skill | dotnet-test | +| "Categorize my tests" / tag tests / trait distribution | `test-tagging` skill | dotnet-test | | "Coverage report" / risk hotspots / CRAP score | `coverage-analysis` skill (use `crap-score` only for explicitly targeted method/class CRAP analysis or narrow-scope Cobertura data) | dotnet-test | | "Find untestable code" / static dependencies | `detect-static-dependencies` skill → hand off to `testability-migration` agent for fixes | dotnet-test | | "Full health check" / "audit my tests" / broad quality request | Run the **Comprehensive Audit Pipeline** below | multiple | @@ -83,11 +83,11 @@ Run these in order. Each step builds context for the next. Stop early if the use - Quick pragmatic scan for the most impactful issues - Produces severity-ranked findings (Critical → Low) -2. **Assertion quality** — `exp-assertion-quality` skill +2. **Assertion quality** — `assertion-quality` skill - Measures assertion variety and depth - Reveals whether tests actually verify meaningful behavior -3. **Test gaps** — `exp-test-gap-analysis` skill +3. **Test gaps** — `test-gap-analysis` skill - Pseudo-mutation analysis to find blind spots - Answers "would tests catch a bug here?" @@ -97,10 +97,10 @@ Run these in order. Each step builds context for the next. Stop early if the use ### Optional follow-ups (offer but don't run automatically) -5. **Test smells** — `exp-test-smell-detection` skill (if step 1 found many issues and the user wants a deeper formal audit) +5. **Test smells** — `test-smell-detection` skill (if step 1 found many issues and the user wants a deeper formal audit) 6. **Maintainability** — `exp-test-maintainability` skill (if the test suite is large and duplication is suspected) 7. **Mock audit** — `exp-mock-usage-analysis` skill (if over-mocking was flagged in step 1) -8. **Test tagging** — `exp-test-tagging` skill (if the user wants to understand test type distribution) +8. **Test tagging** — `test-tagging` skill (if the user wants to understand test type distribution) ### Synthesizing results @@ -152,4 +152,4 @@ Prioritize findings by impact: - **Always start with detection**: Identify test framework, test project paths, and approximate test count before diving into analysis - **Lead with actionable findings**: Put the most impactful issues first - **Distinguish analysis from action**: This agent produces reports. If the user wants to fix issues, point them to the appropriate skill or agent (e.g., `testability-migration` for static dependencies, `code-testing-generator` for writing new tests) -- **Be honest about experimental skills**: Skills from `dotnet-experimental` are being refined — mention this context when presenting their results +- **Be honest about experimental skills**: Skills from `dotnet-experimental` (`exp-test-maintainability`, `exp-mock-usage-analysis`) are being refined — mention this context when presenting their results diff --git a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-assertion-quality/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/assertion-quality/SKILL.md similarity index 98% rename from external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-assertion-quality/SKILL.md rename to external-sources/upstreams/dotnet-skills/dotnet-test/skills/assertion-quality/SKILL.md index 551fa71..a5f7ac0 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-assertion-quality/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/assertion-quality/SKILL.md @@ -1,5 +1,5 @@ --- -name: exp-assertion-quality +name: assertion-quality description: "Analyzes the variety and depth of assertions across .NET test suites. Use when the user asks to evaluate assertion quality, find shallow testing, identify tests with only trivial assertions, measure assertion coverage diversity, or audit whether tests verify different facets of correctness. Produces metrics and actionable recommendations. Works with MSTest, xUnit, NUnit, and TUnit. DO NOT USE FOR: writing new tests (use writing-mstest-tests), detecting anti-patterns (use test-anti-patterns), or fixing existing assertions." license: MIT --- @@ -47,7 +47,7 @@ Low assertion diversity signals shallow testing. Tests may pass while bugs hide ### Step 1: Gather the test code -Read all test files the user provides. If the user points to a directory or project, scan for all test files — see the `exp-dotnet-test-frameworks` skill for framework-specific markers. +Read all test files the user provides. If the user points to a directory or project, scan for all test files — see the `dotnet-test-frameworks` skill for framework-specific markers. ### Step 2: Classify every assertion diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-agent/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-agent/SKILL.md index 1250a47..6dac9ce 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-agent/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-agent/SKILL.md @@ -9,7 +9,9 @@ description: >- tests that compile, pass, and follow project conventions. DO NOT USE FOR: running existing tests, executing dotnet test, applying test filters, detecting test platforms, or troubleshooting test execution - (use run-tests for all of these). + (use run-tests for all of these); MSTest-specific assertion guidance, + MSTest test pattern modernization, or fixing existing MSTest test code + (use writing-mstest-tests for those). license: MIT --- diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/SKILL.md index cd74bfe..5d75109 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/SKILL.md @@ -18,6 +18,9 @@ This skill provides access to language-specific guidance files used by the code- | File | Language | Contents | |------|----------|----------| | [extensions/dotnet.md](extensions/dotnet.md) | .NET (C#/F#/VB) | Build commands, test commands, project reference validation, common CS error codes, MSTest template | +| [extensions/python.md](extensions/python.md) | Python | Framework-adaptive test commands (pytest, custom runners), project layout detection, mocking guidelines, common errors | +| [extensions/typescript.md](extensions/typescript.md) | TypeScript/JavaScript | Build/test commands (Jest/Vitest/Mocha), framework detection, mocking, TS-specific considerations | +| [extensions/powershell.md](extensions/powershell.md) | PowerShell | Test commands (Pester v5), module import patterns, discovery/run pitfalls, mocking, common errors | | [extensions/cpp.md](extensions/cpp.md) | C++ | Testing internals with friend declarations | | [extensions/dotnet-examples.md](extensions/dotnet-examples.md) | .NET (C#/F#/VB) | Concrete pipeline examples: sample research output, plan, generated tests, fix cycles, final report | diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/dotnet.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/dotnet.md index 7c30a78..019c76b 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/dotnet.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/dotnet.md @@ -71,6 +71,18 @@ If a new test project was created, register it with the solution so `dotnet test 4. Skip this if the project is already included in the solution or solution filter used for testing. 5. Prefer the researched test command. If you need to run the solution directly, use `dotnet test --solution ` only for repos on .NET SDK 10+ with MTP-style syntax; otherwise use the standard positional form `dotnet test `. +## Test Framework Detection + +Detect the framework from the test project's `.csproj` package references and match its conventions: + +| Package Reference | Framework | Attributes | Assertion Style | +|-------------------|-----------|------------|-----------------| +| `MSTest.Sdk` or `MSTest.TestFramework` | MSTest | `[TestClass]`, `[TestMethod]`, `[DataRow]` | `Assert.AreEqual(expected, actual)` | +| `xunit` | xUnit | `[Fact]`, `[Theory]`, `[InlineData]` | `Assert.Equal(expected, actual)` | +| `NUnit` | NUnit | `[TestFixture]`, `[Test]`, `[TestCase]` | `Assert.That(actual, Is.EqualTo(expected))` | + +Use the repo's existing framework — do not introduce a different one. + ## MSTest Template ```csharp diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/powershell.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/powershell.md new file mode 100644 index 0000000..e0b1201 --- /dev/null +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/powershell.md @@ -0,0 +1,110 @@ +# PowerShell Extension + +Language-specific guidance for PowerShell test generation using Pester v5. + +## Rule #1: Investigate the Repo First + +Before writing any test or running any command, read: + +1. **Existing tests** — find `*.Tests.ps1` files and copy their style (structure, assertions, mock approach, import method) +2. **Module structure** — look for `.psd1` (manifest), `.psm1` (root module), `Public/`/`Private/` organization +3. **Build/test scripts** — check for `build.ps1`, `Invoke-Build` (`*.build.ps1`), `psake`, or CI scripts +4. **Shell target** — check `.psd1` for `PowerShellVersion`/`CompatiblePSEditions`, CI matrix for `pwsh` vs `powershell.exe` + +Use the repo's existing test conventions. Only add Pester if the repo has no tests at all. + +## Build Commands + +PowerShell is interpreted — no build step. If the repo has a build script, use it. Otherwise validate with: + +- **Module loads**: `Import-Module ./MyModule.psd1 -Force -ErrorAction Stop` +- **Script analyzer**: `Invoke-ScriptAnalyzer -Path ./src -Recurse` (if PSScriptAnalyzer is available) +- **Lint**: `Invoke-ScriptAnalyzer -Path path/to/file.ps1 -Fix` + +## Test Commands + +| Scope | Command | +|-------|---------| +| All tests | `Invoke-Pester` | +| Specific file | `Invoke-Pester -Path ./Tests/Get-Widget.Tests.ps1` | +| Filter by name | `Invoke-Pester -FullNameFilter '*Get-Widget*'` | +| Filter by tag | `Invoke-Pester -TagFilter 'Unit'` | +| Non-interactive (CI) | `Invoke-Pester -CI` | +| Detailed output | `Invoke-Pester -Output Detailed` | + +- Prefer the repo's build/test script over raw `Invoke-Pester` +- Use `-Output Detailed` during fix cycles, `-Output Minimal` for final validation + +## Project Layout and Imports + +| Layout | Import in `BeforeAll` | +|--------|-----------------------| +| Module (`.psd1`) | `Import-Module "$PSScriptRoot/../MyModule.psd1" -Force` | +| Library script (defines functions) | `. $PSScriptRoot/Get-Widget.ps1` | +| Co-located test | `. $PSCommandPath.Replace('.Tests.ps1', '.ps1')` | +| Executable script (has `param()`) | Do **not** dot-source — invoke with `& $PSScriptRoot/script.ps1 -Param value` and assert on output/errors | + +- **All imports go in `BeforeAll`** — never at script top level +- **Use `$PSScriptRoot` or `$PSCommandPath`** — never `$MyInvocation.MyCommand.Path` (returns empty in `BeforeAll`) +- Use `-Force` on `Import-Module` to pick up changes between runs + +## Test File Naming + +- Files: `*.Tests.ps1` — match existing convention (co-located vs `Tests/` directory) + +## Pester v5 Discovery vs Run (Critical) + +Pester v5 runs in **two phases**: Discovery (collects test metadata) then Run (executes tests). This is the #1 source of agent errors. + +**Rules:** +- All setup code goes in `BeforeAll` or `BeforeEach` — never at script top level or loose inside `Describe`/`Context` +- Code directly inside `Describe`/`Context` (but outside `It`/`Before*`/`After*`) runs during **Discovery** — do not put setup, imports, or variable assignments there +- Data for `-ForEach` / `-TestCases` must be set in `BeforeDiscovery`, not `BeforeAll` (BeforeAll runs after discovery) +- `-Skip:$condition` evaluates at Discovery time — conditions from `BeforeAll` will be `$null` +- Use `foreach` loops for dynamic test generation only with `BeforeDiscovery` data +- Use `TestDrive:` for file-based tests instead of touching repo files — Pester cleans it up automatically + +## Common Errors + +| Error | Fix | +|-------|-----| +| Variable is `$null` in `It` block | Move assignment into `BeforeAll` — variables set there are visible to child `It` blocks without `$script:` | +| `-ForEach` data is empty | Move data setup from `BeforeAll` to `BeforeDiscovery` | +| `CommandNotFoundException` for Mock target | The function must exist before mocking — import the module in `BeforeAll` first | +| `$MyInvocation.MyCommand.Path` returns empty | Use `$PSCommandPath` or `$PSScriptRoot` instead | +| `Should Be` (no dash) fails | Use v5 syntax: `Should -Be` (with dash prefix) | +| `Assert-MockCalled` not recognized | Use v5 syntax: `Should -Invoke` | +| Mock has no effect | Check scope — mocks in `It` only apply to that `It`; use `BeforeAll`/`BeforeEach` for broader scope | +| `Should -Throw` doesn't catch cmdlet errors | Most cmdlet errors are non-terminating — wrap with `{ cmd -ErrorAction Stop }` or set `$ErrorActionPreference = 'Stop'` in `BeforeEach` | +| Tests pass on Windows but fail on Linux | Use `Join-Path` not string concatenation; match exact file casing; avoid Windows-only cmdlets (Registry, EventLog) | + +## Mocking Rules + +- Place mocks in `BeforeAll` (shared) or `BeforeEach` (reset per test) +- Mock where the command is **called from** — use `-ModuleName` to mock inside a module's scope +- Use `-ParameterFilter` for selective mocking (no `param()` block needed in v5) +- Verify calls with `Should -Invoke` — default scope inside `It` counts only that test's calls +- Use `InModuleScope` sparingly and as narrowly as possible — prefer `Mock -ModuleName` for testing via public API +- Inside mock bodies, use `$PesterBoundParameters` not `$PSBoundParameters` +- If a test needs more than 3 mocks, flag it as a design smell + +## Non-Obvious Assertions + +Most `Should` operators are self-explanatory. These are the ones agents get wrong: + +- `Should -Throw` requires a **scriptblock**: `{ risky-op } | Should -Throw` — not a direct call +- `Should -Contain` is for **collections** — use `Should -Be` for scalar equality +- `Should -HaveParameter` validates cmdlet signatures: `Get-Command X | Should -HaveParameter 'Name' -Mandatory` +- `Should -Invoke` verifies mock calls: `Should -Invoke Get-Item -Times 1 -Exactly` + +## Cross-Platform + +- Prefer `pwsh` (PowerShell 7+) unless the repo explicitly targets Windows PowerShell 5.1 +- Use `Join-Path` for paths — never string concatenation with `\` +- Linux/macOS file systems are **case-sensitive** — match exact casing in imports and paths +- Windows ships Pester 3.4.0 — if v5 is needed: `Install-Module Pester -Force -SkipPublisherCheck` +- Check `$PSVersionTable.PSEdition` to detect Core vs Desktop + +## Skip Coverage Tools + +Do not configure or run coverage tools (Pester CodeCoverage, JaCoCo export). Coverage is measured separately by the evaluation harness. diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/python.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/python.md new file mode 100644 index 0000000..6584e52 --- /dev/null +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/python.md @@ -0,0 +1,132 @@ +# Python Extension + +Language-specific guidance for Python test generation. + +## Rule #1: Investigate the Repo First + +Before writing any test or running any command, discover what the repo already does: + +1. **Find ALL existing test files** — search broadly: `test_*.py`, `*_test.py`, `*.uts`, `test/*.sh`, or any other test format. Do not assume pytest. +2. **Identify the test framework** — look for: + - Custom test runners (e.g. `UTscapy` for scapy, project-specific harnesses) + - Standard frameworks (`pytest`, `unittest`, `nose2`) + - Test runner scripts in `Makefile`, `tox.ini`, `nox`, `scripts/` + - Config entries in `pyproject.toml`, `setup.cfg`, `pytest.ini`, `conftest.py` +3. **Read existing tests thoroughly** — copy their exact style: file format, imports, fixtures, assertion patterns, helper utilities, setup/teardown conventions +4. **Package layout** — determine import paths from existing code, not guesswork + +**Use whatever framework and conventions the repo already uses.** If the repo uses a custom test framework (custom file formats, custom runners, domain-specific test utilities), adopt it fully — do not layer pytest on top. Only introduce pytest if the repo has no tests at all. + +## Environment Detection + +Detect the runner from lockfiles/config and prefix all commands accordingly: + +| Indicator | Prefix | +|-----------|--------| +| `poetry.lock` / `[tool.poetry]` in `pyproject.toml` | `poetry run` | +| `pdm.lock` / `[tool.pdm]` in `pyproject.toml` | `pdm run` | +| `uv.lock` / `[tool.uv]` in `pyproject.toml` | `uv run` | +| `Pipfile.lock` | `pipenv run` | +| `hatch.toml` / `[tool.hatch]` in `pyproject.toml` | `hatch run` | +| None of the above | `python -m` | + +If `Makefile`, `tox.ini`, or `nox` config exists, prefer those scripts over raw commands. + +## Build Commands + +Python has no separate build step. Validate with the type checker if one is configured: + +| Scope | Command | +|-------|---------| +| Syntax check | ` py_compile path/to/file.py` | +| Type check | ` mypy path/to/file.py` or ` pyright path/to/file.py` | + +## Test Commands + +If the repo uses a **custom test framework** (custom file formats, custom runner), use its native commands — do not wrap them in pytest. Examples: + +| Framework | Command | +|-----------|---------| +| UTscapy (`.uts` files) | ` scapy.tools.UTscapy -f test/test_file.uts` | +| Custom runner script | `make test`, `./run_tests.sh`, `tox` | +| Repo-defined script | Whatever `scripts.test` in Makefile/tox/nox specifies | + +For **pytest** projects (the most common case), use the detected ``: + +| Scope | Command | +|-------|---------| +| All tests | ` pytest` | +| Specific file | ` pytest tests/test_module.py` | +| Specific test | ` pytest tests/test_module.py::TestClass::test_method` | +| Keyword filter | ` pytest -k "keyword"` | +| Stop on first failure | ` pytest -x --tb=short` | + +- Prefer `python -m pytest` over bare `pytest` to ensure the correct interpreter +- If the project uses `unittest` only (no pytest in deps), use `python -m unittest discover` + +## Lint Command + +Use the repo's existing lint script first (`make lint`, `tox -e lint`). Otherwise detect tools from config: + +- `ruff.toml` or `[tool.ruff]` → ` ruff check --fix && ruff format` +- `[tool.black]` → ` black` +- `.flake8` → ` flake8` + +## Project Layout and Imports + +| Layout | Import Style | +|--------|-------------| +| `src/package/module.py` | `from package.module import X` | +| `package/module.py` at root | `from package.module import X` | +| `module.py` at root | `from module import X` | + +- **Match existing test imports exactly** — do not invent `src.` prefixes unless existing tests use them +- Check `pyproject.toml` `[tool.setuptools.package-dir]` for layout hints +- Default test placement: `tests/` mirroring source structure (`src/billing/service.py` → `tests/billing/test_service.py`) + +## Test File Naming + +Match the repo's existing conventions. Common patterns: + +- **pytest**: Files `test_*.py` or `*_test.py`, functions `test_` prefix, classes `Test` prefix +- **Custom frameworks**: Use whatever format existing tests use (e.g. `.uts` for UTscapy, custom extensions) + +If writing new tests in a repo with no tests, default to pytest conventions. + +## Common Errors + +| Error | Fix | +|-------|-----| +| `ModuleNotFoundError: No module named 'src'` | Import from the package name used by the repo, not from `src` | +| `ModuleNotFoundError: No module named 'X'` | Check existing imports for the correct package name; if editable install needed: ` pip install -e .` | +| `ImportError: attempted relative import` | Convert to absolute imports matching existing test patterns | +| `fixture 'X' not found` | Check `conftest.py` for existing fixtures; reuse them instead of creating new ones | +| `TypeError: missing required argument` | Read the full `__init__`/function signature; pass all required parameters | +| `async def functions are not natively supported` | Use `@pytest.mark.asyncio` only if `pytest-asyncio` is already in deps; check for `asyncio_mode = "auto"` in config | +| `SyntaxError` | Fix syntax at the indicated line | + +## Mocking Rules + +- Use `unittest.mock` (stdlib) — no extra dependency needed +- **Patch where the name is looked up**, not where it is defined: `@patch("mypackage.module.datetime")` not `@patch("datetime.datetime")` +- Use `Mock(spec=RealClass)` to catch attribute errors +- Use `AsyncMock` for async functions +- Prefer dependency injection over `@patch` +- If a test needs more than 3 mocks, flag it as a design smell + +## Dependency Installation (Last Resort) + +Only install packages after investigation confirms they are missing. Use the detected prefix: + +| Manager | Install command | +|---------|----------------| +| Poetry | `poetry add --group dev pytest` | +| PDM | `pdm add -dG test pytest` | +| uv | `uv add --dev pytest` | +| pip | `python -m pip install -e ".[dev]"` | + +Never run bare `pip install` in a Poetry/PDM/uv project — it bypasses the lockfile. + +## Skip Coverage Tools + +Do not configure or run coverage tools (coverage.py, pytest-cov). Coverage is measured separately by the evaluation harness. diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/typescript.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/typescript.md new file mode 100644 index 0000000..de26a70 --- /dev/null +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/code-testing-extensions/extensions/typescript.md @@ -0,0 +1,136 @@ +# TypeScript Extension + +Language-specific guidance for TypeScript (and JavaScript) test generation. + +## Rule #1: Investigate the Repo First + +Before writing any test or running any command, read: + +1. **Existing tests** — find `*.test.ts` / `*.spec.ts` files and copy their style (imports, describe/it vs test, assertion patterns, mock approach) +2. **`package.json`** — `scripts.test`, `devDependencies`, `type` field +3. **Config files** — `tsconfig.json`, `jest.config.*`, `vitest.config.*`, `eslint.config.*` + +Use the repo's existing test runner and conventions — do not switch frameworks. If multiple runners are configured, follow whichever `scripts.test` invokes. Only introduce a framework if the repo has no tests at all. + +## Package Manager Detection + +Detect the package manager from lockfiles and use it consistently for **all** commands: + +| Indicator | Manager | Run script | Execute binary | +|-----------|---------|------------|----------------| +| `pnpm-lock.yaml` | pnpm | `pnpm test` | `pnpm exec ` | +| `yarn.lock` | Yarn | `yarn test` | `yarn ` | +| `bun.lockb` / `bun.lock` | Bun | `bun test` | `bunx ` | +| `package-lock.json` or none | npm | `npm test` | `npx ` | + +Use `` below as shorthand for the detected exec command. + +## Build Commands + +| Scope | Command | +|-------|---------| +| Type check | ` tsc --noEmit` or the repo's `typecheck` script | +| Build (if configured) | The repo's `build` script | + +Many projects don't need an explicit build step — the test runner handles transpilation. + +## Test Commands + +Detect the runner from `devDependencies` and `scripts.test`. Always prefer the repo's test script first. + +| Runner | Run once | Filter by file | Filter by name | +|--------|----------|----------------|----------------| +| **Jest** | ` jest` | ` jest path/to/file` | ` jest -t "name"` | +| **Vitest** | ` vitest run` | ` vitest run path/to/file` | ` vitest run -t "name"` | +| **Mocha** | ` mocha` | (use config or positional args) | ` mocha --grep "name"` | + +- **Always use `vitest run`** (not bare `vitest`) — bare `vitest` starts watch mode +- **Never use `--watch`** — the agent must not start interactive/watch mode +- For Jest: `--bail` to stop on first failure, `--verbose` for detail +- Mocha `--grep` filters by **test name**, not file path + +## Lint Command + +Use the repo's lint script first. Otherwise detect from `devDependencies` and config: + +- `eslint.config.*` or `.eslintrc.*` → ` eslint --fix path/to/file.ts` +- `prettier` → ` prettier --write path/to/file.ts` +- `biome.json` → ` biome check --write path/to/file.ts` + +## Project Layout and Imports + +| Layout | Import Style | +|--------|-------------| +| Colocated (`src/module.test.ts`) | `import { X } from './module'` | +| `__tests__/` dir | `import { X } from '../module'` | +| Top-level `tests/` | `import { X } from '../src/module'` | + +- **Match existing test imports** — copy path style from neighboring tests +- If `tsconfig.json` has `paths` aliases (e.g., `@/`), use them in tests too +- For monorepos: import from the package name, not relative cross-package paths +- For monorepo workspaces (Nx, Turborepo, Lerna): run tests via the workspace tool (`nx test `, `turbo test`), not from a random package directory + +## Test File Naming + +- Match existing convention — check for `.test.ts` vs `.spec.ts` +- Jest/Vitest default: `*.test.ts`, `*.spec.ts`, or files inside `__tests__/` +- Place test files to mirror the existing project pattern + +## Common Errors + +| Error | Fix | +|-------|-----| +| `Cannot find module 'X'` | Check existing imports for correct paths; verify `tsconfig.json` `paths`; check `moduleNameMapper` (Jest) or `resolve.alias` (Vitest) | +| `TS2305: has no exported member` | Verify the exact export name from the source file | +| `TS2345: type not assignable` | Match the expected type; use type assertion only for mock objects | +| `SyntaxError: Unexpected token` / `Jest encountered an unexpected token` | Verify TS transform config (`ts-jest`, `@swc/jest`, or Vitest handles natively) | +| `ReferenceError: describe is not defined` | Vitest: import from `vitest` or set `globals: true` in config; Jest: ensure tests run under Jest not bare `node` | +| `Cannot use import statement outside a module` / `ERR_REQUIRE_ESM` | ESM/CJS mismatch — align runner config with the project's module system (see ESM section); do **not** blindly set `"type": "module"` | +| `ReferenceError: document is not defined` | Set test environment: `testEnvironment: 'jsdom'` (Jest) or `environment: 'jsdom'` (Vitest) | +| `jest.mock() ... out-of-scope variables` | Keep `jest.mock()` at top level; don't reference variables declared after the mock call (Jest hoists mocks) | +| `Cannot find module '@/...'` | Mirror the project's alias config in the test runner's module resolution | +| `Warning: not wrapped in act(...)` | Await async UI updates using the repo's existing pattern (`waitFor`, `act`) | + +## ESM vs CommonJS + +Check these signals to determine the project's module system: + +- `"type": "module"` in `package.json` → ESM +- `"module": "ESNext"` or `"NodeNext"` in `tsconfig.json` → ESM output (but not sufficient alone) +- `.mjs`/`.mts` extensions → ESM files + +If the test runner fails with ESM errors, align the runner's config with the project's module system. **Do not change `package.json` `type` field** — align the test runner to match whatever the project uses: + +- **Jest**: `--experimental-vm-modules` + `ts-jest` with `useESM: true`, or `@swc/jest` +- **Vitest**: handles ESM natively +- **Mocha**: `--loader ts-node/esm` + +## Mocking Rules + +- Prefer dependency injection over module mocking +- Use typed mocks: `jest.Mocked`, `vi.mocked(obj)`, or `Partial` with `as T` +- Jest: `jest.mock()` is hoisted — keep at top level, don't close over local variables +- Vitest: `vi.mock()` follows the same hoisting rules +- If a test needs more than 3–4 mocks, flag it as a design smell +- Mock reset: rely on `clearMocks`/`restoreMocks` config if present; otherwise reset in `beforeEach` + +## Framework-Specific Notes + +- **React/Preact**: use `@testing-library/react`, wrap with necessary providers (router, query client, theme) matching existing test setup +- **Express/Koa**: use `supertest` for HTTP testing if the repo already uses it +- **NestJS**: build testing module with `Test.createTestingModule` — don't instantiate controllers directly + +## Dependency Installation (Last Resort) + +Only install packages after investigation confirms they are missing. Use the detected package manager: + +``` + add --save-dev jest ts-jest @types/jest + add --save-dev vitest +``` + +Never install test infrastructure that conflicts with what the repo already uses. + +## Skip Coverage Tools + +Do not configure or run coverage tools (istanbul, c8, `vitest --coverage`). Coverage is measured separately by the evaluation harness. diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/detect-static-dependencies/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/detect-static-dependencies/SKILL.md index 385b612..46bda03 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/detect-static-dependencies/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/detect-static-dependencies/SKILL.md @@ -24,6 +24,12 @@ Scan a C# codebase for calls to hard-to-test static APIs and produce a ranked re - Prioritizing which statics to wrap first (highest-frequency wins) - Creating a migration plan for incremental testability improvements +## Response Guidelines + +- Scale the response to the user's request. A question about a specific category (e.g., "find time statics") should focus on that category with file locations and counts, not produce a full report across all categories. +- When the user provides a specific file or directory path, scan only that scope — do not expand to the entire solution unless asked. +- The full structured report format in Step 4 is for comprehensive audit requests. For focused questions, return only the relevant subset (e.g., category summary + affected files for the requested category). + ## When Not to Use - The user wants wrappers generated (hand off to `generate-testability-wrappers`) diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-mstest-v1v2-to-v3/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-mstest-v1v2-to-v3/SKILL.md index 5667a0f..ca7fea4 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-mstest-v1v2-to-v3/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-mstest-v1v2-to-v3/SKILL.md @@ -34,7 +34,7 @@ Migrate a test project from MSTest v1 (assembly references) or MSTest v2 (NuGet ## When Not to Use -- Project already uses MSTest v3 (3.x packages) +- Project already on MSTest v3 with no migration-related build errors (fully migrated) - Upgrading v3 to v4 -- use `migrate-mstest-v3-to-v4` - Migrating between frameworks (MSTest to xUnit/NUnit) diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-vstest-to-mtp/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-vstest-to-mtp/SKILL.md index 1e002d3..d1d8928 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-vstest-to-mtp/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-vstest-to-mtp/SKILL.md @@ -4,17 +4,17 @@ description: > Migrates .NET test projects from VSTest to Microsoft.Testing.Platform (MTP). Use when user asks to "migrate to MTP", "switch from VSTest", "enable Microsoft.Testing.Platform", "use MTP runner", or mentions EnableMSTestRunner, - EnableNUnitRunner, UseMicrosoftTestingPlatformRunner, dotnet test exit - code 8, zero tests discovered, or MTP behavioral differences - (--ignore-exit-code, TESTINGPLATFORM_EXITCODE_IGNORE). + EnableNUnitRunner, or UseMicrosoftTestingPlatformRunner. + USE FOR: MTP behavioral differences vs VSTest (exit code 8, zero tests + discovered), --ignore-exit-code, TESTINGPLATFORM_EXITCODE_IGNORE. Supports MSTest, NUnit, xUnit.net v2 (via YTest.MTP.XUnit2), and xUnit.net v3 (native MTP). Covers runner enablement, CLI argument - translation, xUnit.net v3 filter syntax, Directory.Build.props and - global.json configuration, CI/CD pipeline updates, and MTP extension - packages. DO NOT USE FOR: migrating between test frameworks - (MSTest/xUnit/NUnit), xUnit.net v2 to v3 API migration, MSTest version - upgrades (use migrate-mstest-* skills), TFM upgrades, or UWP/WinUI test - projects. + translation, xUnit.net v3 filter migration (--filter-class, + --filter-trait, --filter-query), Directory.Build.props and global.json + configuration, CI/CD pipeline updates, and MTP extension packages. + DO NOT USE FOR: migrating between test frameworks (MSTest/xUnit/NUnit), + xUnit.net v2 to v3 API migration, MSTest version upgrades (use + migrate-mstest-* skills), TFM upgrades, or UWP/WinUI test projects. license: MIT --- @@ -35,7 +35,7 @@ Migrate a .NET test solution from VSTest to Microsoft.Testing.Platform (MTP). Th ## When Not to Use -- The project already runs on Microsoft.Testing.Platform -- migration is done +- The project already runs on Microsoft.Testing.Platform and there is no remaining MTP behavioral difference to resolve (e.g., exit code 8 for zero tests discovered) - Migrating between test frameworks (e.g., MSTest to xUnit.net) -- different effort entirely - The project builds UWP or packaged WinUI test projects -- MTP does not support these yet - The solution mixes .NET and non-.NET test adapters (e.g., JavaScript or C++ adapters) -- VSTest is required @@ -208,7 +208,44 @@ VSTest-specific arguments must be translated to MTP equivalents. Build-related a **MSTest, NUnit, and xUnit.net v2 (with `YTest.MTP.XUnit2`)**: The VSTest `--filter` syntax is identical on both VSTest and MTP. No changes needed. -**xUnit.net v3 (native MTP)**: xUnit.net v3 does NOT support the VSTest `--filter` syntax on MTP. See the **VSTest → MTP filter translation** section in the `filter-syntax` skill for the complete translation table. Key translation example: +**xUnit.net v3 (native MTP)**: xUnit.net v3 does NOT support the VSTest `--filter` syntax on MTP. You must translate filters to xUnit.net v3's native filter options. + +#### xUnit.net v3 filter flags + +| Flag | Description | +|------|-------------| +| `--filter-class "name"` | Run all tests in a given class. Supports wildcards (`*`). | +| `--filter-not-class "name"` | Exclude all tests in a given class | +| `--filter-method "name"` | Run a specific test method | +| `--filter-not-method "name"` | Exclude a specific test method | +| `--filter-namespace "name"` | Run all tests in a namespace | +| `--filter-not-namespace "name"` | Exclude all tests in a namespace | +| `--filter-trait "name=value"` | Run tests with a matching trait | +| `--filter-not-trait "name=value"` | Exclude tests with a matching trait | + +Multiple values can be specified with a single flag: `--filter-class Foo Bar`. + +#### VSTest → xUnit.net v3 filter translation table + +| VSTest `--filter` syntax | xUnit.net v3 MTP equivalent | Notes | +|---|---|---| +| `FullyQualifiedName~ClassName` | `--filter-class *ClassName*` | Wildcards required for substring match | +| `FullyQualifiedName=Ns.Class.Method` | `--filter-method Ns.Class.Method` | Exact match on fully qualified method | +| `Name=MethodName` | `--filter-method *MethodName*` | Wildcards for substring match | +| `Category=Value` (trait) | `--filter-trait "Category=Value"` | Filter by trait name/value pair | +| Complex expressions | `--filter-query "expr"` | Uses xUnit.net query filter language (see below) | + +#### xUnit.net v3 query filter language + +For complex expressions, use `--filter-query` with a path-segment syntax: + +```text +////[traitName=traitValue] +``` + +Each segment matches against: assembly name, namespace, class name, method name. Use `*` for "match all" in any segment. Documentation: + +#### Translation example ```shell # VSTest diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-xunit-to-xunit-v3/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-xunit-to-xunit-v3/SKILL.md index 6d2beb6..759ce64 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-xunit-to-xunit-v3/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/migrate-xunit-to-xunit-v3/SKILL.md @@ -5,7 +5,9 @@ description: > USE FOR: upgrading xunit to xunit.v3. DO NOT USE FOR: migrating between test frameworks (MSTest/NUnit to xUnit.net), migrating from VSTest to Microsoft.Testing.Platform - (use migrate-vstest-to-mtp). + (use migrate-vstest-to-mtp). For xUnit v3 MTP filter syntax + (--filter-class, --filter-trait, --filter-query), also load + migrate-vstest-to-mtp. license: MIT --- @@ -34,7 +36,9 @@ Migrate .NET test projects from xUnit.net v2 to xUnit.net v3. The outcome is a s > **Commit strategy:** Commit after each major step so the migration is reviewable and bisectable. Separate project file changes from code changes. -### Step 1: Identify xUnit.net projects +> **Prioritization:** Steps 1-5 are required for every migration. Steps 6-12 are conditional — only apply the ones relevant to the project's code patterns. Skip steps that don't apply. + +### Step 1: Identify xUnit.net projects and verify compatibility Search for test projects referencing xUnit.net v2 packages: @@ -48,20 +52,9 @@ Search for test projects referencing xUnit.net v2 packages: Make sure to check the package references in project files, MSBuild props and targets files, like `Directory.Build.props`, `Directory.Build.targets`, and `Directory.Packages.props`. -### Step 2: Verify compatibility - -1. Verify target framework compatibility: xUnit.net v3 requires **.NET 8+** or **.NET Framework 4.7.2+**. For test library projects, .NET Standard 2.0 is also supported. -2. If any of the test projects have non-compatible target frameworks, STOP here and DON'T do anything. Only tell the user to upgrade the target framework first before migrating xUnit.net. -3. Verify project compatibility: xUnit.net v3 only supports SDK-style projects. If any test projects are non-SDK-style, STOP here and DON'T do anything. Only tell the user to migrate to SDK-style projects first before migrating xUnit.net. - -### Step 3: Establish a baseline - -Run `dotnet test` to establish a baseline of test pass/fail counts. When running `dotnet test`, ensure that: - -- You run `dotnet test` without any additional arguments (i.e., don't pass `--no-restore` or `--no-build`). -- Ensure you redirect the command output to a file and read the output from that file. +Verify target framework compatibility: xUnit.net v3 requires **.NET 8+** or **.NET Framework 4.7.2+**. For test library projects, .NET Standard 2.0 is also supported. If any test projects have non-compatible target frameworks, STOP here — tell the user to upgrade the target framework first. Also verify the project uses SDK-style format. -### Step 4: Update package references +### Step 2: Update package references 1. Update any `PackageReference` or `PackageVersion` items for the new package names, based on the following mapping: @@ -73,7 +66,7 @@ Run `dotnet test` to establish a baseline of test pass/fail counts. When running 2. Update all `xunit.v3.*` packages to the latest correct version available on NuGet. Also update `xunit.runner.visualstudio` to the latest version. -### Step 5: Set `OutputType` to `Exe` +### Step 3: Set `OutputType` to `Exe` In each test project (excluding test library projects), set `OutputType` to `Exe` in the project file: @@ -89,15 +82,25 @@ Depending on the solution in hand, there might be a centralized place where this - If all test projects share a name pattern (e.g., `*.Tests.csproj`), add a conditional property group in `Directory.Build.props` that applies only to those projects, like `Exe`. Adjust the condition as needed to target only test projects. - Otherwise, add the `Exe` property to each test project file individually. -### Step 6: Remove `Xunit.Abstractions` usings +### Step 4: Configure test platform + +Preserve the same test platform that was used with xUnit.net v2. xUnit.net v2 always uses VSTest except if the project used `YTest.MTP.XUnit2`. + +- If the project had a reference to `YTest.MTP.XUnit2`: + - Remove the reference to `YTest.MTP.XUnit2` completely. + - Add `true` to `Directory.Build.props` under an unconditional `PropertyGroup`. +- If the project did NOT reference `YTest.MTP.XUnit2` (the common case): + - Add `false` to `Directory.Build.props` under an unconditional `PropertyGroup`. If `Directory.Build.props` doesn't exist, create it. This keeps the project on VSTest. + +### Step 5: Remove `Xunit.Abstractions` usings Find any `using Xunit.Abstractions;` directives in C# files and remove them completely. -### Step 7: Address `async void` breaking change +### Step 6: Address `async void` breaking change (if applicable) In xUnit.net v3, `async void` test methods are no longer supported and will fail to compile. Search for any test methods declared with `async void` and change them to `async Task`. Test methods can be identified via the `[Fact]` or `[Theory]` attributes or other test attributes. -### Step 8: Address breaking change of attributes +### Step 7: Address breaking change of attributes (if applicable) In xUnit.net v3, some attributes were updated so that they accept a `System.Type` instead of two strings (fully qualified type name and assembly name). These attributes are: @@ -108,7 +111,7 @@ In xUnit.net v3, some attributes were updated so that they accept a `System.Type For example, `[assembly: CollectionBehavior("MyNamespace.MyCollectionFactory", "MyAssembly")]` must be converted to `[assembly: CollectionBehavior(typeof(MyNamespace.MyCollectionFactory))]`. -### Step 9: Inheriting from FactAttribute or TheoryAttribute +### Step 8: Inheriting from FactAttribute or TheoryAttribute (if applicable) Identify if there are any custom attributes that inherit from `FactAttribute` or `TheoryAttribute`. These custom user-defined attributes must now provide source information. For example, if the attribute looked like this: @@ -135,7 +138,7 @@ internal sealed class MyFactAttribute : FactAttribute } ``` -### Step 10: Inheriting from BeforeAfterTestAttribute +### Step 9: Inheriting from BeforeAfterTestAttribute (if applicable) Identify if there are any custom attributes that inherit from `BeforeAfterTestAttribute`. These custom user-defined attributes must update their method signatures. Previously, they would have `Before`/`After` overrides that look like this: @@ -173,25 +176,11 @@ it must be changed to this: } ``` -### Step 11: Address new xUnit analyzer warnings - -xunit.v3 introduced new analyzer warnings. You should attempt to address them. +### Step 10: Address new xUnit analyzer warnings (if applicable) -One of the most notable warnings is [xUnit1051: Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken](https://xunit.net/xunit.analyzers/rules/xUnit1051). Identify the calls to such methods, if any, and pass the cancellation token. +xunit.v3 introduced new analyzer warnings. The most notable is xUnit1051 (use `TestContext.Current.CancellationToken` for methods accepting `CancellationToken`). Address these if present. -### Step 12: Test platform selection - -You should keep the same test platform that was used with xunit 2. - -Note that xunit 2 is always VSTest except if the user used YTest.MTP.XUnit2. - -- If user had a reference to YTest.MTP.XUnit2: - - Remove the reference to YTest.MTP.XUnit2 completely. - - Add `true` to Directory.Build.props under an unconditional PropertyGroup. -- If user didn't have a reference to YTest.MTP.XUnit2: - - Add `false` to Directory.Build.props under an unconditional PropertyGroup. - -### Step 13: Migrate `Xunit.SkippableFact` +### Step 11: Migrate `Xunit.SkippableFact` (if applicable) If there are any package references to `Xunit.SkippableFact`, remove all these package references entirely. @@ -202,19 +191,11 @@ Then, follow these steps to eliminate usages of APIs coming from the removed pac - Change `Skip.If` method calls to `Assert.SkipWhen`. - Change `Skip.IfNot` method calls to `Assert.SkipUnless`. -### Step 14: Update `Xunit.Combinatorial` NuGet package - -Find package references of `Xunit.Combinatorial` and update them from 1.x to the latest 2.x version available. - -### Step 15: Update `Xunit.StaFact` NuGet package - -Find package references of `Xunit.StaFact` and update them from 1.x to the latest 3.x version available. - -### Step 16: Build the solution +### Step 12: Update companion packages (if applicable) -Now, build the solution to identify any remaining compilation errors that might not have been addressed by previous instructions. -Fix any straightforward errors that show up, and keep iterating and fixing more. +- `Xunit.Combinatorial` 1.x → latest 2.x +- `Xunit.StaFact` 1.x → latest 3.x -You can also look into and to help with the remaining compilation errors. +### Step 13: Build and verify -You can fix as much as you can, and it's okay if not everything is fixed. Just tell the user that there are remaining errors that need to be manually addressed. +Build the solution and fix any remaining compilation errors. Run `dotnet test` to verify all tests pass with the same results as before migration. diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-anti-patterns/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-anti-patterns/SKILL.md index 6cbbbfa..44a0886 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-anti-patterns/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-anti-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: test-anti-patterns -description: "Quick pragmatic detection-focused review of .NET test code for anti-patterns that undermine reliability and diagnostic value. Use when asked to audit test quality, investigate flaky or coupled tests, find duplication or magic values, or when tests pass but don't actually verify anything. Best for identifying and prioritizing issues in existing tests with severity-ranked findings and targeted remediation guidance. Catches assertion gaps, swallowed exceptions, always-true assertions, flakiness indicators, test coupling, over-mocking, naming issues, magic values, duplicate tests, and structural problems. Do NOT use for direct MSTest API rewrites or implementation-only fixes (for example swapped Assert.AreEqual argument order or converting `DynamicData` from `IEnumerable` to `ValueTuple`) — use writing-mstest-tests instead. For a deep formal audit based on academic test smell taxonomy, use exp-test-smell-detection instead. Works with MSTest, xUnit, NUnit, and TUnit." +description: "Quick pragmatic detection-focused review of .NET test code for anti-patterns that undermine reliability and diagnostic value. Use when asked to audit test quality, investigate flaky or coupled tests, find duplication or magic values, or when tests pass but don't actually verify anything. Best for identifying and prioritizing issues in existing tests with severity-ranked findings and targeted remediation guidance. Catches assertion gaps, swallowed exceptions, always-true assertions, flakiness indicators, test coupling, over-mocking, naming issues, magic values, duplicate tests, and structural problems. Do NOT use for direct MSTest API rewrites or implementation-only fixes (for example swapped Assert.AreEqual argument order or converting `DynamicData` from `IEnumerable` to `ValueTuple`) — use writing-mstest-tests instead. For a deep formal audit based on academic test smell taxonomy, use test-smell-detection instead. Works with MSTest, xUnit, NUnit, and TUnit." license: MIT --- @@ -25,7 +25,7 @@ Quick, pragmatic analysis of .NET test code for anti-patterns and quality issues - User wants to run or execute tests (use `run-tests`) - User wants to migrate between test frameworks or versions (use migration skills) - User wants to measure code coverage (out of scope) -- User wants a deep formal test smell audit with academic taxonomy and extended catalog (use `exp-test-smell-detection`) +- User wants a deep formal test smell audit with academic taxonomy and extended catalog (use `test-smell-detection`) ## Inputs diff --git a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-gap-analysis/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-gap-analysis/SKILL.md similarity index 98% rename from external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-gap-analysis/SKILL.md rename to external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-gap-analysis/SKILL.md index b5580be..734b1e3 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-gap-analysis/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-gap-analysis/SKILL.md @@ -1,6 +1,6 @@ --- -name: exp-test-gap-analysis -description: "Performs pseudo-mutation analysis on .NET production code to find gaps in existing test suites. Use when the user asks to find weak tests, discover untested edge cases, check if tests would catch a bug, or evaluate test effectiveness through mutation-style reasoning. Analyzes production code for mutation points (boundary conditions, boolean flips, null returns, exception removal, arithmetic changes) and checks whether existing tests would detect each mutation. Works with MSTest, xUnit, NUnit, and TUnit. DO NOT USE FOR: writing new tests (use writing-mstest-tests), detecting test anti-patterns (use test-anti-patterns), measuring assertion diversity (use exp-assertion-quality), or running actual mutation testing tools." +name: test-gap-analysis +description: "Performs pseudo-mutation analysis on .NET production code to find gaps in existing test suites. Use when the user asks to find weak tests, discover untested edge cases, check if tests would catch a bug, or evaluate test effectiveness through mutation-style reasoning. Analyzes production code for mutation points (boundary conditions, boolean flips, null returns, exception removal, arithmetic changes) and checks whether existing tests would detect each mutation. Works with MSTest, xUnit, NUnit, and TUnit. DO NOT USE FOR: writing new tests (use writing-mstest-tests), detecting test anti-patterns (use test-anti-patterns), measuring assertion diversity (use assertion-quality), or running actual mutation testing tools." license: MIT --- @@ -35,7 +35,7 @@ This skill performs **static pseudo-mutation** — reasoning about mutations wit - User wants to write new tests from scratch (use `writing-mstest-tests`) - User wants to detect test anti-patterns like flakiness or poor naming (use `test-anti-patterns`) -- User wants to measure assertion variety (use `exp-assertion-quality`) +- User wants to measure assertion variety (use `assertion-quality`) - User wants to run an actual mutation testing framework like Stryker (help them directly) - User only wants code coverage numbers (out of scope) diff --git a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-smell-detection/SKILL.md similarity index 94% rename from catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/SKILL.md rename to external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-smell-detection/SKILL.md index 546cd98..148f85f 100644 --- a/catalog/Platform/Official-DotNet-Experimental/skills/exp-test-smell-detection/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-smell-detection/SKILL.md @@ -1,6 +1,6 @@ --- -name: exp-test-smell-detection -description: "Deep formal test smell audit based on academic research taxonomy (testsmells.org). Detects 19 categorized smell types — conditional logic, mystery guests, sensitive equality, eager tests, and more — with calibrated severity and research-backed remediation. Use for comprehensive test suite health assessments. For a quick pragmatic review, use test-anti-patterns instead. DO NOT USE FOR: writing new tests (use writing-mstest-tests), evaluating assertion quality specifically (use exp-assertion-quality), or finding test duplication and boilerplate (use exp-test-maintainability)." +name: test-smell-detection +description: "Deep formal test smell audit based on academic research taxonomy (testsmells.org). Detects 19 categorized smell types — conditional logic, mystery guests, sensitive equality, eager tests, and more — with calibrated severity and research-backed remediation. Use for comprehensive test suite health assessments. For a quick pragmatic review, use test-anti-patterns instead. DO NOT USE FOR: writing new tests (use writing-mstest-tests), evaluating assertion quality specifically (use assertion-quality), or finding test duplication and boilerplate (use exp-test-maintainability)." license: MIT --- @@ -34,7 +34,7 @@ Test smells erode confidence in a test suite and inflate maintenance costs: ## When Not to Use - User wants a quick pragmatic test review (use `test-anti-patterns` — faster, covers the most common issues) -- User wants to evaluate assertion diversity specifically (use `exp-assertion-quality`) +- User wants to evaluate assertion diversity specifically (use `assertion-quality`) - User wants to find duplicated boilerplate across tests (use `exp-test-maintainability`) - User wants to write new tests from scratch (help them directly) - User wants to fix a specific failing test (diagnose and fix directly) @@ -50,7 +50,7 @@ Test smells erode confidence in a test suite and inflate maintenance costs: ### Step 1: Gather the test code -Read all test files the user provides. If the user points to a directory or project, scan for all test files by looking for test framework markers — see the `exp-dotnet-test-frameworks` skill for .NET-specific markers. +Read all test files the user provides. If the user points to a directory or project, scan for all test files by looking for test framework markers — see the `dotnet-test-frameworks` skill for .NET-specific markers. For a thorough audit, also consult the [extended smell catalog](references/test-smell-catalog.md) which covers 9 additional smell types beyond the core 10 below. @@ -79,7 +79,7 @@ Tests that depend on external resources — files on disk, databases, network en Tests that call sleep or delay functions to wait for a condition. These introduce non-deterministic timing and slow down the suite. **Severity:** High -**Detection:** Calls to sleep/delay functions inside test methods. See the `exp-dotnet-test-frameworks` skill for .NET-specific patterns. +**Detection:** Calls to sleep/delay functions inside test methods. See the `dotnet-test-frameworks` skill for .NET-specific patterns. #### Smell 4: Assertion-Free Test (Unknown Test) @@ -132,7 +132,7 @@ The test setup method or constructor initializes fields that are not used by eve Tests marked as skipped or disabled. These add overhead and clutter, and the underlying issue they were disabled for may never be addressed. **Severity:** Low -**Detection:** Skip/ignore annotations or conditional compilation that disables a test. See the `exp-dotnet-test-frameworks` skill for framework-specific skip attributes. +**Detection:** Skip/ignore annotations or conditional compilation that disables a test. See the `dotnet-test-frameworks` skill for framework-specific skip attributes. ### Step 3: Apply calibration rules @@ -192,7 +192,7 @@ Present the analysis in this structure: | Flagging integration tests for using real resources | Check for integration test markers and adjust severity accordingly | | Flagging loop-over-collection-assert as conditional logic | Only flag loops with branching or complex logic, not assertion iterations | | Flagging obvious count assertions after adding N items | Consider the immediate context — self-documenting numbers are fine | -| Missing framework-specific assertion syntax | Consult the `exp-dotnet-test-frameworks` skill for .NET framework assertion and skip APIs | +| Missing framework-specific assertion syntax | Consult the `dotnet-test-frameworks` skill for .NET framework assertion and skip APIs | | Over-flagging try/catch that captures for assertion | Distinguish swallowed exceptions from capture-and-assert patterns | | Treating skip annotations with reasons same as bare skips | Note that reasoned skips are less concerning than unexplained ones | | Flagging `DoesNotThrow`-style tests as assertion-free | These implicitly assert no exception — note but acknowledge the intent | diff --git a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-smell-detection/references/test-smell-catalog.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-smell-detection/references/test-smell-catalog.md similarity index 100% rename from external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-smell-detection/references/test-smell-catalog.md rename to external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-smell-detection/references/test-smell-catalog.md diff --git a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-tagging/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-tagging/SKILL.md similarity index 98% rename from external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-tagging/SKILL.md rename to external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-tagging/SKILL.md index 0419a08..b423463 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-experimental/skills/exp-test-tagging/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/test-tagging/SKILL.md @@ -1,5 +1,5 @@ --- -name: exp-test-tagging +name: test-tagging description: "Analyzes test suites and tags each test with a standardized set of traits (e.g., positive, negative, critical-path, boundary, smoke, regression). Use when the user wants to categorize, audit, or label tests with traits. Do not use for writing new tests, running tests, or migrating test frameworks." license: MIT --- @@ -57,7 +57,7 @@ A single test may have **multiple traits** (e.g., both `negative` and `boundary` ### Step 1: Detect the test framework -Examine project files and source code to determine the framework — see the `exp-dotnet-test-frameworks` skill for the complete detection table (package references, test markers, assertion APIs, and skip annotations). +Examine project files and source code to determine the framework — see the `dotnet-test-frameworks` skill for the complete detection table (package references, test markers, assertion APIs, and skip annotations). ### Step 2: Scan existing traits diff --git a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/writing-mstest-tests/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/writing-mstest-tests/SKILL.md index b4b32a4..31cc944 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-test/skills/writing-mstest-tests/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-test/skills/writing-mstest-tests/SKILL.md @@ -1,6 +1,19 @@ --- name: writing-mstest-tests -description: "Best practices for writing new MSTest 3.x/4.x unit tests and implementing concrete fixes in existing MSTest code. Use when the user asks to write, create, implement, repair, or modernize tests (including fix-it prompts such as 'something seems off, fix issues'). Primary fit for direct code changes like correcting swapped Assert.AreEqual argument order, replacing outdated assertion patterns, and converting DynamicData from IEnumerable to ValueTuple-based data sets. Covers modern assertions, data-driven tests, test lifecycle, MSTest.Sdk, sealed classes, Assert.Throws, DynamicData with ValueTuples, TestContext, and conditional execution. Do NOT use for broad test quality audits, flaky-test investigations, or test smell detection reports — use test-anti-patterns instead." +description: > + Write new MSTest unit tests and implement concrete fixes in existing MSTest code using + MSTest 3.x/4.x modern APIs and best practices. + USE FOR: write unit tests for a class, write MSTest tests, create test class, + fix test assertions, MSTest assertion APIs (StartsWith, EndsWith, MatchesRegex, + IsGreaterThan, IsInRange, HasCount, IsNull), something seems off with my tests, + review tests and fix issues, + fix swapped Assert.AreEqual arguments, replace ExpectedException with Assert.Throws, modernize + test patterns, convert DynamicData to ValueTuples, data-driven tests, test lifecycle setup, + sealed test classes, async test patterns, cancellation token testing, + test parallelization, Parallelize, DoNotParallelize, MSTest.Sdk project setup. + DO NOT USE FOR: broad test quality audits or test smell detection (use test-anti-patterns), + running tests (use run-tests), MSTest version migration (use migrate-mstest-v1v2-to-v3 or + migrate-mstest-v3-to-v4). license: MIT --- @@ -13,6 +26,8 @@ Help users write effective, modern unit tests with MSTest 3.x/4.x using current - User wants to write new MSTest unit tests - User wants to improve or modernize existing MSTest tests by implementing concrete fixes - User asks about MSTest assertion APIs, data-driven patterns, or test lifecycle +- User asks to replace `Assert.IsTrue` with more specific assertions (collections, nulls, types, comparisons) +- User asks to replace hard casts with type-checking assertions in tests - User needs help fixing a specific MSTest test bug or failing assertion - User asks to fix swapped `Assert.AreEqual` argument order (expected first, actual second) - User asks to convert `DynamicData` from `IEnumerable` to ValueTuple-based data @@ -34,6 +49,12 @@ Help users write effective, modern unit tests with MSTest 3.x/4.x using current | Existing test code | No | Current tests to fix, update, or modernize | | Test scenario description | No | What behavior the user wants to test | +## Response Guidelines + +- **Specific API or pattern questions** (assertions, data-driven, lifecycle): Jump directly to the relevant workflow step. Do not follow the full workflow. +- **Write new tests from scratch**: Follow the full workflow. +- **Review and fix existing tests**: Fix only the issues present. Do not add unrelated improvements. + ## Workflow ### Step 1: Determine project setup @@ -109,13 +130,29 @@ public sealed class OrderServiceTests ### Step 3: Use modern assertion APIs -Use the correct assertion for each scenario. Prefer `Assert` class methods over `StringAssert` or `CollectionAssert` where both exist. +Pick the most specific assertion for each test scenario. More specific assertions produce better failure messages and make the test's intent clear: -#### Equality and null checks +| What you are testing | Assertion | +|---|---| +| Two values are equal | `Assert.AreEqual(expected, actual)` | +| Same object instance (reference identity) | `Assert.AreSame(expected, actual)` | +| Value is null | `Assert.IsNull(value)` | +| Value is not null | `Assert.IsNotNull(value)` | +| Collection is empty | `Assert.IsEmpty(collection)` | +| Collection is not empty | `Assert.IsNotEmpty(collection)` | +| Collection has exactly N items | `Assert.HasCount(N, collection)` | +| Collection contains an item | `Assert.Contains(item, collection)` | +| Collection does not contain an item | `Assert.DoesNotContain(item, collection)` | +| Object is a specific type | `Assert.IsInstanceOfType(value)` | +| Code throws an exception | `Assert.ThrowsExactly(() => ...)` | + +Prefer `Assert` class methods over `StringAssert` or `CollectionAssert` where both exist. + +#### Equality, null, and reference checks ```csharp Assert.AreEqual(expected, actual); // Value equality -Assert.AreSame(expected, actual); // Reference equality +Assert.AreSame(expected, actual); // Reference equality -- same object instance Assert.IsNull(value); Assert.IsNotNull(value); ``` @@ -151,8 +188,12 @@ Replace generic `Assert.IsTrue` with specialized assertions -- they give better | Instead of | Use | |---|---| | `Assert.IsTrue(list.Count > 0)` | `Assert.IsNotEmpty(list)` | +| `Assert.IsTrue(list.Count == 0)` | `Assert.IsEmpty(list)` | | `Assert.IsTrue(list.Count() == 3)` | `Assert.HasCount(3, list)` | | `Assert.IsTrue(x != null)` | `Assert.IsNotNull(x)` | +| `Assert.IsTrue(x == null)` | `Assert.IsNull(x)` | +| `Assert.AreEqual(a, b)` for same instance | `Assert.AreSame(a, b)` -- reference identity | +| `Assert.IsTrue(!list.Contains(item))` | `Assert.DoesNotContain(item, list)` | | `list.Single(predicate)` + `Assert.IsNotNull` | `Assert.ContainsSingle(list)` | | `Assert.IsTrue(list.Contains(item))` | `Assert.Contains(item, list)` | @@ -323,29 +364,3 @@ public void LocalOnly_InteractiveTest() { } [DoNotParallelize] // Opt out specific classes public sealed class DatabaseIntegrationTests { } ``` - -## Validation - -- [ ] Test classes are `sealed` -- [ ] Test methods follow `MethodName_Scenario_ExpectedBehavior` naming -- [ ] `Assert.ThrowsExactly` used instead of `[ExpectedException]` -- [ ] Specialized assertions used instead of `Assert.IsTrue` (e.g., `Assert.IsNotNull`, `Assert.AreEqual`) -- [ ] DynamicData uses ValueTuple return types instead of `IEnumerable` -- [ ] Sync initialization done in the constructor, not `[TestInitialize]` -- [ ] `TestContext.CancellationToken` passed to async calls in tests with `[Timeout]` -- [ ] Project builds with zero errors and all tests pass - -## Common Pitfalls - -| Pitfall | Solution | -|---------|----------| -| `Assert.AreEqual(actual, expected)` -- swapped arguments | Always put expected first: `Assert.AreEqual(expected, actual)`. Failure messages show "Expected: X, Actual: Y" so wrong order makes messages confusing | -| `[ExpectedException]` -- obsolete, cannot assert message | Use `Assert.Throws` or `Assert.ThrowsExactly` | -| `items.Single()` -- unclear exception on failure | Use `Assert.ContainsSingle(items)` for better failure messages | -| Hard cast `(MyType)result` -- unclear exception | Use `Assert.IsInstanceOfType(result)` | -| `IEnumerable` for DynamicData | Use `IEnumerable<(T1, T2, ...)>` ValueTuples for type safety | -| Sync setup in `[TestInitialize]` | Initialize in the constructor instead -- enables `readonly` fields and satisfies nullability analyzers | -| `CancellationToken.None` in async tests | Use `TestContext.CancellationToken` for cooperative timeout | -| `public TestContext? TestContext { get; set; }` | Drop the `?` -- MSTest suppresses CS8618 for this property | -| `TestContext TestContext { get; set; } = null!` | Remove `= null!` -- unnecessary, MSTest handles assignment | -| Non-sealed test classes | Seal test classes by default for performance | diff --git a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/SKILL.md index 2844e88..5660c48 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/SKILL.md @@ -11,7 +11,7 @@ description: > Dockerfiles for .NET 11. DO NOT USE FOR: .NET Framework migrations, upgrading from .NET 9 or earlier, greenfield .NET 11 projects, or cosmetic modernization unrelated to the upgrade. - NOTE: .NET 11 is in preview. Covers breaking changes through Preview 1. + NOTE: .NET 11 is in preview. Covers breaking changes through Preview 3. license: MIT --- @@ -19,7 +19,7 @@ license: MIT Migrate a .NET 10 project or solution to .NET 11, systematically resolving all breaking changes. The outcome is a project targeting `net11.0` that builds cleanly, passes tests, and accounts for every behavioral, source-incompatible, and binary-incompatible change introduced in .NET 11. -> **Note:** .NET 11 is currently in preview. This skill covers breaking changes documented through Preview 1. It will be updated as additional previews ship. +> **Note:** .NET 11 is currently in preview. This skill covers breaking changes documented through Preview 3. ## When to Use @@ -59,10 +59,14 @@ Migrate a .NET 10 project or solution to .NET 11, systematically resolving all b - **SDK attribute**: `Microsoft.NET.Sdk.Web` → ASP.NET Core; `Microsoft.NET.Sdk.WindowsDesktop` with `` or `` → WPF/WinForms - **PackageReferences**: `Microsoft.EntityFrameworkCore.*` → EF Core; `Microsoft.EntityFrameworkCore.Cosmos` → Cosmos DB provider - **Dockerfile presence** → Container changes relevant - - **Cryptography API usage** → DSA on macOS affected + - **Cryptography API usage** → DSA on macOS affected; AIA cert download changes relevant - **Compression API usage** → DeflateStream/GZipStream/ZipArchive changes relevant - - **TAR API usage** → Header checksum validation change relevant + - **TAR API usage** → Header checksum validation and HardLink entry changes relevant - **`NamedPipeClientStream` usage with `SafePipeHandle`** → SYSLIB0063 constructor obsoletion relevant + - **`BackgroundService` usage** → Unhandled exceptions now stop the host + - **`Microsoft.OpenApi` direct usage** → v3 API breaking changes in ASP.NET Core OpenAPI + - **EF Core SQL Server with Entra ID auth** → SqlClient 7.0 auth dependency changes + - **NativeAOT native libraries on Unix** → Output filename prefix changed 4. Record which reference documents are relevant (see the reference loading table in Step 3). 5. Do a **clean build** (`dotnet build --no-incremental` or delete `bin`/`obj`) on the current `net10.0` target to establish a clean baseline. Record any pre-existing warnings. @@ -93,9 +97,10 @@ Load reference documents based on the project's technology areas: | `references/csharp-compiler-dotnet10to11.md` | Always (C# 15 compiler breaking changes) | | `references/core-libraries-dotnet10to11.md` | Always (applies to all .NET 11 projects) | | `references/sdk-msbuild-dotnet10to11.md` | Always (SDK and build tooling changes) | -| `references/efcore-dotnet10to11.md` | Project uses Entity Framework Core (especially Cosmos DB provider) | -| `references/cryptography-dotnet10to11.md` | Project uses cryptography APIs or targets macOS | -| `references/runtime-jit-dotnet10to11.md` | Deploying to older hardware or embedded devices | +| `references/aspnetcore-dotnet10to11.md` | Project uses ASP.NET Core (OpenAPI, Blazor) | +| `references/efcore-dotnet10to11.md` | Project uses Entity Framework Core | +| `references/cryptography-dotnet10to11.md` | Project uses cryptography APIs, mTLS, or targets macOS | +| `references/runtime-jit-dotnet10to11.md` | Deploying to older hardware, embedded devices, or using NativeAOT | Work through each build error systematically. Common patterns: @@ -115,6 +120,12 @@ Work through each build error systematically. Common patterns: 8. **`when` switch-expression-arm parsing** — `(X.Y) when` is now parsed as a constant pattern with a `when` clause instead of a cast expression, which can cause existing code to fail to compile or change meaning. Review switch expressions using `when` and adjust syntax as needed. +9. **Microsoft.OpenApi v3 breaking changes** — `Microsoft.AspNetCore.OpenApi` now depends on `Microsoft.OpenApi` 3.x. Code using `Microsoft.OpenApi` types directly (`OpenApiDocument`, `OpenApiSchema`, etc.) will have compile errors. Follow the v3 upgrade guide. + +10. **EF Core Design package no longer transitive** — `Microsoft.EntityFrameworkCore.Tools` and `.Tasks` no longer depend on `.Design`. Add an explicit `PackageReference` if needed. + +11. **EFOptimizeContext MSBuild property removed** — Replace with `` and ``. + ### Step 4: Address behavioral changes These changes compile successfully but alter runtime behavior. Review each one and determine impact: @@ -137,6 +148,24 @@ These changes compile successfully but alter runtime behavior. Review each one a 9. **Mono launch target for .NET Framework** — No longer set automatically. If using Mono for .NET Framework apps on Linux, specify explicitly. +10. **Unhandled BackgroundService exceptions stop the host** — Exceptions from `ExecuteAsync()` now propagate and crash the host. Add try/catch in background services that should not bring down the application. + +11. **ZipArchive CRC32 validation** — ZIP reads now validate CRC32 checksums. Corrupt or truncated archives that previously succeeded will now throw `InvalidDataException`. + +12. **TarWriter emits HardLink entries** — Hard-linked files are now written as `HardLink` entries instead of duplicated data. Consumers of .NET-produced tar archives must handle `HardLink` entries. + +13. **AIA certificate downloads disabled** — Server-side client-certificate validation no longer downloads intermediate CAs via AIA by default. Pre-install the full chain or have clients send intermediates. + +14. **Blazor Virtualize OverscanCount default changed** — Default `OverscanCount` changed from 3 to 15. Set explicitly if performance-sensitive. + +15. **Microsoft.Data.SqlClient 7.0 — Entra ID auth separated** — Azure/Entra ID authentication dependencies removed from the core SqlClient package. Add `Microsoft.Data.SqlClient.Extensions.Azure` if using Entra ID auth. + +16. **SqlVector<T> excluded from SELECT** — Vector properties are no longer auto-loaded. Use explicit projections to include vector values. + +17. **SQLitePCLRaw encryption bundles removed** — `bundle_e_sqlcipher` and other encryption bundle packages removed in SQLitePCLRaw 3.0. + +18. **NativeAOT Unix native library `lib` prefix** — Output filenames now include `lib` prefix on Linux/macOS (e.g., `libMyLib.so`). + ### Step 5: Update infrastructure 1. **Dockerfiles**: Update base images from 10.0 to 11.0: @@ -155,7 +184,7 @@ These changes compile successfully but alter runtime behavior. Review each one a "sdk": { - "version": "10.0.100", - "rollForward": "latestFeature" - + "version": "11.0.100-preview.1", + + "version": "11.0.100-preview.3", + "rollForward": "latestFeature" }, "otherSettings": { @@ -173,11 +202,15 @@ These changes compile successfully but alter runtime behavior. Review each one a 3. If the application is containerized, build and test the container image 4. Smoke-test the application, paying special attention to: - Compression behavior with empty streams - - TAR file reading + - TAR file reading (checksum validation and HardLink entries) - EF Core Cosmos DB operations (must be async) - DSA usage on macOS - Memory-intensive MemoryStream usage - Span collection expression assignments + - BackgroundService exception handling + - mTLS / client certificate chain validation + - EF Core SQL Server with Entra ID authentication + - NativeAOT output filenames on Unix 5. Review the diff and ensure no unintended behavioral changes were introduced ## Reference Documents @@ -189,6 +222,7 @@ The `references/` folder contains detailed breaking change information organized | `references/csharp-compiler-dotnet10to11.md` | Always (C# 15 compiler breaking changes) | | `references/core-libraries-dotnet10to11.md` | Always (applies to all .NET 11 projects) | | `references/sdk-msbuild-dotnet10to11.md` | Always (SDK and build tooling changes) | -| `references/efcore-dotnet10to11.md` | Project uses Entity Framework Core (especially Cosmos DB provider) | -| `references/cryptography-dotnet10to11.md` | Project uses cryptography APIs or targets macOS | -| `references/runtime-jit-dotnet10to11.md` | Deploying to older hardware or embedded devices | +| `references/aspnetcore-dotnet10to11.md` | Project uses ASP.NET Core (OpenAPI, Blazor) | +| `references/efcore-dotnet10to11.md` | Project uses Entity Framework Core | +| `references/cryptography-dotnet10to11.md` | Project uses cryptography APIs, mTLS, or targets macOS | +| `references/runtime-jit-dotnet10to11.md` | Deploying to older hardware, embedded devices, or using NativeAOT | diff --git a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/aspnetcore-dotnet10to11.md b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/aspnetcore-dotnet10to11.md new file mode 100644 index 0000000..bc97680 --- /dev/null +++ b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/aspnetcore-dotnet10to11.md @@ -0,0 +1,27 @@ +# ASP.NET Core Breaking Changes (.NET 11) + +These breaking changes affect ASP.NET Core projects. Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/11 + +> **Note:** .NET 11 is in preview. Additional ASP.NET Core breaking changes are expected in later previews. + +## Source-Incompatible Changes + +### Microsoft.OpenApi updated to v3 with OpenAPI 3.2.0 support (Preview 2) + +**Impact: Medium.** `Microsoft.AspNetCore.OpenApi` updated its dependency from `Microsoft.OpenApi` 2.x to 3.x, adding OpenAPI 3.2.0 document generation. The underlying `Microsoft.OpenApi` library has breaking API changes in the v2→v3 transition. + +Code that directly uses `Microsoft.OpenApi` types (`OpenApiDocument`, `OpenApiSchema`, `OpenApiOperation`, etc.) will have compile errors. + +**Fix:** Follow the [Microsoft.OpenApi v3 upgrade guide](https://github.com/microsoft/OpenAPI.NET/blob/main/docs/upgrade-guide-3.md). If you only use the ASP.NET Core OpenAPI integration (`.WithOpenApi()`, `MapOpenApi()`) without touching the object model directly, no changes are needed. + +Source: https://github.com/dotnet/aspnetcore/pull/65415 + +## Behavioral Changes + +### Blazor Virtualize<T> default OverscanCount changed from 3 to 15 (Preview 3) + +**Impact: Low.** The default `OverscanCount` on the `Virtualize` component changed from `3` to `15` to support variable-height item measurement. `QuickGrid` retains its own default of `3`. + +**Fix:** If performance-sensitive, set `OverscanCount` explicitly: ``. + +Source: https://github.com/dotnet/aspnetcore/pull/64964 diff --git a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/core-libraries-dotnet10to11.md b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/core-libraries-dotnet10to11.md index d5a7122..ad45fbb 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/core-libraries-dotnet10to11.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/core-libraries-dotnet10to11.md @@ -85,3 +85,57 @@ Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-librari **Impact: Low.** The minimum supported date for the Japanese Calendar has been corrected. Code using very early dates in the Japanese Calendar may be affected. Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/globalization/11/japanese-calendar-min-date + +### ZipArchive now validates CRC32 when reading entries (Preview 3) + +**Impact: Low–Medium.** ZIP archive reads now validate the CRC32 checksum of each entry. Previously, corrupt or truncated archives were silently accepted; they now throw `InvalidDataException`. + +**Fix:** Ensure ZIP files are not corrupted. If processing partially-written or legacy archives, add error handling for `InvalidDataException`. + +Source: https://github.com/dotnet/runtime/pull/124766 + +### Unhandled BackgroundService exceptions now stop the host (Preview 3) + +**Impact: Medium.** Unhandled exceptions thrown from `BackgroundService.ExecuteAsync()` now propagate and stop the host application. Previously they were silently swallowed. + +```csharp +// .NET 10: exception silently swallowed, host continues +// .NET 11: exception propagates, host stops +protected override async Task ExecuteAsync(CancellationToken stoppingToken) +{ + throw new InvalidOperationException("oops"); // now kills the host +} + +// FIX: Add proper exception handling +protected override async Task ExecuteAsync(CancellationToken stoppingToken) +{ + try + { + // ... work ... + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Background service failed"); + } +} +``` + +**Fix:** Add try/catch in `ExecuteAsync()` for any `BackgroundService` that should not crash the host on failure. + +Source: https://github.com/dotnet/runtime/pull/124863 + +### TarWriter emits HardLink entries for hard-linked files (Preview 3) + +**Impact: Low.** When `TarWriter` archives a directory containing hard links, the same inode encountered more than once is now written as a `HardLink` entry pointing back to the first occurrence, rather than duplicating the file data. + +**Fix:** If consuming tar archives produced by .NET code, ensure the reader handles `HardLink` entry types. + +Source: https://github.com/dotnet/runtime/pull/123874 + +### Zstandard APIs moved from preview package to System.IO.Compression (Preview 3) + +**Impact: Low.** `ZstandardStream` and related APIs that were previously in the `System.IO.Compression.Zstandard` preview NuGet package are now in-box in `System.IO.Compression`. + +**Fix:** Remove the `` preview package if present. The APIs are now available without any additional package reference. + +Source: https://github.com/dotnet/runtime/pull/114545 diff --git a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/cryptography-dotnet10to11.md b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/cryptography-dotnet10to11.md index 9f88243..6ed0516 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/cryptography-dotnet10to11.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/cryptography-dotnet10to11.md @@ -26,3 +26,14 @@ var signature = ecdsa.SignData(data, HashAlgorithmName.SHA256); - **Ed25519** — if available in your scenario This change only affects macOS. DSA continues to work on Windows and Linux (though it is generally considered a legacy algorithm). + +### AIA certificate downloads disabled by default during client-certificate validation (Preview 3) + +**Impact: Medium.** AIA (Authority Information Access) certificate downloads are now disabled by default when performing server-side client-certificate chain validation. Previously the runtime would attempt to fetch intermediate CA certificates online. + +**Fix:** If using mTLS where client certificates rely on AIA URLs for intermediate CAs, either: +- Pre-install the full certificate chain on the server +- Have clients send the full chain including intermediates +- Re-enable AIA downloads via `X509ChainPolicy.DisableCertificateDownloads = false` + +Source: https://github.com/dotnet/runtime/pull/125049 diff --git a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/efcore-dotnet10to11.md b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/efcore-dotnet10to11.md index 4c417d2..d2e9cc5 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/efcore-dotnet10to11.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/efcore-dotnet10to11.md @@ -2,7 +2,7 @@ These breaking changes affect projects using Entity Framework Core 11. Source: https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/breaking-changes -> **Note:** .NET 11 is in preview. The changes below were introduced in **Preview 1**. Additional EF Core breaking changes are expected in later previews. +> **Note:** .NET 11 is in preview. The changes below were introduced in **Preview 1 through Preview 3**. Additional EF Core breaking changes are expected in later previews. ## Medium-Impact Changes @@ -36,3 +36,69 @@ await context.SaveChangesAsync(); - `Any()` → `await AnyAsync()` Tracking issue: https://github.com/dotnet/efcore/issues/37059 + +### Cosmos: empty owned collections return empty collection instead of null (Preview 1) + +**Impact: Low.** When a Cosmos-backed entity has an owned collection with no items, the property now returns an empty collection rather than `null`. + +**Fix:** Update null checks to empty-collection checks: `if (entity.Items is null)` → `if (entity.Items.Count == 0)`. + +Tracking issue: https://github.com/dotnet/efcore/issues/36577 + +## Preview 3 Changes + +### RelationalEventId.MigrationsNotFound now throws by default (Preview 3) + +**Impact: Low.** Calling `Migrate()` or `MigrateAsync()` when no migrations exist in the assembly now throws an exception rather than silently logging. + +**Fix:** If intentional, suppress with: `options.ConfigureWarnings(w => w.Ignore(RelationalEventId.MigrationsNotFound))`. + +Source: https://github.com/dotnet/efcore/pull/37839 + +### EF Core Tools and Tasks no longer transitively depend on Design (Preview 3) + +**Impact: Low.** The `Microsoft.EntityFrameworkCore.Tools` and `Microsoft.EntityFrameworkCore.Tasks` NuGet packages no longer have a transitive dependency on `Microsoft.EntityFrameworkCore.Design`. + +**Fix:** If your project relied on this transitive reference, add it explicitly: + +```xml + +``` + +Source: https://github.com/dotnet/efcore/pull/37837 + +### EFOptimizeContext MSBuild property removed (Preview 3) + +**Impact: Low.** The `true` MSBuild property no longer exists. Code generation is now controlled by `` and ``. + +**Fix:** Replace `` with the two new properties. With `PublishAOT=true`, generation is automatic during publish. + +Source: https://github.com/dotnet/efcore/pull/37838 + +### SqlVector<T> properties excluded from SELECT by default (Preview 3) + +**Impact: Low.** `SqlVector` properties are now excluded from `SELECT` statements when materializing entities (they return `null`). They can still be used in `WHERE`/`ORDER BY` for vector search. + +**Fix:** Use explicit projections to include vector values: `.Select(b => new { b.Id, b.Embedding })`. + +Source: https://github.com/dotnet/efcore/pull/37829 + +### Microsoft.Data.SqlClient updated to 7.0 (Preview 3) + +**Impact: Medium.** EF Core's SQL Server provider now depends on `Microsoft.Data.SqlClient` 7.0. In v7, Azure/Entra ID authentication dependencies (`Azure.Core`, `Azure.Identity`, `Microsoft.Identity.Client`) have been removed from the core package. + +**Fix:** If using Entra ID authentication (e.g., `ActiveDirectoryDefault`, `ActiveDirectoryManagedIdentity`), add: + +```xml + +``` + +Source: https://github.com/dotnet/efcore/pull/37949 + +### Encryption-enabled SQLite packages removed (Preview 3) + +**Impact: Medium.** `SQLitePCLRaw 3.0` (used by `Microsoft.Data.Sqlite` 11) removed `bundle_e_sqlcipher` and several other bundle packages. + +**Fix:** Switch to SQLite Encryption Extension (SEE), SQLCipher from Zetetic, or `SQLite3MultipleCiphers-NuGet`. + +Source: https://github.com/dotnet/efcore/issues/37059 diff --git a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/runtime-jit-dotnet10to11.md b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/runtime-jit-dotnet10to11.md index a290592..b2ded5f 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/runtime-jit-dotnet10to11.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/runtime-jit-dotnet10to11.md @@ -49,3 +49,11 @@ For ReadyToRun-capable assemblies, there may be additional startup overhead on s **Fix:** Verify all deployment targets meet the new minimum requirements. For x86/x64, any CPU from ~2013 or later should be fine. For Windows Arm64, ensure `LSE` support (all Windows 11 compatible Arm64 devices). Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/jit/11/minimum-hardware-requirements + +### NativeAOT native-library outputs use `lib` prefix on Unix (Preview 3) + +**Impact: Low.** NativeAOT shared/native library outputs on Linux and macOS now follow Unix conventions and include the `lib` prefix (e.g., `libMyLib.so` instead of `MyLib.so`). + +**Fix:** Update build scripts, deployment pipelines, or P/Invoke declarations that reference output filenames by the old name without the `lib` prefix. + +Source: https://github.com/dotnet/runtime/pull/124611 diff --git a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/sdk-msbuild-dotnet10to11.md b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/sdk-msbuild-dotnet10to11.md index 16377e7..e256ea6 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/sdk-msbuild-dotnet10to11.md +++ b/external-sources/upstreams/dotnet-skills/dotnet-upgrade/skills/migrate-dotnet10-to-dotnet11/references/sdk-msbuild-dotnet10to11.md @@ -11,3 +11,19 @@ These changes affect the .NET SDK, CLI tooling, NuGet, and MSBuild behavior. Sou **Impact: Low.** The mono launch target is no longer set automatically for .NET Framework apps. If you require Mono for execution on Linux, you need to specify it explicitly in the configuration. Source: https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/11/mono-launch-target-removed + +### NETSDK1235 warning for PackAsTool with custom .nuspec (Preview 2) + +**Impact: Low.** A new build warning `NETSDK1235` is emitted when a project has both `PackAsTool=true` and a custom `NuspecFile` property, which violates .NET Tool packaging requirements. Projects with `TreatWarningsAsErrors=true` will fail. + +**Fix:** Remove the custom `NuspecFile` property when packaging as a .NET Tool, or suppress the warning if the .nuspec is compatible. + +Source: https://github.com/dotnet/sdk/pull/52810 + +### `dotnet publish --self-contained` now parses the passed value (Preview 3) + +**Impact: Low.** `dotnet publish --self-contained` previously always interpreted the flag as `true` regardless of the passed value. It now correctly parses the value (e.g., `--self-contained false` actually produces a framework-dependent publish). + +**Fix:** Review build scripts that pass `--self-contained` to ensure the intended value is correct. + +Source: https://github.com/dotnet/sdk/pull/52333 diff --git a/external-sources/upstreams/dotnet-skills/dotnet/skills/csharp-scripts/SKILL.md b/external-sources/upstreams/dotnet-skills/dotnet/skills/csharp-scripts/SKILL.md index dad39fe..8f5c41b 100644 --- a/external-sources/upstreams/dotnet-skills/dotnet/skills/csharp-scripts/SKILL.md +++ b/external-sources/upstreams/dotnet-skills/dotnet/skills/csharp-scripts/SKILL.md @@ -1,41 +1,44 @@ --- name: csharp-scripts -description: Run single-file C# programs as scripts (file-based apps) for quick experimentation, prototyping, and concept testing. Use when the user wants to write and execute a small C# program without creating a full project. +description: "Run file-based C# apps with the .NET CLI when the user explicitly wants C#/.NET code without creating a project. Use for C# language/API experiments, one-file C# apps, small multi-file C# apps composed with `#:include`/`#:exclude`, or C# file-based apps linked with `#:ref`. Do not use for language-agnostic throwaway scripts, generic computations, Python/PowerShell-style automation, full projects, or existing app integration." license: MIT --- -# C# Scripts +# File-Based C# Apps ## When to Use -- Testing a C# concept, API, or language feature with a quick one-file program +- Testing a C# concept, API, or language feature with a quick file-based app - Prototyping logic before integrating it into a larger project +- Building a small utility from one entry-point file and a few helper `.cs` files ## When Not to Use -- The user needs a full project with multiple files or project references +- The user asks for a language-agnostic quick script, throwaway computation, or shell/Python/PowerShell-style automation +- The user needs a full project, solution integration, or project references in an existing app - The user is working inside an existing .NET solution and wants to add code there -- The program is too large or complex for a single file +- The app is large enough that project structure, build customization, tests, or publish configuration should live in a `.csproj` ## Inputs | Input | Required | Description | |-------|----------|-------------| -| C# code or intent | Yes | The code to run, or a description of what the script should do | +| C# code or intent | Yes | The code to run, or a description of what the file-based app should do | ## Workflow ### Step 1: Check the .NET SDK version -Run `dotnet --version` to verify the SDK is installed and note the major version number. File-based apps require .NET 10 or later. If the version is below 10, follow the [fallback for older SDKs](#fallback-for-net-9-and-earlier) instead. +Run `dotnet --version` to verify the SDK is installed and note the full version, including the feature band. File-based apps require .NET 10 or later. `#:include`, `#:exclude`, and transitive directive processing require SDK 10.0.300 or later; SDK 10.0.100/10.0.200 builds can run single-file apps but do not support those multi-file directives. If the version is below 10, follow the [fallback for older SDKs](#fallback-for-net-9-and-earlier) instead. -### Step 2: Write the script file +### Step 2: Write the app file -Create a single `.cs` file using top-level statements. Place it outside any existing project directory to avoid conflicts with `.csproj` files. +Create an entry-point `.cs` file using top-level statements. Place it outside any existing project directory to avoid conflicts with `.csproj` files. ```csharp +#!/usr/bin/env dotnet // hello.cs -Console.WriteLine("Hello from a C# script!"); +Console.WriteLine("Hello from a file-based app!"); var numbers = new[] { 1, 2, 3, 4, 5 }; Console.WriteLine($"Sum: {numbers.Sum()}"); @@ -47,7 +50,7 @@ Guidelines: - Place `using` directives at the top of the file (after the `#!` line and any `#:` directives if present) - Place type declarations (classes, records, enums) after all top-level statements -### Step 3: Run the script +### Step 3: Run the app ```bash dotnet hello.cs @@ -65,7 +68,7 @@ Place directives at the top of the file (immediately after an optional shebang l #### `#:package` — NuGet package references -Always specify a version: +Specify a version unless the app intentionally uses central package management. Use `@*` when the latest available package is acceptable (or `@*-*` for pre-release): ```csharp #:package Humanizer@2.14.1 @@ -109,6 +112,26 @@ Reference another project by relative path: #:project ../MyLibrary/MyLibrary.csproj ``` +#### `#:ref` — File-based app references + +Reference another `.cs` file as a separate file-based app project when it should compile into a separate assembly instead of being included in the same compilation. Use `#:include` for ordinary helper files that should share the same assembly as the entry point; use `#:ref` when you want project-reference-like boundaries. + +```csharp +#:property ExperimentalFileBasedProgramEnableRefDirective=true +#:ref ../Shared/Formatter.cs + +Console.WriteLine(Formatter.Title("hello world")); +``` + +Guidelines: + +- The referenced file is compiled as its own virtual project and added as a project reference. +- If the referenced file is a library without top-level statements, put `#:property OutputType=Library` in that referenced file. +- Members that must be consumed by the referencing app should be public; internal members are not visible across the assembly boundary. +- `#:ref` is transitive: a referenced file can contain its own `#:ref` and other `#:` directives. +- Relative paths are resolved relative to the file containing the directive. +- Some SDK builds require `#:property ExperimentalFileBasedProgramEnableRefDirective=true`; remove that property if the SDK accepts `#:ref` without it. + #### `#:sdk` — SDK selection Override the default SDK (`Microsoft.NET.Sdk`): @@ -117,9 +140,65 @@ Override the default SDK (`Microsoft.NET.Sdk`): #:sdk Microsoft.NET.Sdk.Web ``` +#### `#:include` and `#:exclude` — Multi-file apps + +In .NET SDK 10.0.300 and later, file-based apps can include additional files in the same virtual project. Check the full `dotnet --version` output before using these directives; a 10.0.100 or 10.0.200 SDK is still .NET 10 but does not support them. Use `#:include` for helper source files and supported assets, and `#:exclude` to remove files from an include pattern or default item set. + +```csharp +#!/usr/bin/env dotnet +#:include Helpers.cs +#:include Models/*.cs +#:exclude Models/Generated/*.cs + +Console.WriteLine(Formatter.Title("hello world")); +``` + +Guidelines: + +- Treat the file passed to `dotnet` as the entry point; put top-level statements there. +- Put declarations such as classes, records, and enums in included `.cs` files. +- Prefer explicit globs such as `Helpers.cs` or `Models/*.cs` over broad recursive globs. +- Paths are resolved relative to the file containing the directive. +- Include directives from non-entry-point C# files are processed too, so a helper file can declare its own `#:package`, `#:property`, `#:sdk`, `#:project`, `#:ref`, `#:include`, or `#:exclude` directives. +- Avoid duplicate directives across included files unless the directive kind explicitly supports duplicates; duplicate `#:package`, `#:property`, `#:sdk`, `#:include`, and `#:exclude` entries can fail. +- When an app uses `#:include`, add a shebang (`#!/usr/bin/env dotnet`) to the entry-point file on Unix-like systems to make the entry point clear to tools. Use `LF` line endings and no BOM for shebang files. + +Example layout: + +```text +scratch/ + hello.cs + Helpers.cs + Models/ + Person.cs +``` + +```csharp +#!/usr/bin/env dotnet +// hello.cs +#:include Helpers.cs +#:include Models/*.cs + +var person = new Person("Ada"); +Console.WriteLine(Formatter.Title(person.Name)); +``` + +```csharp +// Helpers.cs +static class Formatter +{ + public static string Title(string value) => value.ToUpperInvariant(); +} +``` + +```csharp +// Models/Person.cs +record Person(string Name); +``` + ### Step 5: Clean up -Remove the script file when the user is done. To clear cached build artifacts: +Remove the app files when the user is done. To clear cached build artifacts: ```bash dotnet clean hello.cs @@ -173,7 +252,7 @@ partial class AppJsonContext : JsonSerializerContext; ## Converting to a project -When a script outgrows a single file, convert it to a full project: +When a file-based app outgrows this workflow, convert it to a full project: ```bash dotnet project convert hello.cs @@ -184,29 +263,35 @@ dotnet project convert hello.cs If the .NET SDK version is below 10, file-based apps are not available. Use a temporary console project instead: ```bash -mkdir -p /tmp/csharp-script && cd /tmp/csharp-script +mkdir -p /tmp/csharp-file-based-app && cd /tmp/csharp-file-based-app dotnet new console -o . --force ``` -Replace the generated `Program.cs` with the script content and run with `dotnet run`. Add NuGet packages with `dotnet add package `. Remove the directory when done. +Replace the generated `Program.cs` with the app content and run with `dotnet run`. Add NuGet packages with `dotnet add package `. Remove the directory when done. ## Validation - [ ] `dotnet --version` reports 10.0 or later (or fallback path is used) -- [ ] The script compiles without errors (can be checked explicitly with `dotnet build .cs`) +- [ ] If the app uses `#:include`, `#:exclude`, or transitive directives from included files, `dotnet --version` reports SDK 10.0.300 or later +- [ ] The app compiles without errors (can be checked explicitly with `dotnet build .cs`) - [ ] `dotnet .cs` produces the expected output -- [ ] Script file and cached artifacts are cleaned up after the session +- [ ] Multi-file apps include every required helper file and exclude unintended matches +- [ ] App files and cached artifacts are cleaned up after the session ## Common Pitfalls | Pitfall | Solution | |---------|----------| -| `.cs` file is inside a directory with a `.csproj` | Move the script outside the project directory, or use `dotnet run --file file.cs` | +| `.cs` file is inside a directory with a `.csproj` | Move the app outside the project directory, or use `dotnet run --file file.cs` | | `#:package` without a version | Specify a version: `#:package PackageName@1.2.3` or `@*` for latest | | `#:property` with wrong syntax | Use `PropertyName=Value` with no spaces around `=` and no quotes: `#:property AllowUnsafeBlocks=true` | | Directives placed after C# code | All `#:` directives must appear immediately after an optional shebang line (if present) and before any `using` directives or other C# statements | +| Helper file is not compiled | Add `#:include Helper.cs` or an appropriate glob to the entry-point file | +| Shared file needs an assembly boundary | Use `#:ref Shared.cs` instead of `#:include Shared.cs`, and set `#:property OutputType=Library` in the referenced file if it has no entry point | +| Broad include pulls in unrelated files | Prefer narrow include patterns and use `#:exclude` for generated, backup, or experimental files | +| Duplicate directives in included files | Keep package, property, SDK, include, and exclude directives unique across the entry point and included C# files | | Reflection-based JSON serialization fails | Use source-generated JSON with `JsonSerializerContext` (see [Source-generated JSON](#source-generated-json)) | -| Unexpected build behavior or version errors | File-based apps inherit `global.json`, `Directory.Build.props`, `Directory.Build.targets`, and `nuget.config` from parent directories. Move the script to an isolated directory if the inherited settings conflict | +| Unexpected build behavior or version errors | File-based apps inherit `global.json`, `Directory.Build.props`, `Directory.Build.targets`, and `nuget.config` from parent directories. Move the app to an isolated directory if the inherited settings conflict | ## More info diff --git a/external-sources/vendir.lock.yml b/external-sources/vendir.lock.yml index 1deb6c5..e81d762 100644 --- a/external-sources/vendir.lock.yml +++ b/external-sources/vendir.lock.yml @@ -2,11 +2,11 @@ apiVersion: vendir.k14s.io/v1alpha1 directories: - contents: - git: - commitTitle: Bump the github-actions-dependencies group across 1 directory with - 3 updates (#631)... - sha: 4a4c424ee43f60354230389e96c39053307121d8 + commitTitle: Fix migrate-vstest-to-mtp activation for `Handle exit code 8` scenario + (#648)... + sha: de0a97c551178951f6405740b9aaba5635381d44 tags: - - skill-validator-nightly-4-g4a4c424 + - skill-validator-nightly-22-gde0a97c path: dotnet-skills path: upstreams kind: LockConfig