From b676cbb246cdbccb05ab9209caea430b4bac6a8c Mon Sep 17 00:00:00 2001 From: onlyexeption Date: Sun, 18 Oct 2026 16:39:09 +0300 Subject: [PATCH 01/11] fix(theme): replace palette override advice with verified recolor steps --- README.md | 4 +- scripts/smoke-test.ts | 22 ++++- server/README.md | 4 +- server/ServerInstructions.cs | 4 +- server/Tools/ThemeTools.cs | 162 ++++++++++++++++++++++--------- server/Tools/ToolDescriptions.cs | 10 +- 6 files changed, 150 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 5efc6fa..02ae880 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # wpf-mcp -MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 6,900+ types, and named-theme setup with palette re-coloring. +MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 6,900+ types, and named-theme setup with verified recolor steps. Distributed as a NuGet `dotnet tool`, [`Infragistics.Wpf.Mcp`](https://www.nuget.org/packages/Infragistics.Wpf.Mcp) — currently in **beta** (`0.1.0-beta.0`). From the first stable release it will also be listed in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.Infragistics-Developer-Tools/wpf-mcp`. @@ -16,7 +16,7 @@ All tools are read-only (`readOnlyHint: true`, `openWorldHint: false`) — none | `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. Call first for any theming task. | | `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Use the exact `path` from `setup_wpf_theme`. | -| `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's centralized color/brush palette — exact resource keys, values, and a ready-to-merge override skeleton — for re-coloring a theme without creating a new one. | +| `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's color/brush palette — exact resource keys and values — plus verified steps to recolor that theme (control-level overrides or an extended theme) without creating a new one. | | `search_wpf_docs` | Search 2,600+ how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. | | `get_wpf_doc` | Full text (including XAML samples) of one documentation topic by slug. Use the `slug` from a `search_wpf_docs` result. | diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 50fcdef..2766492 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -21,7 +21,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const ENTRY = join(ROOT, 'dist', 'index.js'); const DOTNET_DLL = process.env.WPF_MCP_DOTNET_DLL ?? join(ROOT, 'server', 'bin', 'Debug', 'net10.0', 'wpf-mcp.dll'); -const TARGET = process.argv.includes('--dotnet') +const DOTNET = process.argv.includes('--dotnet'); +const TARGET = DOTNET ? { entry: DOTNET_DLL, command: 'dotnet', args: [DOTNET_DLL], hint: 'dotnet build server/' } : { entry: ENTRY, command: process.execPath, args: [ENTRY], hint: 'npm run build' }; @@ -130,6 +131,12 @@ async function main(): Promise { text = textOf(r); const path = /`(Themes\/[^`]+\.xaml)`/.exec(text)?.[1]; check('component+theme filter yields a resource path', !r.isError && !!path, text.slice(0, 300)); + if (DOTNET) { + // The IG theme's class is IgTheme; `new IGTheme()` does not compile (C# server only — src/ is not updated). + r = await call('setup_wpf_theme', { component: 'DataChart', theme: 'IG' }); + text = textOf(r); + check('IG apply block uses the IgTheme class', !r.isError && text.includes('new Infragistics.Themes.IgTheme()') && !text.includes('IGTheme()'), text.slice(0, 600)); + } console.log('get_wpf_theme_resource'); if (path) { @@ -145,6 +152,19 @@ async function main(): Promise { check('MetroDark palette has color keys', !r.isError && /Color_\d+|Brush\d+/.test(text), text.slice(0, 300)); r = await call('get_wpf_theme_palette', {}); check('omitting theme returns the chooser', !r.isError && textOf(r).includes('MetroDark')); + if (DOTNET) { + // Overriding palette keys does not recolor a theme (verified); the tool returns verified recolor steps + // instead, with class names read from each theme's own files (C# server only — src/ is not updated). + r = await call('get_wpf_theme_palette', { theme: 'RoyalDark' }); + text = textOf(r); + check('no palette-override skeleton', !r.isError && !text.includes('Override skeleton') && text.includes('does not recolor the theme'), text.slice(-600)); + check('RoyalDark steps name its compiled grid and DockManager classes', + text.includes('igThemes:DataPresenterRoyalDark.CellValuePresenter') && text.includes('DockManagerRoyalDark_Brushes.xaml') && text.includes('Infragistics.Themes.RoyalDarkTheme')); + r = await call('get_wpf_theme_palette', { theme: 'IG' }); + text = textOf(r); + check('IG steps use IgTheme and DataPresenterIGTheme', + !r.isError && text.includes('Infragistics.Themes.IgTheme') && text.includes('igThemes:DataPresenterIGTheme.CellValuePresenter') && !text.includes('IGTheme()')); + } } finally { await client.close(); } diff --git a/server/README.md b/server/README.md index 081ea14..5214211 100644 --- a/server/README.md +++ b/server/README.md @@ -2,7 +2,7 @@ -MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 6,900+ types, 2,600+ documentation topics, and named-theme setup with palette re-coloring. Everything an AI coding agent needs to write correct Infragistics XAML instead of guessing. +MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 6,900+ types, 2,600+ documentation topics, and named-theme setup with verified recolor steps. Everything an AI coding agent needs to write correct Infragistics XAML instead of guessing. Packaged as a `dotnet tool`. All Infragistics data ships inside the package; nothing is downloaded at runtime, and every tool is read-only. @@ -86,7 +86,7 @@ To use `dnx` instead of a global install, set `"command": "dnx"` and `"args": [" | `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. | | `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. | -| `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's centralized color/brush palette — exact resource keys, values, and a ready-to-merge override skeleton — for re-coloring a theme without creating a new one. | +| `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's color/brush palette — exact resource keys and values — plus verified steps to recolor that theme (control-level overrides or an extended theme) without creating a new one. | | `search_wpf_docs` | Search how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. | | `get_wpf_doc` | Full text (including XAML samples) of one documentation topic by slug. | diff --git a/server/ServerInstructions.cs b/server/ServerInstructions.cs index c2b9054..b5ec719 100644 --- a/server/ServerInstructions.cs +++ b/server/ServerInstructions.cs @@ -13,10 +13,10 @@ public static class ServerInstructions 3. Whenever the task needs HOW-TO guidance beyond "what members exist" — layouts/nesting, styling/theming, data binding, editing/validation, filtering/sorting/grouping/summaries, exporting, performance, commands, known issues, etc. — call search_wpf_docs(query, control: component), passing the SAME component name from step 1/2 to scope the search. 4. get_wpf_doc(slug) on the most relevant result from step 3 to read the full XAML example or how-to text before writing any code or giving usage advice. 5. For new projects, get_wpf_project_scaffold(components) after step 1 for dotnet CLI + xmlns setup, then still run steps 2-4 per component before writing real XAML. - 6. Establish the visual theme EARLY — treat it as a default step for any new window/app, not an optional afterthought. Call setup_wpf_theme(component?, theme?) to pick a named theme and get the exact ready-to-paste apply steps (NuGet package + App.xaml.cs ThemeManager call for the newer family, or Theme="" for the legacy family) plus the resource file paths; then get_wpf_theme_resource(path) only if you need to copy/override a specific Style/ControlTemplate. When the goal is to RE-COLOR an existing theme (change its palette) rather than restyle one control, call get_wpf_theme_palette(theme) for that theme's centralized color/brush keys and a ready-to-merge override skeleton. + 6. Establish the visual theme EARLY — treat it as a default step for any new window/app, not an optional afterthought. Call setup_wpf_theme(component?, theme?) to pick a named theme and get the exact ready-to-paste apply steps (NuGet package + App.xaml.cs ThemeManager call for the newer family, or Theme="" for the legacy family) plus the resource file paths; then get_wpf_theme_resource(path) only if you need to copy/override a specific Style/ControlTemplate. When the goal is to RE-COLOR an existing theme (brand colors, a different accent) rather than restyle one control, call get_wpf_theme_palette(theme) for that theme's colors plus the verified recolor steps — overriding the palette keys themselves does NOT recolor a theme. ALWAYS call list_wpf_components/search_wpf_api before writing any XAML to get the correct xmlns namespace URI; wrong values cause immediate compile errors. - ALWAYS establish a theme via setup_wpf_theme (then get_wpf_theme_palette to recolor if needed) BEFORE writing any Style, Setter, Brush, or color attribute on an Infragistics control. Do NOT hand-author a color scheme (dark, neon, corporate, etc.) to approximate a look — a named theme or palette override is the correct, lower-maintenance path. + ALWAYS establish a theme via setup_wpf_theme (then get_wpf_theme_palette to recolor if needed) BEFORE writing any Style, Setter, Brush, or color attribute on an Infragistics control. Do NOT hand-author a color scheme (dark, neon, corporate, etc.) to approximate a look — a named theme plus the verified recolor steps from get_wpf_theme_palette is the correct, lower-maintenance path. Never guess property names, child-element nesting, or other usage details (styling, data binding, performance, etc.) — verify through this chain rather than assuming from a similar control or from naming conventions. """; diff --git a/server/Tools/ThemeTools.cs b/server/Tools/ThemeTools.cs index a834e87..7948324 100644 --- a/server/Tools/ThemeTools.cs +++ b/server/Tools/ThemeTools.cs @@ -14,7 +14,7 @@ public sealed partial class ThemeTools(DataStore store, ToolLog log) // ── setup_wpf_theme ────────────────────────────────────────────────────── private const string NewerMechanismLine = - "_Newer family (`Themes/`): apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (requires the `Infragistics.WPF.Themes..Trial` NuGet package). Do NOT merge these files directly into `Application.Resources`._"; + "_Newer family (`Themes/`): apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (the IG theme's class is `IgTheme`; requires the `Infragistics.WPF.Themes..Trial` NuGet package). Do NOT merge these files directly into `Application.Resources`._"; private const string LegacyMechanismLine = "_Legacy family (`DefaultStyles/`): the named theme is already embedded in the component's assembly — just set `Theme=\"\"` on the control. Use these files only to copy/override individual Styles/ControlTemplates._"; @@ -32,11 +32,16 @@ public sealed partial class ThemeTools(DataStore store, ToolLog log) "dotnet add package Infragistics.WPF.Themes..Trial", "```", "```csharp", - "// App.xaml.cs — set before the first window is created", + "// App.xaml.cs — set before the first window is created (the IG theme's class is IgTheme)", "Infragistics.Themes.ThemeManager.ApplicationTheme = new Infragistics.Themes.Theme();", "```", }); + // ThemeManager theme classes are "Theme" (MetroDarkTheme, RoyalDarkTheme, …) except IG, + // whose class is IgTheme: `new IGTheme()` fails with CS0234 (verified by compiling, 26.1.54). + private static string ThemeClassName(string theme) => + string.Equals(theme, "IG", StringComparison.OrdinalIgnoreCase) ? "IgTheme" : $"{theme}Theme"; + private static string NewerApplyBlock(string theme) => string.Join('\n', new[] { "", @@ -48,7 +53,7 @@ private static string NewerApplyBlock(string theme) => string.Join('\n', new[] "// App.xaml.cs — set before the first window is created", "protected override void OnStartup(StartupEventArgs e)", "{", - $" Infragistics.Themes.ThemeManager.ApplicationTheme = new Infragistics.Themes.{theme}Theme();", + $" Infragistics.Themes.ThemeManager.ApplicationTheme = new Infragistics.Themes.{ThemeClassName(theme)}();", " base.OnStartup(e);", "}", "```", @@ -263,7 +268,7 @@ Call setup_wpf_theme first — never guess this path. var isNewerFamily = path.Replace('\\', '/').StartsWith("Themes/", StringComparison.Ordinal); var mechanismNote = isNewerFamily - ? "_Newer family: apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` — do not merge this file directly into Application.Resources._" + ? "_Newer family: apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` (the IG theme's class is `IgTheme`) — do not merge this file directly into Application.Resources._" : "_Legacy family: the named theme is already embedded in the component assembly — set `Theme=\"...\"` on the control. Use this file to copy/override specific styles only._"; var shown = content.Length > MaxThemeFileChars @@ -361,40 +366,126 @@ private static List ParsePalette(string xaml) .Select(t => (t.theme, t.file!)) .ToArray(); - private static readonly string PaletteGuidance = string.Join('\n', new[] + // ── recolor guidance ───────────────────────────────────────────────────── + // Verified in test apps (Infragistics 26.1.54; RoyalDark and IG; chart, grid, gauge, DockManager, + // standard WPF controls): overriding palette keys never recolors a ThemeManager theme — each theme + // file merges its own copy of .Theme.Colors.xaml and resolves it with StaticResource, and + // ThemeManager appends its dictionary after the app's. Control-level overrides and an extended + // theme (ThemeBase subclass remapping files) do work. + + [GeneratedRegex(@"")] + private static partial Regex CompiledThemeClassTag(); + + /// The compiled theme class a ThemeManager wrapper file merges, read from the file itself so the + /// per-theme names stay exact: `DataPresenterRoyalDark`, but `DataPresenterIGTheme` for IG. + private string? CompiledThemeClass(ThemeResourceFile[] files, string fileSuffix, string classPrefix) { - "## How to apply a re-tuned palette", - "", - "These keys are the single re-color surface for the **newer \"Infragistics.Controls.*\" family** (charts, gauges, etc.) applied via `Infragistics.Themes.ThemeManager`. To recolor the theme WITHOUT creating a new theme, override just the keys you want in your own `ResourceDictionary` and merge it into the theme load — do NOT invent new keys or rename existing ones.", - "", - "⚠️ **Load-order matters.** The theme references these colors from compiled BAML primitives via `StaticResource` (resolved once at parse time), so merging an override dictionary *after* the theme has already loaded may NOT recolor already-styled controls. Merge your override so it is present BEFORE `ThemeManager.ApplicationTheme` is set / before the first themed window is created (e.g. in `App.xaml.cs` before `base.OnStartup`).", - "", - "⚠️ **Newer family only.** Legacy \"Infragistics.Windows.*\" controls (XamDataGrid, XamRibbon, XamDockManager, ...) do NOT read this palette — their themes are embedded BAML applied via `Theme=\"...\"`. Recolor those by copying individual Styles/ControlTemplates (see `setup_wpf_theme` / `get_wpf_theme_resource`).", - "", - "_This is a read-only introspection tool — it returns the real keys/values and a skeleton to copy; it does not modify your project._", - }); + var file = files.FirstOrDefault(f => f.File.EndsWith(fileSuffix, StringComparison.OrdinalIgnoreCase)); + var xaml = file is null ? null : store.LoadThemeResource(file.Path); + return xaml is null ? null : CompiledThemeClassTag().Matches(xaml) + .Select(m => m.Groups[1].Value) + .FirstOrDefault(n => n.StartsWith(classPrefix, StringComparison.Ordinal) && !n.EndsWith("Brushes", StringComparison.Ordinal)); + } + + /// `` as the file is actually named in the theme folder. + private static string ThemeFile(ThemeResourceFile[] files, string theme, string suffix) => + files.FirstOrDefault(f => f.File.Equals(theme + suffix, StringComparison.OrdinalIgnoreCase))?.File ?? theme + suffix; + + private string RecolorGuidance(string theme, ThemeResourceFile[] files) + { + var themeClass = ThemeClassName(theme); + var dpClass = CompiledThemeClass(files, ".xamDataPresenter.xaml", "DataPresenter") ?? $"DataPresenter{theme}"; + var dpPrimitives = CompiledThemeClass(files, ".xamDataPresenter.xaml", "Primitives") ?? $"Primitives{theme}"; + var dockClass = CompiledThemeClass(files, ".xamDockManager.xaml", "DockManager") ?? $"DockManager{theme}"; + var dockPrimitives = CompiledThemeClass(files, ".xamDockManager.xaml", "Primitives") ?? dpPrimitives; + var colorsFile = ThemeFile(files, theme, ".Theme.Colors.xaml"); + var stylesFile = ThemeFile(files, theme, ".Styles.xaml"); + var chartFile = ThemeFile(files, theme, ".xamDataChart.xaml"); + var msFile = ThemeFile(files, theme, ".MSControls.Core.Implicit.xaml"); + + return $$$""" + ## How to recolor {{{theme}}} (verified) + + Read the palette above as a **map** of which color plays which role, and use it to pick matching replacement colors. **Overriding these keys does not recolor the theme**: every theme file merges its own private copy of `{{{colorsFile}}}` and resolves the keys against it, and ThemeManager adds its dictionary after the app's resources. Verified in test apps on RoyalDark and IG for charts, grids, gauges, DockManager and standard WPF controls; all ThemeManager themes are built the same way. + + **Never works** + - Overriding the palette keys (`Color_…`, `Brush01`–`Brush05`): the theme files never read your copy. + - Putting overrides in a ResourceDictionary that App.xaml merges (`Application.Resources.MergedDictionaries`): ThemeManager's dictionary is added after it and wins. + - ResourceWasher: it tints every color (backgrounds too), can't hit an exact brand color, and crashes on the compiled grid theme. The Infragistics Color Tuner only generates ResourceWashers merged in App.xaml, so it doesn't work either. + + **Where overrides work:** on the control itself, in the control's or window's `Resources`, or in a dictionary added in code **after** the theme is set (verified for the gauge, standard controls and DockManager): + ```csharp + Infragistics.Themes.ThemeManager.ApplicationTheme = new Infragistics.Themes.{{{themeClass}}}(); + Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("BrandColors.xaml", UriKind.Relative) }); + ``` + For the grid and DockManager, overrides placed directly in `Application.Resources` (not in a merged dictionary) work too. + + ### Recolor specific controls + - **Chart (`XamCategoryChart`):** set `Brushes` and `Outlines` (each an `ig:BrushCollection`) on the chart. + - **Grid (`XamDataGrid`):** Styles based on the theme's own grid styles, in the grid's `Resources` or directly in `Application.Resources`: + ```xml + + + + ``` + Excel-style filter checkboxes need their own implicit `HierarchicalDataTemplate DataType="{x:Type igDP:RecordFilterTreeItem}"` with a CheckBox style. Check the text contrast too: light themes such as IG keep dark selected text, so also set `ForegroundSelectedStyle`, `ForegroundActiveStyle` and (on `CellValuePresenter`) `ForegroundFieldSelectedStyle` to a style with a readable `TextBlock.Foreground`. + - **DockManager:** override its brush keys (`igDock:DockManagerBrushKeys`, xmlns `http://infragistics.com/DockManager`) app-wide — directly in `Application.Resources` or in a dictionary added after the theme. On RoyalDark, `TabbedDocumentActiveOuterBorderFillKey` draws the selected tab line and `MenuItemCheckmarkBoxHottrackCenterFillKey` the checked menu item; every key and its color is in `DefaultStyles/DockManager/{{{dockClass}}}_Brushes.xaml`. Overrides on the `XamDockManager` itself miss floating windows. + - **Gauge (`XamRadialGauge`):** set `RangeBrushes` on the gauge. For an app-wide gauge Style (`BasedOn="{StaticResource {x:Type ig:XamRadialGauge}}"`), load the gauge type right after setting the theme — `System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeof(Infragistics.Controls.Gauges.XamRadialGauge).TypeHandle);` — or the gauge silently loses the theme. + - **Standard WPF controls (Button, CheckBox, RadioButton, ListBoxItem, TextBox):** re-declaring the theme's brush keys near the control does nothing — the templates hold frozen brushes. Copy the control's template from `{{{stylesFile}}}` with the accent brushes swapped, or use the extended theme below. A `ProgressBar` only needs `Foreground`. A Button shows the accent only when focused or pressed. + + ### Recolor the whole app (extended theme) + Subclass the theme and point ThemeManager at copied, recolored theme files: + ```csharp + public class BrandTheme : Infragistics.Themes.{{{themeClass}}} + { + protected override void ConfigureControlMappings() + { + base.ConfigureControlMappings(); + var chart = BuildLocationString(typeof(BrandTheme).Assembly.FullName, "/Themes/Brand/{{{chartFile}}}"); + foreach (var key in Mappings.Keys.ToList()) + if (Mappings[key].EndsWith("{{{chartFile}}}", StringComparison.OrdinalIgnoreCase)) // compare case-insensitively: registered names can differ in case + Mappings[key] = chart; + } + } + // App.xaml.cs: Infragistics.Themes.ThemeManager.ApplicationTheme = new BrandTheme(); + ``` + - **Charts, gauges, standard controls and other controls:** copy the control's theme file and the files it merges (such as `{{{stylesFile}}}` and `{{{colorsFile}}}`) into your project, change the accent colors in the copied `{{{colorsFile}}}`, and remap that file as above. Standard WPF controls use `{{{msFile}}}`. + - **Grid:** its theme file only wraps compiled themes. Remap it to your own dictionary that merges `` plus copies of the grid's theme files from `DefaultStyles/DataPresenter/` — `{{{dpClass}}}.xaml`, `{{{dpClass}}}_Express.xaml`, `{{{dpClass}}}_Brushes.xaml` and the `DataPresenterGeneric*.xaml` files they merge, with the `Images` folder as `Resource` items — and change the accent in the copied `{{{dpClass}}}_Brushes.xaml`. If the Excel-style filter checkboxes keep the old color (seen on IG), they are styled by the standard-controls file, `{{{msFile}}}` — remap that too. + - **DockManager:** remap its file to a dictionary that merges ``, `` and your brush-key overrides — no copied templates needed. + - Copied theme files must be re-copied on every Infragistics upgrade. `get_wpf_theme_resource` returns them, but files over 24,000 characters are cut off; the full sources are in the Infragistics/wpf-resources repository on GitHub. + + _Read-only tool: it returns the theme's real keys and verified steps; it does not modify your project._ + """; + } private static string BuildPaletteChooser(IEnumerable themeNames) => string.Join('\n', new[] { "# WPF Theme Palettes — pick a base theme", "", - "These newer-family (ThemeManager) themes expose a re-tunable color palette:", + "These ThemeManager themes have a color palette:", "", } .Concat(themeNames.Select(t => $"- `{t}`")) .Concat(new[] { "", - "## To re-color WITHOUT knowing the theme name", + "## To recolor WITHOUT knowing the theme name", "", "1. **Detect the theme the app already uses** from the workspace, in priority order:", - " - `App.xaml.cs` → `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();`", + " - `App.xaml.cs` → `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` (the IG theme's class is `IgTheme`)", " - the `.csproj` → an `Infragistics.WPF.Themes..Trial` PackageReference", " - XAML → a `Theme=\"\"` attribute", "2. If you cannot detect it, **ask the user** which base theme they want — or simply whether they want a **dark** base (e.g. `MetroDark`, `RoyalDark`) or a **light** base (e.g. `Office2013`, `RoyalLight`, `Metro`, `IG`). _This tool cannot classify dark/light automatically — the palette files are not consistent enough to derive it reliably._", - "3. Call `get_wpf_theme_palette(theme)` with the chosen name to get its keys + a ready-to-merge override skeleton.", + "3. Call `get_wpf_theme_palette(theme)` with the chosen name to get its colors plus the verified recolor steps for that theme.", "", - "_Concrete colors the user gives (e.g. \"neon purple\") are mapped to hex and assigned to the returned accent/chart-series keys by you — the tool only supplies the grounded keys._", + "_Overriding the palette keys does not recolor a theme; the steps returned in step 3 say what does. Concrete colors the user gives (e.g. \"neon purple\") are mapped to hex by you — use the palette to see which colors play which role._", })); [McpServerTool(Name = "get_wpf_theme_palette", ReadOnly = true, Idempotent = true, OpenWorld = false)] @@ -403,11 +494,12 @@ public CallToolResult GetThemePalette( [Description(""" A newer-family (ThemeManager) theme name, e.g. "MetroDark", "RoyalDark", "RoyalLight", "Office2013", "Office2010Blue", "Metro", "IG". Case-insensitive; a substring is accepted. - Returns that theme's centralized color/brush palette (its `.Theme.Colors.xaml`) so you can - re-color the theme by overriding existing keys — NOT create a new theme. + Returns that theme's color/brush palette (its `.Theme.Colors.xaml`) as a map of which color + plays which role, plus the verified steps to recolor that theme (overriding the palette keys does NOT + recolor it). OMIT this to get the chooser: the list of palette-capable theme names plus how to detect which theme the app already uses. Detect the current theme from the workspace FIRST (App.xaml.cs - `ThemeManager.ApplicationTheme = new Theme();`, an `Infragistics.WPF.Themes..Trial` + `ThemeManager.ApplicationTheme = new Theme();` or `new IgTheme()`, an `Infragistics.WPF.Themes..Trial` PackageReference, or a `Theme=""` attribute); if you cannot, ask the user which base theme (or dark vs light) they want, then call again with that theme. """)] @@ -441,7 +533,7 @@ Optional substring filter matched against the palette group label (e.g. "accent" if (match.file is null) { var names = string.Join(", ", available.Select(t => $"`{t.theme}`")); - var msg = $"No re-tunable palette found for theme \"{theme}\". Themes with a color palette (newer ThemeManager family): {names}. Call setup_wpf_theme to browse all themes and style folders."; + var msg = $"No color palette found for theme \"{theme}\". Themes with a color palette (newer ThemeManager family): {names}. Call setup_wpf_theme to browse all themes and style folders."; log.Write("get_wpf_theme_palette", input, msg, timer); return ToolResult.Error(msg); } @@ -503,27 +595,9 @@ Optional substring filter matched against the palette group label (e.g. "accent" output.Add($"| `{e.Key}` | `{e.Value}`{(e.Kind == "Brush" ? " _(brush)_" : "")} | {e.Note ?? ""} |"); } + var themeFiles = store.ThemeIndex.NewerThemes.First(t => t.Theme == match.theme).Files; output.Add(""); - output.Add("## Override skeleton"); - output.Add(""); - output.Add("_Copy into a `ResourceDictionary`, change only the values you want, and merge it ahead of the theme load (see guidance below). Delete rows you are not changing._"); - output.Add(""); - output.Add("```xml"); - output.Add(""); - foreach (var g in byGroup) - { - output.Add($" "); - foreach (var e in g) - output.Add(e.Kind == "Brush" - ? $" " - : $" {e.Value}"); - } - output.Add(""); - output.Add("```"); - - output.Add(""); - output.Add(PaletteGuidance); + output.Add(RecolorGuidance(match.theme, themeFiles)); var text = string.Join('\n', output); log.Write("get_wpf_theme_palette", input, text, timer); diff --git a/server/Tools/ToolDescriptions.cs b/server/Tools/ToolDescriptions.cs index 5d2609d..323f0b8 100644 --- a/server/Tools/ToolDescriptions.cs +++ b/server/Tools/ToolDescriptions.cs @@ -58,7 +58,7 @@ public static class ToolDescriptions Returns a ranked list of matching topics with slug, title, controls, tags, and a short summary — NOT the full content. This is a lookup step, not the final answer: always follow up with get_wpf_doc(slug) on the most relevant result(s) before writing any code or giving usage advice. - For APPLYING or RE-COLORING a named theme, do not rely on these docs — use setup_wpf_theme (which mechanism applies + the exact ready-to-paste apply steps) and get_wpf_theme_palette (the centralized palette keys) instead; those tools are authoritative for theming, the docs here only cover conceptual/how-to background. + For APPLYING or RE-COLORING a named theme, do not rely on these docs — use setup_wpf_theme (which mechanism applies + the exact ready-to-paste apply steps) and get_wpf_theme_palette (the theme's colors plus verified recolor steps) instead; those tools are authoritative for theming, the docs here only cover conceptual/how-to background. """; public const string GetWpfDoc = """ @@ -73,7 +73,7 @@ public static class ToolDescriptions List available Infragistics WPF named themes and the raw XAML style/resource-dictionary files backing them, sourced directly from the official Infragistics/wpf-resources repository. Covers BOTH theming mechanisms used across the product — they are NOT interchangeable, always check which one applies before writing code: 1. Legacy "Infragistics.Windows.*" family (XamDataGrid/XamDataPresenter, XamRibbon, XamDockManager, XamTabControl, etc.) — named themes (MetroDark, RoyalDark, Office2013, ...) ship embedded as BAML **inside the component's own assembly**. Apply out-of-the-box by simply setting `Theme="MetroDark"` on the control — no package or merge needed. The matching files under `DefaultStyles/{Folder}/` are for *customizing*: copy the relevant Style/ControlTemplate and override just what you need. - 2. Newer "Infragistics.Controls.*" family (charts, gauges, and other newer controls) — themes are NOT embedded; apply via a separate `Infragistics.WPF.Themes..Trial` NuGet package plus `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (do NOT manually merge the `Themes/{ThemeName}/` files into Application.Resources — that throws a runtime XamlParseException). The matching files under `Themes/{ThemeName}/` are for inspecting/customizing the exact brushes and templates that theme applies. + 2. Newer "Infragistics.Controls.*" family (charts, gauges, and other newer controls) — themes are NOT embedded; apply via a separate `Infragistics.WPF.Themes..Trial` NuGet package plus `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (the IG theme's class is `IgTheme`; do NOT manually merge the `Themes/{ThemeName}/` files into Application.Resources — that throws a runtime XamlParseException). The matching files under `Themes/{ThemeName}/` are for inspecting/customizing the exact brushes and templates that theme applies. Call this FIRST for any styling/theming task — before writing XAML, before advising which mechanism applies, and before calling get_wpf_theme_resource. For newer-family themes it also returns the COMPLETE ready-to-paste apply block (the `dotnet add package Infragistics.WPF.Themes..Trial` line plus the `App.xaml.cs` ThemeManager call), so applying a real theme is fewer steps than hand-authoring brushes — always prefer it over inventing your own color scheme. Pass `component` and/or `theme` to filter; omit both to see the full list of theme names and legacy style folders. Returns exact `path` values — reuse them verbatim in get_wpf_theme_resource(path) to read the actual XAML content. """; @@ -87,10 +87,10 @@ public static class ToolDescriptions """; public const string GetWpfThemePalette = """ - Return the centralized color/brush PALETTE of one newer-family (ThemeManager) Infragistics WPF theme — the exact `` and `` resource keys, current values, and semantic groupings parsed from that theme's official `.Theme.Colors.xaml`. Use this for the specific task of RE-COLORING an existing theme (tuning the palette) WITHOUT creating a brand-new theme. + Return the color/brush PALETTE of one newer-family (ThemeManager) Infragistics WPF theme — the exact `` and `` resource keys, current values, and semantic groupings parsed from that theme's official `.Theme.Colors.xaml` — plus the VERIFIED steps to recolor that theme (brand colors, a different accent) without creating a brand-new theme. - Returns: the real keys (e.g. `Color_024` accent, `Color_028` error, base grays `Color_000`-`Color_009`, chart-series colors, plus the 5 primary "Color Tuner" `Brush01`-`Brush05`), grouped using the file's own section labels (never guessed); a ready-to-merge override `ResourceDictionary` skeleton pre-filled with those verbatim keys; and application guidance including the load-order caveat (the theme reads these via StaticResource from compiled BAML, so an override must be merged BEFORE the theme loads to take effect) and the fact that this palette applies ONLY to the newer "Infragistics.Controls.*" family, not legacy embedded-BAML controls. + Returns: the real keys (e.g. `Color_024` accent, `Color_028` error, base grays `Color_000`-`Color_009`, chart-series colors, plus the 5 primary `Brush01`-`Brush05`), grouped using the file's own section labels (never guessed), to read as a map of which color plays which role; then recolor steps for that theme. ⚠️ Overriding these palette keys does NOT recolor the theme (every theme file merges its own private copy of the palette, and ThemeManager adds its dictionary after the app's), and an override ResourceDictionary merged in App.xaml never wins. What works, verified in test apps on charts, grids, gauges, DockManager and standard WPF controls: control-level overrides (chart `Brushes`, grid Styles `BasedOn` the theme's compiled grid styles, DockManager brush keys, gauge `RangeBrushes`, copied templates for standard controls), or an extended theme — a subclass of the theme class that remaps theme files to copied, recolored ones. The steps name the exact class and file names for the requested theme. - Only newer-family themes have a palette (e.g. MetroDark, RoyalDark, RoyalLight, Office2013, Office2010Blue, Metro, IG). Pass `theme` (case-insensitive; substring accepted) and optionally `filter` to narrow to a group/key/value (e.g. "accent", "chart", "Brush01", "00AADE"). If the user does NOT name a theme, detect the one the app already uses from the workspace FIRST — App.xaml.cs `ThemeManager.ApplicationTheme = new Theme();`, an `Infragistics.WPF.Themes..Trial` PackageReference, or a `Theme=""` attribute — and if you cannot, call this tool with NO `theme` to get the chooser (palette-capable theme names + detection steps) and ask the user for a dark vs light base. When the user gives concrete colors (e.g. "neon purple"), YOU translate them to hex and assign them to the accent/chart-series keys this tool returns — the tool supplies the grounded keys, it does not do color mapping. Prefer this over get_wpf_theme_resource when the user's goal is "change the colors / palette of theme X" rather than reading one control's full template. This is a read-only introspection tool — it does not modify the user's project. + Only newer-family themes have a palette (MetroDark, RoyalDark, RoyalLight, Office2013, Office2010Blue, Metro, IG). Pass `theme` (case-insensitive; substring accepted) and optionally `filter` to narrow to a group/key/value (e.g. "accent", "chart", "Brush01", "00AADE"). If the user does NOT name a theme, detect the one the app already uses from the workspace FIRST — App.xaml.cs `ThemeManager.ApplicationTheme = new Theme();` (the IG theme's class is `IgTheme`), an `Infragistics.WPF.Themes..Trial` PackageReference, or a `Theme=""` attribute — and if you cannot, call this tool with NO `theme` to get the chooser (palette-capable theme names + detection steps) and ask the user for a dark vs light base. When the user gives concrete colors (e.g. "neon purple"), YOU translate them to hex — the tool supplies the grounded palette and steps, it does not do color mapping. Prefer this over get_wpf_theme_resource when the user's goal is "change the colors of theme X" rather than reading one control's full template. This is a read-only introspection tool — it does not modify the user's project. """; } From a0eb9825132662fcb1718c74d876883c187bb3c1 Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Tue, 20 Oct 2026 11:35:40 +0300 Subject: [PATCH 02/11] feat: enhance logging and input validation across tools; update README for debug logging details --- README.md | 2 +- scripts/smoke-test.ts | 53 ++++++++++++++++++++++++++++++++-- server/Program.cs | 6 +++- server/README.md | 2 +- server/Tools/ComponentTools.cs | 37 +++++++++++++++++------- server/Tools/DocsTools.cs | 31 +++++++++++++------- server/Tools/SearchTools.cs | 9 ++++-- server/Tools/ToolInput.cs | 18 ++++++++++++ server/Tools/ToolLog.cs | 7 +++-- 9 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 server/Tools/ToolInput.cs diff --git a/README.md b/README.md index 6d88a6e..63ddd35 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ To run from a source checkout instead, build it first (see [DEVELOPMENT.md](DEVE - **`infragistics.wpf.mcp is not found in NuGet feeds`** — the command is missing `--prerelease` (or an exact `@0.1.0-beta.0` version); see [Installation](#installation). - **`Settings file 'DotnetToolSettings.xml' was not found in the package`** — the active .NET SDK is older than 10 (check `dotnet --version`; a `global.json` in the current folder can pin an older one). Install the .NET 10 SDK. - **Which Infragistics version is the data from?** The server prints it to stderr on startup: `Infragistics WPF MCP server 0.1.0-beta.0 ready (data: Infragistics 26.1.21, built )`. Every release regenerates the data from the version pinned in [`nuget/WpfDocs.csproj`](nuget/WpfDocs.csproj). -- **Run the server with `--debug`** (add it to your MCP client config's `args`, see above) to log every tool call's input/output/timing to `wpf-mcp.log` in your system temp folder — useful for reproducing an agent's exact tool-calling sequence after the fact. The exact path is printed to stderr on startup, and can be overridden with the `WPF_MCP_LOG` environment variable. +- **Run the server with `--debug`** (add it to your MCP client config's `args`, see above) to log every tool call's input/output/timing to `wpf-mcp.log` in your system temp folder — useful for reproducing an agent's exact tool-calling sequence after the fact. The exact path is printed to stderr on startup, and can be overridden with the `WPF_MCP_LOG` environment variable. `--debug` also turns on the MCP SDK's per-request logging on stderr, which is otherwise limited to warnings and errors. - **`wpf-mcp` is not found** after `dotnet tool install -g` — the global tools folder (`%USERPROFILE%\.dotnet\tools` on Windows, `~/.dotnet/tools` elsewhere) is not on the `PATH` of the process that launches the client. Restart the client, or use the full path as `command`. - **A tool call succeeded but the answer looks wrong or a control seems missing** — this is usually stale/outdated data from Infragistics' own NuGet package (summaries, base types) rather than a bug in this server. Cross-check against the pinned version above before assuming the MCP itself is at fault. - **A tool returns "no topics found" / "no themes found" for everything** on a source build — the data pipeline didn't complete. Re-run `npm run build:all` and read the output; it either reports real counts or aborts with a 🚨 banner saying exactly what's missing. Published packages are validated against these counts before release, so this can't happen with a NuGet install. diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 56374d0..cf7386b 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -13,7 +13,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -25,6 +26,9 @@ const IS_DOTNET = process.argv.includes('--dotnet'); const TARGET = IS_DOTNET ? { entry: DOTNET_DLL, command: 'dotnet', args: [DOTNET_DLL], hint: 'dotnet build server/' } : { entry: ENTRY, command: process.execPath, args: [ENTRY], hint: 'npm run build' }; +// Top-level heading of a tool response: the C# server uses `##` (clients render `#` as a page-sized +// title in their tool-result panes), the frozen TS server still uses `#`. +const H1 = IS_DOTNET ? '##' : '#'; const EXPECTED_TOOLS = [ 'list_wpf_components', @@ -92,7 +96,7 @@ async function main(): Promise { console.log('get_wpf_api_reference'); r = await call('get_wpf_api_reference', { component: 'XamDataGrid', kind: 'all' }); text = textOf(r); - check('XamDataGrid resolves with xmlns and NuGet', !r.isError && text.startsWith('# XamDataGrid') && text.includes('xmlns:igDP=') && text.includes('Infragistics.WPF')); + check('XamDataGrid resolves with xmlns and NuGet', !r.isError && text.startsWith(`${H1} XamDataGrid\n`) && text.includes('xmlns:igDP=') && text.includes('Infragistics.WPF')); check('inherited members are grouped by base type', text.includes('(inherited from `DataPresenterBase`)'), 'no "(inherited from `DataPresenterBase`)" section — enrichWithInheritance did not run'); // kind: properties — small enough to render every member (inherited ones included) in full. r = await call('get_wpf_api_reference', { component: 'XamDataGrid', kind: 'properties' }); @@ -125,7 +129,16 @@ async function main(): Promise { console.log('search_wpf_api'); r = await call('search_wpf_api', { query: 'filter', limit: 5 }); - check('keyword search returns ranked results', !r.isError && textOf(r).includes('# WPF API Search')); + check('keyword search returns ranked results', !r.isError && textOf(r).startsWith(`${H1} WPF API Search`)); + if (IS_DOTNET) { + // C# only: the TS server returns no results for limit <= 0 and doesn't enforce max lengths. + r = await call('search_wpf_api', { query: 'filter', limit: 0 }); + check('limit 0 is clamped to 1 result', !r.isError && textOf(r).includes('(1 of '), textOf(r).slice(0, 300)); + r = await call('search_wpf_api', { query: 'filter '.repeat(100) }); + check('over-long query → isError naming the limit', r.isError === true && textOf(r).includes('max 128 characters'), textOf(r).slice(0, 300)); + r = await call('search_wpf_api', { query: ' ' }); + check('blank query → isError', r.isError === true && textOf(r).includes('must not be empty'), textOf(r).slice(0, 300)); + } console.log('get_wpf_project_scaffold'); r = await call('get_wpf_project_scaffold', { components: ['XamDataGrid', 'XamCategoryChart'], projectName: 'SmokeApp', framework: 'net10.0' }); @@ -143,6 +156,12 @@ async function main(): Promise { } r = await call('search_wpf_docs', { query: 'column series', limit: 3 }); check('docs-common topics resolved ({CategoryChartName} → real name)', !r.isError && /XamCategoryChart|XamDataChart/.test(textOf(r)), textOf(r).slice(0, 300)); + if (IS_DOTNET) { + // C# only: the TS server strips hyphens but not spaces from control names. + r = await call('search_wpf_docs', { control: 'ThemeManager', limit: 50 }); + text = textOf(r); + check('control "ThemeManager" matches topics tagged "IG Theme Manager"', !r.isError && text.includes('IG Theme Manager'), text.slice(0, 300)); + } r = await call('get_wpf_doc', { topic: 'no-such-topic-slug' }); check('unknown slug → isError', r.isError === true); @@ -169,10 +188,38 @@ async function main(): Promise { check('MetroDark palette has color keys', !r.isError && /Color_\d+|Brush\d+/.test(text), text.slice(0, 300)); r = await call('get_wpf_theme_palette', {}); check('omitting theme returns the chooser', !r.isError && textOf(r).includes('MetroDark')); + + if (IS_DOTNET) { + console.log('stderr'); + check('no SDK info lines without --debug', !/^info:/m.test(stderr.join('')), stderr.join('').slice(0, 300)); + } } finally { await client.close(); } + if (IS_DOTNET) { + // C# only: a second server with --debug; parallel calls must each get a log entry. stderr is + // ignored, not piped: --debug turns on the SDK's info lines and an unread pipe would fill up. + console.log('--debug log'); + const logPath = join(tmpdir(), `wpf-mcp-smoke-${process.pid}.log`); + rmSync(logPath, { force: true }); + const env = Object.fromEntries(Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined)); + const debugClient = new Client({ name: 'wpf-mcp-smoke-test', version: '0.0.0' }); + await debugClient.connect(new StdioClientTransport({ + command: TARGET.command, args: [...TARGET.args, '--debug'], env: { ...env, WPF_MCP_LOG: logPath }, stderr: 'ignore', + })); + try { + const calls = 20; + await Promise.all(Array.from({ length: calls }, (_, i) => + debugClient.callTool({ name: 'search_wpf_api', arguments: { query: `filter ${i}`, limit: 1 } }))); + const entries = existsSync(logPath) ? readFileSync(logPath, 'utf8').match(/^\[[^\]]+\] search_wpf_api \(\d+ms\)$/gm)?.length ?? 0 : 0; + check(`${calls} parallel calls → ${calls} log entries`, entries === calls, `${entries} entries in ${logPath}`); + } finally { + await debugClient.close(); + rmSync(logPath, { force: true }); + } + } + const ms = Math.round(performance.now() - start); if (failures > 0) { console.error(`\n${failures} check(s) failed (${ms}ms)`); diff --git a/server/Program.cs b/server/Program.cs index c1f6f04..b4be1ef 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -11,7 +11,8 @@ .Split('+')[0]; var store = new DataStore(Path.Combine(AppContext.BaseDirectory, "data")); -var log = new ToolLog(args.Contains("--debug")); +var debug = args.Contains("--debug"); +var log = new ToolLog(debug); // Content root = the tool's own folder, not the MCP client's working directory, so an // unrelated (or malformed) appsettings.json in the user's workspace is never loaded. @@ -22,6 +23,9 @@ }); // stdout is the MCP transport — every log line must go to stderr. builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); +// MCP clients surface the server's stderr in their logs; the SDK's per-request info lines are +// only worth it when debugging. The "ready" line below is Console.Error, so it always shows. +builder.Logging.SetMinimumLevel(debug ? LogLevel.Information : LogLevel.Warning); builder.Services.AddSingleton(store); builder.Services.AddSingleton(log); builder.Services diff --git a/server/README.md b/server/README.md index c3bdf34..cae201c 100644 --- a/server/README.md +++ b/server/README.md @@ -97,7 +97,7 @@ All tools are annotated `readOnlyHint: true`, `openWorldHint: false` — none of - **`infragistics.wpf.mcp is not found in NuGet feeds`** — the command is missing `--prerelease` (or an exact `@0.1.0-beta.0` version); see [Install](#install). - **`Settings file 'DotnetToolSettings.xml' was not found in the package`** — the active .NET SDK is older than 10 (check `dotnet --version`; a `global.json` in the current folder can pin an older one). Install the .NET 10 SDK. - **Which Infragistics version is the data from?** The server prints it to stderr on startup: `Infragistics WPF MCP server 0.1.0-beta.0 ready (data: Infragistics 26.1.21, built )`. -- **Log every tool call** — add `"args": ["--debug"]` to the client configuration. Each call's input, output and timing goes to `wpf-mcp.log` in your system temp folder (override with the `WPF_MCP_LOG` environment variable); the exact path is printed to stderr on startup. +- **Log every tool call** — add `"args": ["--debug"]` to the client configuration. Each call's input, output and timing goes to `wpf-mcp.log` in your system temp folder (override with the `WPF_MCP_LOG` environment variable); the exact path is printed to stderr on startup. `--debug` also turns on the MCP SDK's per-request logging on stderr, which is otherwise limited to warnings and errors. - **`wpf-mcp` is not found** after `dotnet tool install -g` — the global tools folder (`%USERPROFILE%\.dotnet\tools` on Windows, `~/.dotnet/tools` elsewhere) is not on the `PATH` of the process that launches the client. Restart the client, or use the full path as `command`. - **An answer looks wrong or a control seems missing** — this is usually stale data in Infragistics' own NuGet package (summaries, base types) rather than a bug in this server. Cross-check against the pinned version above before assuming the MCP itself is at fault. diff --git a/server/Tools/ComponentTools.cs b/server/Tools/ComponentTools.cs index 31dae5c..63aef92 100644 --- a/server/Tools/ComponentTools.cs +++ b/server/Tools/ComponentTools.cs @@ -25,6 +25,11 @@ public CallToolResult ListComponents( { var timer = Stopwatch.StartNew(); var input = new { filter }; + if (ToolInput.CheckLength("filter", filter, max: 128) is { } invalid) + { + log.Write("list_wpf_components", input, invalid, timer); + return ToolResult.Error(invalid); + } var components = store.Components; // Same relevance ladder as handlers.ts: name match always outranks a description or @@ -68,7 +73,7 @@ static double MatchScore(ComponentEntry c, string needle) { var line = new List { - $"## {c.Component}", + $"### {c.Component}", $"- **xmlns:** `xmlns:{c.DefaultPrefix}=\"{c.XamlNamespace}\"`", $"- **NuGet:** `{c.NugetPackage}`", $"- **Assembly:** `{c.Assembly}`", @@ -79,7 +84,7 @@ static double MatchScore(ComponentEntry c, string needle) return string.Join('\n', line); }); - var header = $"# Infragistics WPF Components ({matches.Length}{(string.IsNullOrEmpty(filter) ? " total" : $" matching \"{filter}\"")})"; + var header = $"## Infragistics WPF Components ({matches.Length}{(string.IsNullOrEmpty(filter) ? " total" : $" matching \"{filter}\"")})"; var text = string.Join("\n\n", new[] { header, "" }.Concat(lines)); log.Write("list_wpf_components", input, text, timer); @@ -146,14 +151,14 @@ private static List RenderMemberSections((ApiMemberEntry m, string kind) if (direct.Length > 0) { output.Add(""); - output.Add($"## {label}"); + output.Add($"### {label}"); if (IsCompact(detail, kindKey, inherited: false)) output.Add(FormatMemberNames(direct)); else output.AddRange(direct.Select(FormatMember)); } foreach (var group in inherited) { output.Add(""); - output.Add($"## {label} (inherited from `{group.Key}`)"); + output.Add($"### {label} (inherited from `{group.Key}`)"); if (IsCompact(detail, kindKey, inherited: true)) output.Add(FormatMemberNames(group)); else output.AddRange(group.Select(FormatMember)); } @@ -184,6 +189,11 @@ or to get details of members a compacted response lists by name only. { var timer = Stopwatch.StartNew(); var input = new { component, kind = kind.ToString(), member }; + if ((ToolInput.CheckLength("component", component, max: 128, min: 1) ?? ToolInput.CheckLength("member", member, max: 64)) is { } invalid) + { + log.Write("get_wpf_api_reference", input, invalid, timer); + return ToolResult.Error(invalid); + } var memberFilter = member?.Trim(); var doc = store.LoadApiDoc(component); @@ -211,14 +221,14 @@ or to get details of members a compacted response lists by name only. var summary = string.IsNullOrEmpty(doc.Summary) ? "_(no summary)_" : doc.Summary; var output = new List { - $"# {doc.Component}", + $"## {doc.Component}", "", $"**xmlns:** `xmlns:{doc.DefaultPrefix}=\"{doc.XamlNamespace}\"`", $"**NuGet:** `{doc.NugetPackage}`", $"**Assembly:** `{doc.Assembly}`", $"**Full type name:** `{doc.DotnetNamespace}.{doc.Component}`", "", - "## Summary", + "### Summary", summary, }; @@ -234,7 +244,7 @@ or to get details of members a compacted response lists by name only. if (!string.IsNullOrEmpty(doc.Remarks)) { output.Add(""); - output.Add("## Remarks"); + output.Add("### Remarks"); output.Add(doc.Remarks); } @@ -324,6 +334,11 @@ Call list_wpf_components first to resolve exact names. { var timer = Stopwatch.StartNew(); var input = new { components, projectName, framework = framework.Wire() }; + if (ToolInput.CheckLength("projectName", projectName, max: 128, min: 1) is { } invalid) + { + log.Write("get_wpf_project_scaffold", input, invalid, timer); + return ToolResult.Error(invalid); + } if (!ProjectNameRegex().IsMatch(projectName)) { @@ -380,9 +395,9 @@ Call list_wpf_components first to resolve exact names. lines.Add(""); } - lines.Add($"# WPF Project Scaffold: `{projectName}`"); + lines.Add($"## WPF Project Scaffold: `{projectName}`"); lines.Add(""); - lines.Add("## 1. Create and configure the project"); + lines.Add("### 1. Create and configure the project"); lines.Add(""); lines.Add("```bash"); lines.Add($"dotnet new wpf -n {projectName} --framework {framework.Wire()}"); @@ -391,7 +406,7 @@ Call list_wpf_components first to resolve exact names. lines.Add("dotnet restore"); lines.Add("```"); lines.Add(""); - lines.Add("## 2. Add xmlns declarations to MainWindow.xaml"); + lines.Add("### 2. Add xmlns declarations to MainWindow.xaml"); lines.Add(""); lines.Add("```xml"); lines.Add(" $"- **{r.component}** → `{r.nugetPackage}`")); var text = string.Join('\n', lines); diff --git a/server/Tools/DocsTools.cs b/server/Tools/DocsTools.cs index 107b14a..a5159aa 100644 --- a/server/Tools/DocsTools.cs +++ b/server/Tools/DocsTools.cs @@ -37,10 +37,15 @@ At least one of `query` or `control` must be provided. { var timer = Stopwatch.StartNew(); var input = new { query, control, limit }; + if ((ToolInput.CheckLength("query", query, max: 128) ?? ToolInput.CheckLength("control", control, max: 64)) is { } invalid) + { + log.Write("search_wpf_docs", input, invalid, timer); + return ToolResult.Error(invalid); + } var tokens = SearchTools.Tokenize(query); var controlFilter = control?.ToLowerInvariant().Trim(); if (string.IsNullOrEmpty(controlFilter)) controlFilter = null; - limit = Math.Min(limit, 50); + limit = Math.Clamp(limit, 1, 50); if (tokens.Length == 0 && controlFilter is null) { @@ -54,12 +59,13 @@ At least one of `query` or `control` must be provided. foreach (var entry in store.DocIndex) { - // Hard pre-filter on control name: bidirectional substring match with hyphens - // stripped, so "FinancialChart" ~ "XamFinancialChart" and "financial-chart" both hit. + // Hard pre-filter on control name: bidirectional substring match with hyphens and spaces + // stripped, so "FinancialChart" ~ "XamFinancialChart" and "financial-chart" both hit, and + // "ThemeManager" ~ "IG Theme Manager". if (controlFilter is not null && !entry.ControlNames.Any(c => { - var cNorm = c.ToLowerInvariant().Replace("-", ""); - var fNorm = controlFilter.Replace("-", ""); + var cNorm = c.ToLowerInvariant().Replace("-", "").Replace(" ", ""); + var fNorm = controlFilter.Replace("-", "").Replace(" ", ""); return cNorm.Contains(fNorm, StringComparison.Ordinal) || fNorm.Contains(cNorm, StringComparison.Ordinal); })) { @@ -119,7 +125,7 @@ At least one of `query` or `control` must be provided. var queryLabel = !string.IsNullOrEmpty(query) ? $"\"{query}\"" : "(browsing by control)"; var text = string.Join("\n\n", new[] { - $"# WPF Documentation Search: {queryLabel} ({limited.Length} of {totalFound} matches)", + $"## WPF Documentation Search: {queryLabel} ({limited.Length} of {totalFound} matches)", "", } .Concat(lines) @@ -152,6 +158,11 @@ public CallToolResult GetDoc( { var timer = Stopwatch.StartNew(); var input = new { topic }; + if (ToolInput.CheckLength("topic", topic, max: 160, min: 1) is { } invalid) + { + log.Write("get_wpf_doc", input, invalid, timer); + return ToolResult.Error(invalid); + } var doc = store.LoadDoc(topic); if (doc is null) @@ -173,7 +184,7 @@ public CallToolResult GetDoc( var output = new List { - $"# {(string.IsNullOrEmpty(doc.Title) ? doc.Slug : doc.Title)}", + $"## {(string.IsNullOrEmpty(doc.Title) ? doc.Slug : doc.Title)}", "", $"**Topic slug:** `{doc.Slug}`", $"**Source:** {(doc.Source == "wpf" ? "docs-wpf" : "docs-common (shared cross-platform topic)")}", @@ -184,7 +195,7 @@ public CallToolResult GetDoc( if (doc.XamlSnippets.Length > 0) { output.Add(""); - output.Add("## XAML Examples"); + output.Add("### XAML Examples"); var shown = doc.XamlSnippets.Take(MaxSnippetsShown).ToArray(); for (var i = 0; i < shown.Length; i++) { @@ -193,7 +204,7 @@ public CallToolResult GetDoc( ? $"{snippet[..MaxSnippetChars]}\n" : snippet; output.Add(""); - output.Add($"### Example {i + 1}"); + output.Add($"#### Example {i + 1}"); output.Add("```xml"); output.Add(text); output.Add("```"); @@ -215,7 +226,7 @@ public CallToolResult GetDoc( ? $"{doc.Body[..MaxBodyChars]}\n\n_(truncated, {doc.Body.Length} chars total)_" : doc.Body; output.Add(""); - output.Add("## Full Topic Text"); + output.Add("### Full Topic Text"); output.Add(""); output.Add(body); diff --git a/server/Tools/SearchTools.cs b/server/Tools/SearchTools.cs index a5905b8..be24c8e 100644 --- a/server/Tools/SearchTools.cs +++ b/server/Tools/SearchTools.cs @@ -38,8 +38,13 @@ public CallToolResult SearchApi( { var timer = Stopwatch.StartNew(); var input = new { query, limit }; + if (ToolInput.CheckLength("query", query, max: 128, min: 1) is { } invalid) + { + log.Write("search_wpf_api", input, invalid, timer); + return ToolResult.Error(invalid); + } var tokens = Tokenize(query); - limit = Math.Min(limit, 50); + limit = Math.Clamp(limit, 1, 50); var results = new List<(string typeName, string summary, string nugetPackage, string matchedOn, int score)>(); @@ -86,7 +91,7 @@ public CallToolResult SearchApi( var text = string.Join("\n\n", new[] { - $"# WPF API Search: \"{query}\" ({limited.Length} of {totalFound} matches)", + $"## WPF API Search: \"{query}\" ({limited.Length} of {totalFound} matches)", "", } .Concat(lines) diff --git a/server/Tools/ToolInput.cs b/server/Tools/ToolInput.cs new file mode 100644 index 0000000..11ee693 --- /dev/null +++ b/server/Tools/ToolInput.cs @@ -0,0 +1,18 @@ +namespace Infragistics.Wpf.Mcp.Tools; + +/// Input checks the SDK doesn't do: [MinLength]/[MaxLength]/[Range] on a tool parameter only +/// describe the schema, so each tool enforces its limits itself before doing any work. +public static class ToolInput +{ + /// The error message when `value` is outside [min, max] characters, otherwise null. Pass the + /// same numbers as the parameter's attributes. Null counts as empty, and so does whitespace + /// for the minimum. + public static string? CheckLength(string name, string? value, int max, int min = 0) + { + var length = value?.Length ?? 0; + if (length > max) return $"`{name}` is too long ({length} characters), max {max} characters."; + if ((value?.Trim().Length ?? 0) < min) + return min == 1 ? $"`{name}` must not be empty." : $"`{name}` is too short, min {min} characters."; + return null; + } +} diff --git a/server/Tools/ToolLog.cs b/server/Tools/ToolLog.cs index e545934..1139fa6 100644 --- a/server/Tools/ToolLog.cs +++ b/server/Tools/ToolLog.cs @@ -8,6 +8,9 @@ namespace Infragistics.Wpf.Mcp.Tools; public sealed class ToolLog { private readonly bool _enabled; + // Tool calls run concurrently and File.AppendAllText doesn't share the file with another + // writer, so unserialized appends collide and the entry is lost. Covers this process only. + private readonly Lock _gate = new(); public string Path { get; } public ToolLog(bool enabled) @@ -24,8 +27,8 @@ public void Write(string tool, object input, string output, Stopwatch timer) var preview = output.Length > 400 ? $"{output[..400]}… ({output.Length} chars)" : output; try { - File.AppendAllText(Path, - $"[{DateTime.UtcNow:yyyy-MM-dd'T'HH:mm:ss.fff'Z'}] {tool} ({timer.ElapsedMilliseconds}ms)\n IN: {JsonSerializer.Serialize(input)}\n OUT: {preview}\n\n"); + var entry = $"[{DateTime.UtcNow:yyyy-MM-dd'T'HH:mm:ss.fff'Z'}] {tool} ({timer.ElapsedMilliseconds}ms)\n IN: {JsonSerializer.Serialize(input)}\n OUT: {preview}\n\n"; + lock (_gate) File.AppendAllText(Path, entry); } catch { From 3d3592a70fc9432c90f4fd6480c6287c64283c34 Mon Sep 17 00:00:00 2001 From: onlyexeption Date: Tue, 20 Oct 2026 11:35:45 +0300 Subject: [PATCH 03/11] fix(api-data): resolve xmlns per namespace and fix inheritance --- CLAUDE.md | 4 +- DEVELOPMENT.md | 10 +- scripts/build-api.ts | 157 ++++++++++++++++++------------ scripts/smoke-test.ts | 27 +++++ scripts/type-extractor/Program.cs | 142 +++++++++++++++++++++++++-- server/Data/Models.cs | 6 +- server/Tools/ComponentTools.cs | 11 ++- server/Tools/ToolDescriptions.cs | 2 + 8 files changed, 282 insertions(+), 77 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 13db20c..fe23dea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,8 @@ Release/publish process (`npm run release -- ` → PR → GitHub Release w ### Data pipeline (build time, `scripts/`) - `nuget/WpfDocs.csproj` pins the Infragistics Trial package version (currently 26.1.21) and is the single place to bump it. `docs:restore` fills `nuget/packages/`. -- `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext` and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName,isNullable,isEnum,enumValues}` plus `"Type.__baseType" → "Base"`. This is the only source of property types, enum values, and inheritance; XML docs alone give just summaries. -- `scripts/build-api.ts` parses the NuGet `*.xml` doc files (split-based chunking, not regex-over-whole-file, to avoid backtracking on multi-MB inputs), merges `type-info.json`, then `enrichWithInheritance()` walks `__baseType` chains (cycle-guarded) copying parent members onto children tagged with `declaredOn`. Types are filtered by `EXCLUDE_SUFFIXES`/`EXCLUDE_CONTAINS`/`EXCLUDE_PACKAGES`; members with summary `"internal"` are dropped. When two types share a short name, the one with more documented members wins and the rest are recorded in `alternates`. `ASSEMBLY_XMLNS` maps assembly → xmlns URI/prefix; anything unlisted gets `http://schemas.infragistics.com/xaml` / `ig`. `namespaces.json` is only `Xam*`-prefixed types. `TFM_PREF` and `byVersionDesc` pick one lib folder per package. +- `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext` and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName,isNullable,isEnum,enumValues}` (instance and static properties, plus static `ResourceKey`/`RoutedCommand` fields marked `isField`), `"__baseTypes"` (`"Assembly|Ns.Type" → "Assembly|Ns.Base"`, generic bases as their definition) and `"__xaml"` (`"Assembly|Ns.Type" → {xmlns, prefix, ns, xamlName?}`). The xmlns comes from System.Xaml's `XamlSchemaContext` over the DLLs' own `XmlnsDefinition`/`XmlnsPrefix` attributes — the most specific xmlns that resolves to exactly that type, else `clr-namespace:` syntax; nested types are `Outer+Inner`. This is the only source of property types, enum values, inheritance and xmlns; XML docs alone give just summaries. +- `scripts/build-api.ts` parses the NuGet `*.xml` doc files (split-based chunking, not regex-over-whole-file, to avoid backtracking on multi-MB inputs), merges `type-info.json` (its regex-parsed strings are JSON-unescaped: the extractor writes `` ` `` and `<` as ```/`<`), then `enrichWithInheritance()` walks the `__baseTypes` chains by assembly + full name (cycle-guarded, and only into the entry that IS that base, not a same-named type) copying parent members onto children tagged with `declaredOn`. Static key/command fields are listed with the properties (`isField`). Types are filtered by `EXCLUDE_SUFFIXES`/`EXCLUDE_CONTAINS`/`EXCLUDE_PACKAGES`; members with summary `"internal"` are dropped. When two types share a short name, the one with more documented members wins and the rest are recorded in `alternates`. Each type's `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from `__xaml`; types it lacks (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:`. `namespaces.json` is only `Xam*`-prefixed types. `TFM_PREF` and `byVersionDesc` pick one lib folder per package. - `scripts/build-docs.ts` converts `.adoc` topics to `docs/{slug}.json` + `docs-index.json`. docs-common topics use `{PlaceholderName}` variables resolved from `docs-common/DocsConfig.xml` for the `wpf` product; only folders in `COMMON_INCLUDE` are taken from docs-common. - `scripts/build-themes.ts` copies `Themes/` (newer ThemeManager family) and `DefaultStyles/` (legacy embedded-BAML `Theme="..."` family) XAML from wpf-resources and records per-file `targetTypes` parsed from `TargetType=` declarations — this is what makes "is component X themeable" lookups content-aware rather than filename-based. - `scripts/build-guard.ts` `assertBuildStep()` is the fail-loud convention: any step whose output could be legitimately zero due to a missing upstream must assert instead of `console.warn`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 87618e2..f34663a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -241,15 +241,19 @@ A version can never be pushed twice, so a rerun of the workflow for a tag that a ### Why reflection at build time -Infragistics WPF NuGet packages ship two files per assembly: `InfragisticsWPF.*.dll` and the compiler-generated `InfragisticsWPF.*.xml` doc comments. The XML gives summaries but no property types, enum values or inheritance; only reflection on the DLL does. So `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext`, walks all public types and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName, isNullable, isEnum, enumValues}` plus `"Type.__baseType" → "Base"`. `scripts/build-api.ts` then merges that with the XML docs. No reflection happens at query time; a tool call is a JSON file read. +Infragistics WPF NuGet packages ship two files per assembly: `InfragisticsWPF.*.dll` and the compiler-generated `InfragisticsWPF.*.xml` doc comments. The XML gives summaries but no property types, enum values or inheritance; only reflection on the DLL does. So `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext`, walks all public types and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName, isNullable, isEnum, enumValues}` (static properties and static `ResourceKey`/`RoutedCommand` fields included, the latter with `isField`), plus `__baseTypes` and `__xaml`, both keyed by `"Assembly|Namespace.Type"` so types sharing a short name can't overwrite each other. `scripts/build-api.ts` then merges that with the XML docs. + +The xmlns of each type is resolved here, not guessed: the extractor reads every DLL's `XmlnsDefinition`/`XmlnsPrefix` attributes and asks System.Xaml's `XamlSchemaContext` — the rules the WPF markup compiler applies — which xmlns resolves to exactly that type. When a namespace has several (e.g. `http://infragistics.com/Themes` and the catch-all `http://schemas.infragistics.com/xaml/wpf`), the one mapping the fewest namespaces wins. Types no Infragistics xmlns reaches (or where a same-named type shadows them) get `clr-namespace:Ns;assembly=Asm`, with a unique `ig…` prefix; nested types get `xamlName` `Outer+Inner`. A DLL whose Infragistics references aren't in the set (the .NET Framework-only Exchange connector) is left out of resolution, because `XamlSchemaContext` throws on every lookup otherwise. Measured on 26.1.21: all 5,092 public non-generic indexed types resolve to the exact type, and a WPF build of 308 sampled XAML files using the generated xmlns succeeds (the previous per-assembly table got 1,984 of them wrong). No reflection happens at query time; a tool call is a JSON file read. ### build-api.ts specifics - XML files are parsed with split-based chunking, not a regex over the whole multi-MB file, and members are bucketed into a `Map` (O(n)). -- `enrichWithInheritance()` walks `__baseType` chains (cycle-guarded) and copies parent members onto children tagged with `declaredOn`. This is why `XamDataGrid` shows dozens of properties although it declares few directly. +- `type-info.json`'s per-member entries are read with regexes, so the captured strings are JSON-unescaped (System.Text.Json writes `` ` `` and `<` as ```/`<`); without that, generic names never matched and `Action` reached agents as `Action`. +- `enrichWithInheritance()` walks `__baseTypes` chains by assembly + full name (cycle-guarded) and copies parent members onto children tagged with `declaredOn`; it only follows a base whose indexed entry IS that type, so a same-named type (chart `Axis` vs Excel `Axis`) can't leak members in. Generic bases (`DataPresenterResourceSet`1`, which holds the theme classes' static styles) are linked through their definition. This is why `XamDataGrid` shows dozens of properties although it declares few directly. +- Static `ResourceKey`/`RoutedCommand` fields (`F:` entries marked `isField` by the extractor — brush keys, commands) are listed with the properties; other fields (enum values, `DependencyProperty` identifiers) are not. - Types matching `EXCLUDE_SUFFIXES` / `EXCLUDE_CONTAINS` / `EXCLUDE_PACKAGES` are dropped, as are members whose summary is literally `internal`. - When two types share a short name, the one documenting more members wins the file name; the others are listed in its `alternates`. -- `ASSEMBLY_XMLNS` maps assembly → xmlns URI/prefix; unlisted assemblies get `http://schemas.infragistics.com/xaml` / `ig`. `namespaces.json` contains only `Xam*`-prefixed types. +- `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from the extractor's `__xaml`; types it has no entry for (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:` syntax, and the build logs how many. `namespaces.json` contains only `Xam*`-prefixed types. ### `get_wpf_api_reference` size guard diff --git a/scripts/build-api.ts b/scripts/build-api.ts index 4343d0a..53ebdc1 100644 --- a/scripts/build-api.ts +++ b/scripts/build-api.ts @@ -21,31 +21,6 @@ const PACKAGES_DIR = join(ROOT, 'nuget', 'packages'); const API_OUT_DIR = join(ROOT, 'src', 'data', 'api'); const NS_OUT_FILE = join(ROOT, 'src', 'data', 'namespaces.json'); -// ── XAML namespace map ──────────────────────────────────────────────────────── -// Maps assembly name → XAML xmlns URI + recommended prefix. -// Sourced from [assembly: XmlnsDefinition] attributes; stable across versions. -const ASSEMBLY_XMLNS: Record = { - 'Infragistics.WPF.DataPresenter': { xmlns: 'http://infragistics.com/DataPresenter', prefix: 'igDP' }, - 'InfragisticsWPF.DataPresenter': { xmlns: 'http://infragistics.com/DataPresenter', prefix: 'igDP' }, - 'Infragistics.WPF.DockManager': { xmlns: 'http://infragistics.com/DockManager', prefix: 'igDock' }, - 'InfragisticsWPF.DockManager': { xmlns: 'http://infragistics.com/DockManager', prefix: 'igDock' }, - 'Infragistics.WPF.Ribbon': { xmlns: 'http://infragistics.com/Ribbon', prefix: 'igRibbon' }, - 'InfragisticsWPF.Ribbon': { xmlns: 'http://infragistics.com/Ribbon', prefix: 'igRibbon' }, - 'Infragistics.WPF.Editors': { xmlns: 'http://infragistics.com/Editors', prefix: 'igEditors' }, - 'InfragisticsWPF.Editors': { xmlns: 'http://infragistics.com/Editors', prefix: 'igEditors' }, - 'Infragistics.WPF.Documents.Excel': { xmlns: 'http://infragistics.com/Excel', prefix: 'igExcel' }, - 'InfragisticsWPF.Documents.Excel': { xmlns: 'http://infragistics.com/Excel', prefix: 'igExcel' }, - 'Infragistics.WPF.DataPresenter.ExcelExporter': { xmlns: 'http://infragistics.com/ExcelExporter', prefix: 'igExcelExporter' }, - 'InfragisticsWPF.DataPresenter.ExcelExporter': { xmlns: 'http://infragistics.com/ExcelExporter', prefix: 'igExcelExporter' }, - 'Infragistics.WPF.DataPresenter.WordWriter': { xmlns: 'http://infragistics.com/WordWriter', prefix: 'igWordWriter' }, - 'InfragisticsWPF.DataPresenter.WordWriter': { xmlns: 'http://infragistics.com/WordWriter', prefix: 'igWordWriter' }, - 'Infragistics.WPF.Reporting': { xmlns: 'http://infragistics.com/Reporting', prefix: 'igRep' }, - 'InfragisticsWPF.Reporting': { xmlns: 'http://infragistics.com/Reporting', prefix: 'igRep' }, - 'Infragistics.WPF.OutlookBar': { xmlns: 'http://infragistics.com/OutlookBar', prefix: 'igOB' }, - 'InfragisticsWPF.OutlookBar': { xmlns: 'http://infragistics.com/OutlookBar', prefix: 'igOB' }, -}; -const DEFAULT_XMLNS = { xmlns: 'http://schemas.infragistics.com/xaml', prefix: 'ig' }; - // ── Type name exclusion list ────────────────────────────────────────────────── const EXCLUDE_SUFFIXES = [ 'AutomationPeer', 'EventArgs', 'Converter', 'Exception', 'Factory', @@ -179,6 +154,8 @@ export interface ApiMemberEntry { isEnum?: boolean; enumValues?: string[]; declaredOn?: string; + /** A static ResourceKey/RoutedCommand field (used via {x:Static}), listed with the properties. */ + isField?: boolean; } export interface AlternateType { @@ -194,6 +171,8 @@ export interface ApiEntry { xamlNamespace: string; defaultPrefix: string; dotnetNamespace: string; + /** Set only for nested types: their XAML name, e.g. "DataPresenterRoyalDark+Locator". */ + xamlName?: string; summary: string; remarks: string; /** Immediate base type name, if it is also an indexed Infragistics type. */ @@ -220,8 +199,12 @@ interface TypeInfoEntry { isNullable: boolean; isEnum: boolean; enumValues?: string[]; + isField?: boolean; } +/** XAML namespace of one type, resolved by the type extractor (see "Phase 3" in type-extractor/Program.cs). */ +interface XamlTypeInfo { xmlns: string; prefix: string; ns: string; xamlName?: string } + function loadTypeInfo(): Record { const path = join(ROOT, 'nuget', 'type-info.json'); assertBuildStep(existsSync(path), @@ -231,29 +214,44 @@ function loadTypeInfo(): Record { ); const raw = readFileSync(path, 'utf-8'); const result: Record = {}; + // The regexes capture raw JSON string contents, and System.Text.Json escapes ` < > + as ` etc.: decode them, + // or "DataPresenterResourceSet`1" never matches and "Action" reaches agents as "Action". + const decode = (s: string): string => JSON.parse(`"${s}"`); - // Parse __baseType string values e.g. "XamCategoryChart.__baseType":"XamXYChart" - const baseRe = /"([^"]+\.__baseType)":"([^"]+)"/g; - let bm: RegExpExecArray | null; - while ((bm = baseRe.exec(raw)) !== null) result[bm[1]] = bm[2]; - - // Parse property type entries - const re = /"([^"]+)":\{"typeName":"([^"]+)","isNullable":(true|false),"isEnum":(true|false)(?:,"enumValues":\[([^\]]*)])?\}/g; + // Parse property (and static field) type entries + const re = /"([^"]+)":\{"typeName":"([^"]+)","isNullable":(true|false),"isEnum":(true|false)(?:,"enumValues":\[([^\]]*)])?(?:,"isField":(true))?\}/g; let m: RegExpExecArray | null; while ((m = re.exec(raw)) !== null) { const enumValuesRaw = m[5]; - result[m[1]] = { - typeName: m[2], + const entry: TypeInfoEntry = { + typeName: decode(m[2]), isNullable: m[3] === 'true', isEnum: m[4] === 'true', enumValues: enumValuesRaw - ? enumValuesRaw.replace(/"/g, '').split(',').filter(Boolean) + ? enumValuesRaw.replace(/"/g, '').split(',').filter(Boolean).map(decode) : undefined, }; + if (m[6]) entry.isField = true; + result[decode(m[1])] = entry; } return result; } +/** + * The extractor's maps keyed by "Assembly|Namespace.Type" (nested: "…|Namespace.Outer.Inner"), so types that share + * a short name can't overwrite each other: each type's XAML namespace and prefix, and its Infragistics base type. + */ +function loadExtractorMaps(): { xaml: Record; baseTypes: Record } { + const parsed = JSON.parse(readFileSync(join(ROOT, 'nuget', 'type-info.json'), 'utf-8')); + const xaml = parsed.__xaml as Record | undefined; + const baseTypes = parsed.__baseTypes as Record | undefined; + assertBuildStep(!!xaml && Object.keys(xaml).length > 0 && !!baseTypes && Object.keys(baseTypes).length > 0, + `nuget/type-info.json has no "__xaml" / "__baseTypes" section — it was written by an older type extractor, so ` + + `every type would get a guessed xmlns and no inherited members. Run: npm run generate:types` + ); + return { xaml: xaml!, baseTypes: baseTypes! }; +} + /** * Second pass: enrich every entry in-memory with inherited Infragistics members. * This walks the full base-type chain generically for ALL indexed types (properties, @@ -263,31 +261,33 @@ function loadTypeInfo(): Record { */ function enrichWithInheritance( entries: Map, - typeInfo: Record + baseTypes: Record ): number { let enriched = 0; + const fullKey = (e: ApiEntry) => `${e.assembly}|${e.dotnetNamespace}.${e.component}`; - for (const [typeName, entry] of entries) { - const immediateBase = typeInfo[`${typeName}.__baseType`]; - if (typeof immediateBase === 'string') entry.baseType = immediateBase; - - if (!(`${typeName}.__baseType` in typeInfo)) continue; + for (const entry of entries.values()) { + const immediateBase = baseTypes[fullKey(entry)]; + if (!immediateBase) continue; + entry.baseType = immediateBase.split('.').pop()!; const knownProps = new Set(entry.properties.map(p => p.name)); const knownEvents = new Set(entry.events.map(e => e.name)); const knownMethods = new Set(entry.methods.map(m => m.name)); - const visited = new Set([typeName]); + const visited = new Set([fullKey(entry)]); let added = false; - let current = typeName; + let current = fullKey(entry); while (true) { - const base = typeInfo[`${current}.__baseType`]; - if (!base || typeof base !== 'string') break; - if (visited.has(base)) break; // cycle guard - visited.add(base); + const baseKey = baseTypes[current]; + if (!baseKey) break; + if (visited.has(baseKey)) break; // cycle guard + visited.add(baseKey); + // Entries are keyed by short name; only follow it if that entry IS this base type, not a same-named one. + const base = baseKey.split('.').pop()!; const baseEntry = entries.get(base); - if (!baseEntry) break; + if (!baseEntry || fullKey(baseEntry) !== baseKey) break; for (const p of baseEntry.properties.filter(p => !p.declaredOn && !knownProps.has(p.name))) { entry.properties.push({ ...p, declaredOn: base }); @@ -304,7 +304,7 @@ function enrichWithInheritance( knownMethods.add(m.name); added = true; } - current = base; + current = baseKey; } if (added) enriched++; @@ -318,6 +318,10 @@ async function generate() { mkdirSync(API_OUT_DIR, { recursive: true }); const typeInfo = loadTypeInfo(); + const { xaml: xamlTypes, baseTypes } = loadExtractorMaps(); + // Prefix per xmlns, for the few types the extractor has no entry for (generics, DLLs left out of XAML resolution). + const prefixByXmlns = new Map(Object.values(xamlTypes).map(x => [x.xmlns, x.prefix])); + const xamlFallbacks: string[] = []; const discovered = discoverXmlFiles(); const sources = discovered.filter(s => !EXCLUDE_PACKAGES.includes(s.packageId)); console.log(`Scanning ${sources.length} XML documentation files...`); @@ -343,12 +347,11 @@ async function generate() { const assemblyMatch = xml.match(/\s*([^<]+)<\/name>/); if (!assemblyMatch) { console.log('skip (no assembly)'); continue; } const assemblyName = assemblyMatch[1].trim(); - const xmlnsInfo = ASSEMBLY_XMLNS[assemblyName] ?? DEFAULT_XMLNS; const members = parseAllMembers(xml); // Build lookup maps once per file — O(n) instead of O(n × types) - const typeMembers = new Map(); + const typeMembers = new Map(); const typeList: RawMember[] = []; for (const m of members) { if (m.kind === 'T') { typeList.push(m); continue; } @@ -360,9 +363,10 @@ async function generate() { const lastDot = memberPath.lastIndexOf('.'); if (lastDot < 0) continue; const typeFqn = memberPath.substring(0, lastDot); - if (!typeMembers.has(typeFqn)) typeMembers.set(typeFqn, { props: [], events: [], methods: [] }); + if (!typeMembers.has(typeFqn)) typeMembers.set(typeFqn, { props: [], fields: [], events: [], methods: [] }); const bucket = typeMembers.get(typeFqn)!; if (m.kind === 'P') bucket.props.push(m); + else if (m.kind === 'F') bucket.fields.push(m); // kept below only if the extractor marked it isField else if (m.kind === 'E') bucket.events.push(m); else if (m.kind === 'M' && !m.name.startsWith('#')) bucket.methods.push(m); } @@ -372,25 +376,41 @@ async function generate() { const typeName = type.name; if (shouldExclude(typeName)) continue; + // For a nested type this is "Namespace.Outer", which keeps the C# full name right; xamlName carries "Outer+Inner". const dotnetNs = type.fullName.substring(0, type.fullName.lastIndexOf('.')); - const bucket = typeMembers.get(type.fullName) ?? { props: [], events: [], methods: [] }; + const bucket = typeMembers.get(type.fullName) ?? { props: [], fields: [], events: [], methods: [] }; + + let xaml = xamlTypes[`${assemblyName}|${type.fullName}`]; + if (!xaml) { + // No resolved entry (open generics can't be named in XAML; DLLs the extractor left out of XAML resolution): + // clr-namespace syntax, which needs no xmlns mapping. + const xmlns = `clr-namespace:${dotnetNs};assembly=${assemblyName}`; + xaml = { xmlns, prefix: prefixByXmlns.get(xmlns) ?? `ig${dotnetNs.split('.').pop()}`, ns: dotnetNs }; + xamlFallbacks.push(type.fullName); + } + + const withTypeInfo = (m: RawMember) => { + const ti = typeInfo[`${typeName}.${m.name}`]; + const extra = (ti && typeof ti === 'object') ? ti : {}; + return { name: m.name, summary: m.summary, ...extra }; + }; const entry: ApiEntry = { component: typeName, assembly: assemblyName, nugetPackage: packageId, - xamlNamespace: xmlnsInfo.xmlns, - defaultPrefix: xmlnsInfo.prefix, + xamlNamespace: xaml.xmlns, + defaultPrefix: xaml.prefix, dotnetNamespace: dotnetNs, + ...(xaml.xamlName ? { xamlName: xaml.xamlName } : {}), summary: type.summary, remarks: type.remarks, - properties: bucket.props - .filter(m => m.summary.toLowerCase() !== 'internal') - .map(m => { - const ti = typeInfo[`${typeName}.${m.name}`]; - const extra = (ti && typeof ti === 'object') ? ti : {}; - return { name: m.name, summary: m.summary, ...extra }; - }), + // Static key/command fields are listed with the properties: both are used the same way in XAML. + properties: [ + ...bucket.props.filter(m => m.summary.toLowerCase() !== 'internal').map(withTypeInfo), + ...bucket.fields.filter(m => m.summary.toLowerCase() !== 'internal').map(withTypeInfo) + .filter(f => (f as ApiMemberEntry).isField === true), + ], events: bucket.events .filter(m => m.summary.toLowerCase() !== 'internal') .map(m => ({ name: m.name, summary: m.summary })), @@ -437,6 +457,10 @@ async function generate() { } console.log(`${Date.now() - t0}ms (${members.length} members)`); } + const genericFallbacks = xamlFallbacks.filter(n => n.includes('`')).length; + console.log(`XAML namespaces: ${xamlFallbacks.length} type(s) fell back to clr-namespace syntax ` + + `(${genericFallbacks} generic, ${xamlFallbacks.length - genericFallbacks} other${xamlFallbacks.length > genericFallbacks ? `: ${xamlFallbacks.filter(n => !n.includes('`')).slice(0, 8).join(', ')}` : ''})`); + for (const [lower, alts] of alternates) { const winner = entries.get(keyByLower.get(lower)!); if (winner) winner.alternates = alts.sort((a, b) => b.memberCount - a.memberCount); @@ -444,7 +468,14 @@ async function generate() { // Enrich in-memory, then write everything at once console.log(`Enriching inheritance...`); - const enriched = enrichWithInheritance(entries, typeInfo); + const enriched = enrichWithInheritance(entries, baseTypes); + + const escaped = [...entries.values()].filter(e => /\\u00[0-9A-Fa-f]{2}/.test(JSON.stringify(e).replace(/\\\\/g, ''))); + assertBuildStep(escaped.length === 0, + `${escaped.length} API entr${escaped.length === 1 ? 'y' : 'ies'} (e.g. ${escaped.slice(0, 3).map(e => e.component).join(', ')}) contain ` + + `JSON escapes such as \\u003C as literal text — a type-info.json string was used without decode(), so agents would ` + + `read "Action\\u003CToolCommand\\u003E" instead of "Action".` + ); console.log(`Writing ${entries.size} API files...`); await Promise.all( diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index eab088c..3c3aac7 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -88,6 +88,10 @@ async function main(): Promise { let r = await call('list_wpf_components', { filter: 'grid' }); let text = textOf(r); check('filter "grid" finds XamDataGrid with its xmlns', !r.isError && text.includes('XamDataGrid') && text.includes('http://infragistics.com/DataPresenter')); + // The xmlns comes from the DLL's own XmlnsDefinition per namespace, not one guess per assembly + // (XamTabControl used to get http://schemas.infragistics.com/xaml, which doesn't compile). + r = await call('list_wpf_components', { filter: 'XamTabControl' }); + check('XamTabControl gets the xmlns its DLL maps it to', !r.isError && textOf(r).includes('xmlns:igWindows="http://infragistics.com/Windows"'), textOf(r).slice(0, 300)); console.log('get_wpf_api_reference'); r = await call('get_wpf_api_reference', { component: 'XamDataGrid', kind: 'all' }); @@ -97,6 +101,29 @@ async function main(): Promise { // kind: properties — small enough to render every member (inherited ones included) in full. r = await call('get_wpf_api_reference', { component: 'XamDataGrid', kind: 'properties' }); check('property types and enum values are attached', /`[A-Za-z.]+` — values: `/.test(textOf(r)), 'no "`Type` — values: `…`" member line — type-info.json was not merged'); + // Theme classes: their xmlns is http://infragistics.com/Themes, and their static styles come from a generic base. + r = await call('get_wpf_api_reference', { component: 'DataPresenterRoyalDark' }); + text = textOf(r); + check('theme class gets the Themes xmlns and its inherited static styles', + !r.isError && text.includes('xmlns:igThemes="http://infragistics.com/Themes"') && text.includes('**CellValuePresenter** `Style`') && text.includes('(inherited from `DataPresenterResourceSet`1`)'), + text.slice(0, 400)); + // Static key/command fields are listed (they used to be dropped: DataPresenterCommands showed 0 of 71 commands). + r = await call('get_wpf_api_reference', { component: 'DataPresenterCommands', kind: 'properties' }); + check('static command fields are listed', !r.isError && textOf(r).includes('**DeleteSelectedDataRecords** `RoutedCommand`'), textOf(r).slice(0, 300)); + // type-info strings are JSON-unescaped (generic type names used to reach agents as Action). + // (C# compacts XamDataChart's inherited properties to names, so ask for the member itself there.) + r = await call('get_wpf_api_reference', IS_DOTNET ? { component: 'XamDataChart', member: 'CommandCompleted' } : { component: 'XamDataChart', kind: 'properties' }); + check('generic type names are not JSON-escaped', !r.isError && textOf(r).includes('`Action`') && !/\\u00[0-9A-F]{2}/.test(textOf(r))); + // Inheritance follows the exact base type: the chart Axis used to show members of the Excel library's ChartObject. + r = await call('get_wpf_api_reference', { component: 'Axis', kind: 'properties' }); + check('chart Axis does not inherit from a same-named type', !r.isError && !textOf(r).includes('ChartObject')); + if (IS_DOTNET) { + // C# only: field label and nested-class XAML name are rendered by the C# server. + r = await call('get_wpf_api_reference', { component: 'DataPresenterCommands', member: 'DeleteSelectedDataRecords' }); + check('static fields are labelled', !r.isError && textOf(r).includes('`RoutedCommand` _(static field)_'), textOf(r).slice(0, 400)); + r = await call('get_wpf_api_reference', { component: 'ActionBase' }); + check('nested classes show their XAML name', !r.isError && textOf(r).includes('**XAML name:** `igHelpers:ActionHistory+ActionBase`'), textOf(r).slice(0, 400)); + } if (IS_DOTNET) { // C# only: the TS server has no size guard / member filter. r = await call('get_wpf_api_reference', { component: 'XamCategoryChart' }); diff --git a/scripts/type-extractor/Program.cs b/scripts/type-extractor/Program.cs index f6a6207..1a00fb8 100644 --- a/scripts/type-extractor/Program.cs +++ b/scripts/type-extractor/Program.cs @@ -8,8 +8,11 @@ * expected by scripts/build-api.ts loadTypeInfo(). * * Output format (flat object): - * "TypeShortName.__baseType" : "BaseTypeShortName" (only for Infragistics bases) - * "TypeShortName.PropName" : { typeName, isNullable, isEnum, enumValues? } + * "__baseTypes" : { "Assembly|Namespace.Type" : "BaseAssembly|BaseNamespace.BaseType" } + * (only for Infragistics bases; a generic base as its definition, "…ResourceSet`1") + * "TypeShortName.PropName" : { typeName, isNullable, isEnum, enumValues? } (instance and static) + * "TypeShortName.FieldName" : { typeName, isNullable, isEnum, isField: true } (static ResourceKey/RoutedCommand fields) + * "__xaml" : { "Assembly|Namespace.Type" (nested: "…|Namespace.Outer.Inner") : { xmlns, prefix, ns, xamlName? } } */ using System; @@ -123,6 +126,7 @@ // Use a plain Dictionary — JsonSerializer serialises this cleanly var output = new SortedDictionary(StringComparer.Ordinal); +var baseTypes = new SortedDictionary(StringComparer.Ordinal); foreach (var asm in loaded) { foreach (var t in SafeGetTypes(asm)) { @@ -131,16 +135,22 @@ var shortName = t.Name; // ── Base type (only emit when the base is also an Infragistics type) ── + // A generic base such as DataPresenterResourceSet has a FullName with type arguments; + // its definition is the known type, and Name ("DataPresenterResourceSet`1") matches build-api's key. + // Keyed by assembly + full name: 72 short names are shared by types with different bases + // (the chart Axis and the Excel Axis), and a short-name key let one overwrite the other. try { var bt = t.BaseType; - if (bt?.FullName != null && knownTypes.ContainsKey(bt.FullName)) - output[$"{shortName}.__baseType"] = bt.Name; + var btDef = bt is { IsGenericType: true } ? bt.GetGenericTypeDefinition() : bt; + if (btDef?.FullName != null && knownTypes.ContainsKey(btDef.FullName)) + baseTypes[$"{asm.GetName().Name}|{t.FullName}"] = $"{btDef.Assembly.GetName().Name}|{btDef.FullName}"; } catch { /* skip */ } // ── Properties (DeclaredOnly so we don't duplicate inherited ones) ──── + // Static ones too: theme classes expose their styles as static properties (DataPresenterResourceSet`1.CellValuePresenter). try { var props = t.GetProperties( - BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly); foreach (var prop in props) { try { @@ -149,10 +159,130 @@ } catch { /* skip unparseable property */ } } } catch { /* skip inaccessible type */ } + + // ── Static key and command fields, used in XAML via {x:Static} ──────── + // (style/brush keys such as DockManagerBrushKeys.*, commands such as DataPresenterCommands.*). + // DependencyProperty and RoutedEvent fields are left out: their property/event is documented already. + if (t.IsEnum) continue; + try { + foreach (var field in t.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)) { + if (!typeof(System.Windows.ResourceKey).IsAssignableFrom(field.FieldType) && + !typeof(System.Windows.Input.RoutedCommand).IsAssignableFrom(field.FieldType)) continue; + var entry = BuildPropertyEntry(field.FieldType); + entry["isField"] = true; + output[$"{shortName}.{field.Name}"] = entry; + } + } catch { /* skip inaccessible type */ } } } -Console.Error.WriteLine($"[TypeExtractor] phase 2: {output.Count} entries"); +output["__baseTypes"] = baseTypes; +Console.Error.WriteLine($"[TypeExtractor] phase 2: {output.Count} entries, {baseTypes.Count} base types"); + +// ── Phase 3: the XAML namespace of every public type ───────────────────────── +// Resolved with System.Xaml's XamlSchemaContext — the XmlnsDefinition rules the WPF markup compiler applies — so +// a type only gets an xmlns that resolves to exactly that type, not a same-named one in another namespace or DLL. +// Several xmlns can map one CLR namespace; the most specific wins (http://infragistics.com/Themes over the +// catch-all http://schemas.infragistics.com/xaml/wpf). Types no Infragistics xmlns reaches get clr-namespace syntax. + +var xmlnsDefs = new List<(string Xmlns, string Ns, string Asm)>(); +var officialPrefixes = new Dictionary(StringComparer.Ordinal); +foreach (var asm in loaded) { + IList attrs; + try { attrs = asm.GetCustomAttributesData(); } catch { continue; } + foreach (var a in attrs) { + if (a.AttributeType.FullName == "System.Windows.Markup.XmlnsDefinitionAttribute") { + // AssemblyName lets one assembly map an xmlns onto types in another. + var target = a.NamedArguments.FirstOrDefault(n => n.MemberName == "AssemblyName").TypedValue.Value as string + ?? asm.GetName().Name!; + xmlnsDefs.Add(((string)a.ConstructorArguments[0].Value!, (string)a.ConstructorArguments[1].Value!, target)); + } else if (a.AttributeType.FullName == "System.Windows.Markup.XmlnsPrefixAttribute") { + officialPrefixes[(string)a.ConstructorArguments[0].Value!] = (string)a.ConstructorArguments[1].Value!; + } + } +} +var namespacesPerXmlns = xmlnsDefs.GroupBy(d => d.Xmlns).ToDictionary(g => g.Key, g => g.Select(d => d.Ns).Distinct().Count()); +// A DLL whose Infragistics references aren't in the set (a .NET Framework-only package referencing InfragisticsWPF) +// makes XamlSchemaContext throw for every xmlns lookup, so it is left out; its types fall back to clr-namespace. +var xamlAssemblies = loaded.Where(a => a.GetReferencedAssemblies() + .Where(r => r.Name!.StartsWith("Infragistics", StringComparison.OrdinalIgnoreCase)) + .All(r => dllByFilename.ContainsKey(r.Name + ".dll"))).ToList(); +foreach (var a in loaded.Except(xamlAssemblies)) + Console.Error.WriteLine($"[TypeExtractor] phase 3: {a.GetName().Name} left out of XAML resolution (references an Infragistics assembly not in the set)"); +var xamlContext = new System.Xaml.XamlSchemaContext(xamlAssemblies); + +bool ResolvesTo(string xmlns, string xamlName, Type t) { + try { return xamlContext.GetXamlType(new System.Xaml.Schema.XamlTypeName(xmlns, xamlName))?.UnderlyingType == t; } + catch { return false; } +} + +var xamlTypes = new List<(string Key, string Ns, string Asm, string XamlName, string Xmlns, bool IsClr)>(); +var unresolved = 0; +foreach (var asm in xamlAssemblies) { + var asmName = asm.GetName().Name!; + foreach (var t in SafeGetTypes(asm)) { + if (!t.IsVisible || t.ContainsGenericParameters || t.FullName == null || + !t.FullName.StartsWith("Infragistics.", StringComparison.Ordinal)) continue; + var ns = t.Namespace ?? ""; + // Nested types are named Outer+Inner in XAML; the XML docs call them Namespace.Outer.Inner. + var xamlName = t.Name; + for (var outer = t.DeclaringType; outer != null; outer = outer.DeclaringType) xamlName = $"{outer.Name}+{xamlName}"; + + var xmlns = xmlnsDefs + .Where(d => d.Ns == ns && string.Equals(d.Asm, asmName, StringComparison.OrdinalIgnoreCase)) + .Select(d => d.Xmlns).Distinct() + .OrderBy(x => namespacesPerXmlns[x]).ThenBy(x => x, StringComparer.Ordinal) + .FirstOrDefault(x => ResolvesTo(x, xamlName, t)); + var isClr = xmlns == null; + xmlns ??= $"clr-namespace:{ns};assembly={asmName}"; + if (isClr && !ResolvesTo(xmlns, xamlName, t)) { unresolved++; continue; } + // Keyed by assembly too: the same Namespace.Type can exist in two DLLs (Caret in RichTextEditor and SyntaxEditor). + xamlTypes.Add(($"{asmName}|{ns}.{xamlName.Replace('+', '.')}", ns, asmName, xamlName, xmlns, isClr)); + } +} + +// Prefixes, one per xmlns: the DLLs' own XmlnsPrefix (igDP, igThemes, …), else "ig" + the xmlns' last path segment. +// A clr-namespace gets "ig" + the shortest tail of its namespace that is unique and doesn't clash with those +// (igCalculationStrategies); when one namespace spans several DLLs, the DLL's last name segment is added +// (igControlsEditorsXamSyntaxEditor), and a counter is the last resort. +string Cap(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; +string XmlnsPrefix(string xmlns) => officialPrefixes.TryGetValue(xmlns, out var p) ? p : "ig" + Cap(xmlns.TrimEnd('/').Split('/').Last()); +var reserved = xamlTypes.Where(x => !x.IsClr).Select(x => XmlnsPrefix(x.Xmlns)).ToHashSet(StringComparer.Ordinal); +var clr = xamlTypes.Where(x => x.IsClr).GroupBy(x => x.Xmlns).ToDictionary(g => g.Key, g => ( + Segments: g.First().Ns.Split('.').Skip(g.First().Ns.StartsWith("Infragistics", StringComparison.Ordinal) ? 1 : 0).Select(Cap).ToArray(), + AsmSegment: Cap(g.First().Asm.Split('.').Last()))); +var depth = clr.Keys.ToDictionary(k => k, _ => 1); +var withAsm = new HashSet(StringComparer.Ordinal); +string ClrPrefix(string xmlns) { + var (segments, asmSegment) = clr[xmlns]; + var tail = string.Concat(segments.TakeLast(depth[xmlns])); + return "ig" + (tail.Length > 0 ? tail : "Infragistics") + (withAsm.Contains(xmlns) ? asmSegment : ""); +} +for (var changed = true; changed;) { + changed = false; + foreach (var group in clr.Keys.GroupBy(ClrPrefix).Where(g => g.Count() > 1 || reserved.Contains(g.Key)).ToList()) + foreach (var xmlns in group) { + if (depth[xmlns] < clr[xmlns].Segments.Length) depth[xmlns]++; + else if (!withAsm.Add(xmlns)) continue; + changed = true; + } +} +var clrPrefixes = clr.Keys.GroupBy(ClrPrefix).SelectMany(g => g.OrderBy(x => x, StringComparer.Ordinal) + .Select((xmlns, i) => (xmlns, prefix: g.Count() == 1 && !reserved.Contains(g.Key) ? g.Key : $"{g.Key}{i + 1}"))) + .ToDictionary(x => x.xmlns, x => x.prefix); + +var xamlOut = new SortedDictionary(StringComparer.Ordinal); +foreach (var x in xamlTypes) { + var info = new Dictionary { + ["xmlns"] = x.Xmlns, + ["prefix"] = x.IsClr ? clrPrefixes[x.Xmlns] : XmlnsPrefix(x.Xmlns), + ["ns"] = x.Ns, + }; + if (x.XamlName.Contains('+')) info["xamlName"] = x.XamlName; + xamlOut[x.Key] = info; +} +output["__xaml"] = xamlOut; +Console.Error.WriteLine($"[TypeExtractor] phase 3: {xamlOut.Count} XAML namespaces ({xamlTypes.Count(x => x.IsClr)} clr-namespace, {unresolved} unresolvable)"); // ── Write JSON ──────────────────────────────────────────────────────────────── diff --git a/server/Data/Models.cs b/server/Data/Models.cs index 2489e5e..016d93f 100644 --- a/server/Data/Models.cs +++ b/server/Data/Models.cs @@ -21,7 +21,8 @@ public sealed record ApiMemberEntry( bool? IsNullable, bool? IsEnum, string[]? EnumValues, - string? DeclaredOn); + string? DeclaredOn, + bool? IsField = null); // a static ResourceKey/RoutedCommand field, listed with the properties public sealed record AlternateType(string FullName, string NugetPackage, int MemberCount); @@ -38,7 +39,8 @@ public sealed record ApiEntry( AlternateType[]? Alternates, ApiMemberEntry[] Properties, ApiMemberEntry[] Events, - ApiMemberEntry[] Methods); + ApiMemberEntry[] Methods, + string? XamlName = null); // nested types only: "Outer+Inner" /// Compact entry in search-index.json — single-letter keys keep the ~5MB index small. public sealed record SearchIndexEntry( diff --git a/server/Tools/ComponentTools.cs b/server/Tools/ComponentTools.cs index 31dae5c..aef9dbe 100644 --- a/server/Tools/ComponentTools.cs +++ b/server/Tools/ComponentTools.cs @@ -97,6 +97,7 @@ private static string FormatMember(ApiMemberEntry m) if (m.IsEnum == true && m.EnumValues is { Length: > 0 }) line += $" — values: `{string.Join("` | `", m.EnumValues)}`"; } + if (m.IsField == true) line += " _(static field)_"; line += $": {(string.IsNullOrEmpty(m.Summary) ? "—" : m.Summary)}"; return line; } @@ -214,13 +215,21 @@ or to get details of members a compacted response lists by name only. $"# {doc.Component}", "", $"**xmlns:** `xmlns:{doc.DefaultPrefix}=\"{doc.XamlNamespace}\"`", + }; + if (doc.XamlName is { } xamlName) + output.Add($"**XAML name:** `{doc.DefaultPrefix}:{xamlName}` (nested class)"); + // clr-namespace names the assembly, and only the .NET (8+) builds are called Infragistics.WPF.*. + if (doc.XamlNamespace.StartsWith("clr-namespace:", StringComparison.Ordinal) && doc.Assembly.StartsWith("Infragistics.WPF", StringComparison.Ordinal)) + output.Add($"_.NET Framework 4.x apps: use `assembly=InfragisticsWPF{doc.Assembly["Infragistics.WPF".Length..]}` (that build's assembly name)._"); + output.AddRange(new[] + { $"**NuGet:** `{doc.NugetPackage}`", $"**Assembly:** `{doc.Assembly}`", $"**Full type name:** `{doc.DotnetNamespace}.{doc.Component}`", "", "## Summary", summary, - }; + }); if (doc.Alternates is { Length: > 0 }) { diff --git a/server/Tools/ToolDescriptions.cs b/server/Tools/ToolDescriptions.cs index f942866..1279cba 100644 --- a/server/Tools/ToolDescriptions.cs +++ b/server/Tools/ToolDescriptions.cs @@ -25,6 +25,8 @@ public static class ToolDescriptions Use after list_wpf_components (or search_wpf_api, if the exact type name isn't known yet) to resolve the component name. Also use to check whether a specific property or event exists on a known type — it returns the complete member list, including members inherited from Infragistics base classes (grouped under "inherited from `Base`" headings). + The xmlns line is resolved from the type's own assembly, so use it exactly as given — it can differ between types of one control (e.g. igDP for XamDataGrid, igThemes for DataPresenterRoyalDark, igPrim for primitives, or a clr-namespace: line). Static resource keys and commands (e.g. DockManagerBrushKeys.*, DataPresenterCommands.*) are listed with the properties, marked "(static field)" — use them via {x:Static prefix:Type.Member}. Nested classes show a "XAML name" (Outer+Inner). + Large types (mostly charts and grids, whose members are largely inherited) are compacted to stay under ~25K characters, giving up detail in this order until the response fits: events and methods are listed by name only first (properties keep full detail), then inherited members, and as a last resort all members. The response then says so in an "ℹ️ Compacted" line. Every member name is always listed. For full properties only, kind: "properties" is often small enough to come back uncompacted. To get full details (type, enum values, summary) for specific members, pass member: a case-insensitive name substring, combinable with kind — e.g. get_wpf_api_reference("XamCategoryChart", member: "YAxis") returns every YAxis* member, inherited ones included. When you already know which members you need, pass member up front. If a type's own member list is unexpectedly sparse, the response names its base type — call get_wpf_api_reference again on that base type to see the rest of the inherited surface. From c282f4d8ca70ba551cef3460254529d0f41160c4 Mon Sep 17 00:00:00 2001 From: onlyexeption Date: Tue, 20 Oct 2026 11:49:41 +0300 Subject: [PATCH 04/11] fix(api-data): stop indexing non-public classes --- CLAUDE.md | 4 ++-- DEVELOPMENT.md | 9 +++++---- README.md | 6 +++--- scripts/build-api.ts | 19 +++++++++++++------ scripts/smoke-test.ts | 14 ++++++++++++++ scripts/type-extractor/Program.cs | 17 +++++++++++++++++ scripts/validate-package.ts | 2 +- server/README.md | 2 +- server/Tools/ComponentTools.cs | 2 +- 9 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe23dea..2748520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,8 +46,8 @@ Release/publish process (`npm run release -- ` → PR → GitHub Release w ### Data pipeline (build time, `scripts/`) - `nuget/WpfDocs.csproj` pins the Infragistics Trial package version (currently 26.1.21) and is the single place to bump it. `docs:restore` fills `nuget/packages/`. -- `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext` and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName,isNullable,isEnum,enumValues}` (instance and static properties, plus static `ResourceKey`/`RoutedCommand` fields marked `isField`), `"__baseTypes"` (`"Assembly|Ns.Type" → "Assembly|Ns.Base"`, generic bases as their definition) and `"__xaml"` (`"Assembly|Ns.Type" → {xmlns, prefix, ns, xamlName?}`). The xmlns comes from System.Xaml's `XamlSchemaContext` over the DLLs' own `XmlnsDefinition`/`XmlnsPrefix` attributes — the most specific xmlns that resolves to exactly that type, else `clr-namespace:` syntax; nested types are `Outer+Inner`. This is the only source of property types, enum values, inheritance and xmlns; XML docs alone give just summaries. -- `scripts/build-api.ts` parses the NuGet `*.xml` doc files (split-based chunking, not regex-over-whole-file, to avoid backtracking on multi-MB inputs), merges `type-info.json` (its regex-parsed strings are JSON-unescaped: the extractor writes `` ` `` and `<` as ```/`<`), then `enrichWithInheritance()` walks the `__baseTypes` chains by assembly + full name (cycle-guarded, and only into the entry that IS that base, not a same-named type) copying parent members onto children tagged with `declaredOn`. Static key/command fields are listed with the properties (`isField`). Types are filtered by `EXCLUDE_SUFFIXES`/`EXCLUDE_CONTAINS`/`EXCLUDE_PACKAGES`; members with summary `"internal"` are dropped. When two types share a short name, the one with more documented members wins and the rest are recorded in `alternates`. Each type's `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from `__xaml`; types it lacks (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:`. `namespaces.json` is only `Xam*`-prefixed types. `TFM_PREF` and `byVersionDesc` pick one lib folder per package. +- `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext` and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName,isNullable,isEnum,enumValues}` (instance and static properties, plus static `ResourceKey`/`RoutedCommand` fields marked `isField`), `"__baseTypes"` (`"Assembly|Ns.Type" → "Assembly|Ns.Base"`, generic bases as their definition) and `"__xaml"` (`"Assembly|Ns.Type" → {xmlns, prefix, ns, xamlName?}`) and `"__nonPublicTypes"` (internal / private nested types reflection positively sees as non-public; a type that can't be loaded is never listed). The xmlns comes from System.Xaml's `XamlSchemaContext` over the DLLs' own `XmlnsDefinition`/`XmlnsPrefix` attributes — the most specific xmlns that resolves to exactly that type, else `clr-namespace:` syntax; nested types are `Outer+Inner`. This is the only source of property types, enum values, inheritance and xmlns; XML docs alone give just summaries. +- `scripts/build-api.ts` parses the NuGet `*.xml` doc files (split-based chunking, not regex-over-whole-file, to avoid backtracking on multi-MB inputs), merges `type-info.json` (its regex-parsed strings are JSON-unescaped: the extractor writes `` ` `` and `<` as ```/`<`), then `enrichWithInheritance()` walks the `__baseTypes` chains by assembly + full name (cycle-guarded, and only into the entry that IS that base, not a same-named type) copying parent members onto children tagged with `declaredOn`. Static key/command fields are listed with the properties (`isField`). Types are filtered by `EXCLUDE_SUFFIXES`/`EXCLUDE_CONTAINS`/`EXCLUDE_PACKAGES`, and non-public types (the XML docs also document ~2,000 internal classes such as `ActivePaneManager`) are left out via `__nonPublicTypes` before short-name collisions are resolved, so a public type wins its name; members with summary `"internal"` are dropped. When two types share a short name, the one with more documented members wins and the rest are recorded in `alternates`. Each type's `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from `__xaml`; types it lacks (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:`. `namespaces.json` is only `Xam*`-prefixed types. `TFM_PREF` and `byVersionDesc` pick one lib folder per package. - `scripts/build-docs.ts` converts `.adoc` topics to `docs/{slug}.json` + `docs-index.json`. docs-common topics use `{PlaceholderName}` variables resolved from `docs-common/DocsConfig.xml` for the `wpf` product; only folders in `COMMON_INCLUDE` are taken from docs-common. - `scripts/build-themes.ts` copies `Themes/` (newer ThemeManager family) and `DefaultStyles/` (legacy embedded-BAML `Theme="..."` family) XAML from wpf-resources and records per-file `targetTypes` parsed from `TargetType=` declarations — this is what makes "is component X themeable" lookups content-aware rather than filename-based. - `scripts/build-guard.ts` `assertBuildStep()` is the fail-loud convention: any step whose output could be legitimately zero due to a missing upstream must assert instead of `console.warn`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f34663a..5032ebe 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -39,7 +39,7 @@ First run takes 2–5 minutes, almost all of it the NuGet restore and the type e A healthy build ends with these counts (they drift slightly between Infragistics versions): ``` -Registry: 191 Xam* controls → src/data/namespaces.json +Registry: 189 Xam* controls → src/data/namespaces.json Search index: 6907 types → src/data/search-index.json Topics written : 2678 theme-index.json: 8 newer themes, 45 legacy style folders, 822 XAML files total. @@ -243,7 +243,7 @@ A version can never be pushed twice, so a rerun of the workflow for a tag that a Infragistics WPF NuGet packages ship two files per assembly: `InfragisticsWPF.*.dll` and the compiler-generated `InfragisticsWPF.*.xml` doc comments. The XML gives summaries but no property types, enum values or inheritance; only reflection on the DLL does. So `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext`, walks all public types and writes `nuget/type-info.json` — a flat map of `"Type.Prop" → {typeName, isNullable, isEnum, enumValues}` (static properties and static `ResourceKey`/`RoutedCommand` fields included, the latter with `isField`), plus `__baseTypes` and `__xaml`, both keyed by `"Assembly|Namespace.Type"` so types sharing a short name can't overwrite each other. `scripts/build-api.ts` then merges that with the XML docs. -The xmlns of each type is resolved here, not guessed: the extractor reads every DLL's `XmlnsDefinition`/`XmlnsPrefix` attributes and asks System.Xaml's `XamlSchemaContext` — the rules the WPF markup compiler applies — which xmlns resolves to exactly that type. When a namespace has several (e.g. `http://infragistics.com/Themes` and the catch-all `http://schemas.infragistics.com/xaml/wpf`), the one mapping the fewest namespaces wins. Types no Infragistics xmlns reaches (or where a same-named type shadows them) get `clr-namespace:Ns;assembly=Asm`, with a unique `ig…` prefix; nested types get `xamlName` `Outer+Inner`. A DLL whose Infragistics references aren't in the set (the .NET Framework-only Exchange connector) is left out of resolution, because `XamlSchemaContext` throws on every lookup otherwise. Measured on 26.1.21: all 5,092 public non-generic indexed types resolve to the exact type, and a WPF build of 308 sampled XAML files using the generated xmlns succeeds (the previous per-assembly table got 1,984 of them wrong). No reflection happens at query time; a tool call is a JSON file read. +The xmlns of each type is resolved here, not guessed: the extractor reads every DLL's `XmlnsDefinition`/`XmlnsPrefix` attributes and asks System.Xaml's `XamlSchemaContext` — the rules the WPF markup compiler applies — which xmlns resolves to exactly that type. When a namespace has several (e.g. `http://infragistics.com/Themes` and the catch-all `http://schemas.infragistics.com/xaml/wpf`), the one mapping the fewest namespaces wins. Types no Infragistics xmlns reaches (or where a same-named type shadows them) get `clr-namespace:Ns;assembly=Asm`, with a unique `ig…` prefix; nested types get `xamlName` `Outer+Inner`. A DLL whose Infragistics references aren't in the set (the .NET Framework-only Exchange connector) is left out of resolution, because `XamlSchemaContext` throws on every lookup otherwise. Measured on 26.1.21: all 5,180 public non-generic indexed types resolve to the exact type, and a WPF build of 308 sampled XAML files using the generated xmlns succeeds (the previous per-assembly table got 1,984 of them wrong). No reflection happens at query time; a tool call is a JSON file read. ### build-api.ts specifics @@ -252,6 +252,7 @@ The xmlns of each type is resolved here, not guessed: the extractor reads every - `enrichWithInheritance()` walks `__baseTypes` chains by assembly + full name (cycle-guarded) and copies parent members onto children tagged with `declaredOn`; it only follows a base whose indexed entry IS that type, so a same-named type (chart `Axis` vs Excel `Axis`) can't leak members in. Generic bases (`DataPresenterResourceSet`1`, which holds the theme classes' static styles) are linked through their definition. This is why `XamDataGrid` shows dozens of properties although it declares few directly. - Static `ResourceKey`/`RoutedCommand` fields (`F:` entries marked `isField` by the extractor — brush keys, commands) are listed with the properties; other fields (enum values, `DependencyProperty` identifiers) are not. - Types matching `EXCLUDE_SUFFIXES` / `EXCLUDE_CONTAINS` / `EXCLUDE_PACKAGES` are dropped, as are members whose summary is literally `internal`. +- Non-public types are dropped too. The XML docs also document internal and private nested classes (`ActivePaneManager`, `XamFunnelController`, …) that no app can use — on 26.1.21 they were 1,653 of the 6,907 pages, and search ranked some first ("active pane" → `ActivePaneManager`). The extractor lists the types reflection positively sees as non-public (`__nonPublicTypes`; a type it can't load is never listed, so a load failure can't hide public API), and `build-api.ts` skips them before short-name collisions are resolved, so 89 names that an internal class used to win now document the public class (`DashboardTileGroupDescription`). No public type inherits from a non-public one (C# forbids it), so no inherited members are lost. - When two types share a short name, the one documenting more members wins the file name; the others are listed in its `alternates`. - `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from the extractor's `__xaml`; types it has no entry for (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:` syntax, and the build logs how many. `namespaces.json` contains only `Xam*`-prefixed types. @@ -263,7 +264,7 @@ Because inherited members are copied onto every subclass, a few hundred types (m 2. Inherited members are listed by name only, one line per "inherited from" section (chosen over name + type: at a 25K threshold name-only gives `XamCategoryChart` 19.8K and leaves 8 types over the limit, while name + type gives 26.2K and leaves 12). 3. All members are listed by name only (6 types that declare 250+ members themselves, e.g. `Series`, `XamDomainChart`). -Section headings always stay, so every member name and its declaring type remain visible. A compacted response says so and points to the optional `member` parameter, a case-insensitive name substring that returns only the matching members, inherited ones included, in full detail. The guard applies to `member`-filtered responses as well, so a very broad filter can't bypass it. Measured over all 6,907 types of the Infragistics 26.1.21 data (re-measure after a data bump): max 71,686 → 24,863 chars, p99 58,954 → 16,717; 211 types are compacted (32 at level 1, among them `XamDataGrid` 19.0K, `XamTreeGrid` 19.0K and `XamGrid` 14.9K with every property in full; 173 at level 2, among them the charts; 6 at level 3) and all others render byte-identically to before. +Section headings always stay, so every member name and its declaring type remain visible. A compacted response says so and points to the optional `member` parameter, a case-insensitive name substring that returns only the matching members, inherited ones included, in full detail. The guard applies to `member`-filtered responses as well, so a very broad filter can't bypass it. Measured over all 5,343 public types of the Infragistics 26.1.21 data (re-measure after a data bump): max 116,994 → 24,914 chars (`RibbonBrushKeys`, whose keys are listed as static fields), p99 58,621 → 18,007; 213 types are compacted (30 at level 1, among them `XamDataGrid` 20.5K, `XamTreeGrid` 20.5K and `XamGrid` 14.9K with every property in full; 173 at level 2, among them the charts; 10 at level 3, among them the four `…BrushKeys` classes) and all others render in full. ### Two runtimes, one data set @@ -273,7 +274,7 @@ Section headings always stay, so every member name and its declaring type remain | File | Size | Loaded | Purpose | |---|---|---|---| -| `dist/data/namespaces.json` | ~50 KB | at startup | 191 Xam* control registry | +| `dist/data/namespaces.json` | ~50 KB | at startup | 189 Xam* control registry | | `dist/data/search-index.json` | ~5 MB | at startup | compact index (`n,s,a,p,m` keys): name, summary, member names | | `dist/data/docs-index.json` | ~1 MB | at startup | topic slugs, titles, control names, summaries | | `dist/data/theme-index.json` | small | at startup | theme/style files with parsed `targetTypes` | diff --git a/README.md b/README.md index 524a2e1..e8788d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # wpf-mcp -MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 6,900+ types, and named-theme setup with verified recolor steps. +MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 5,300+ public types, and named-theme setup with verified recolor steps. Distributed as a NuGet `dotnet tool`, [`Infragistics.Wpf.Mcp`](https://www.nuget.org/packages/Infragistics.Wpf.Mcp) — currently in **beta** (`0.1.0-beta.0`). From the first stable release it will also be listed in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.Infragistics-Developer-Tools/wpf-mcp`. @@ -10,9 +10,9 @@ All tools are read-only (`readOnlyHint: true`, `openWorldHint: false`) — none | Tool | Description | |---|---| -| `list_wpf_components` | List all 191 Xam* controls with canonical XAML namespace URIs, NuGet packages, and descriptions. Always call this first before writing XAML. | +| `list_wpf_components` | List all 189 Xam* controls with canonical XAML namespace URIs, NuGet packages, and descriptions. Always call this first before writing XAML. | | `get_wpf_project_scaffold` | Generate ready-to-run `dotnet new` + `dotnet add package` + `dotnet restore` commands and xmlns declarations for a new WPF project. Pass component names resolved via `list_wpf_components`. | -| `search_wpf_api` | Search across all 6,900+ types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | +| `search_wpf_api` | Search across all 5,300+ public types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | | `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details for just the members you need. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. Call first for any theming task. | | `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Use the exact `path` from `setup_wpf_theme`. | diff --git a/scripts/build-api.ts b/scripts/build-api.ts index 53ebdc1..ccecf8a 100644 --- a/scripts/build-api.ts +++ b/scripts/build-api.ts @@ -241,15 +241,17 @@ function loadTypeInfo(): Record { * The extractor's maps keyed by "Assembly|Namespace.Type" (nested: "…|Namespace.Outer.Inner"), so types that share * a short name can't overwrite each other: each type's XAML namespace and prefix, and its Infragistics base type. */ -function loadExtractorMaps(): { xaml: Record; baseTypes: Record } { +function loadExtractorMaps(): { xaml: Record; baseTypes: Record; nonPublic: Set } { const parsed = JSON.parse(readFileSync(join(ROOT, 'nuget', 'type-info.json'), 'utf-8')); const xaml = parsed.__xaml as Record | undefined; const baseTypes = parsed.__baseTypes as Record | undefined; - assertBuildStep(!!xaml && Object.keys(xaml).length > 0 && !!baseTypes && Object.keys(baseTypes).length > 0, - `nuget/type-info.json has no "__xaml" / "__baseTypes" section — it was written by an older type extractor, so ` + - `every type would get a guessed xmlns and no inherited members. Run: npm run generate:types` + const nonPublic = parsed.__nonPublicTypes as string[] | undefined; + assertBuildStep(!!xaml && Object.keys(xaml).length > 0 && !!baseTypes && Object.keys(baseTypes).length > 0 && !!nonPublic?.length, + `nuget/type-info.json has no "__xaml" / "__baseTypes" / "__nonPublicTypes" section — it was written by an older ` + + `type extractor, so every type would get a guessed xmlns, no inherited members, and internal classes would be ` + + `indexed as usable. Run: npm run generate:types` ); - return { xaml: xaml!, baseTypes: baseTypes! }; + return { xaml: xaml!, baseTypes: baseTypes!, nonPublic: new Set(nonPublic) }; } /** @@ -318,7 +320,8 @@ async function generate() { mkdirSync(API_OUT_DIR, { recursive: true }); const typeInfo = loadTypeInfo(); - const { xaml: xamlTypes, baseTypes } = loadExtractorMaps(); + const { xaml: xamlTypes, baseTypes, nonPublic } = loadExtractorMaps(); + let skippedNonPublic = 0; // Prefix per xmlns, for the few types the extractor has no entry for (generics, DLLs left out of XAML resolution). const prefixByXmlns = new Map(Object.values(xamlTypes).map(x => [x.xmlns, x.prefix])); const xamlFallbacks: string[] = []; @@ -375,6 +378,9 @@ async function generate() { if (!type.fullName.startsWith('Infragistics.')) continue; const typeName = type.name; if (shouldExclude(typeName)) continue; + // The XML docs also document internal / private nested classes, which apps can't use. Leaving them out here, + // before short-name collisions are resolved, also lets a public type win its name (DashboardTileGroupDescription). + if (nonPublic.has(`${assemblyName}|${type.fullName}`)) { skippedNonPublic++; continue; } // For a nested type this is "Namespace.Outer", which keeps the C# full name right; xamlName carries "Outer+Inner". const dotnetNs = type.fullName.substring(0, type.fullName.lastIndexOf('.')); @@ -457,6 +463,7 @@ async function generate() { } console.log(`${Date.now() - t0}ms (${members.length} members)`); } + console.log(`Left out ${skippedNonPublic} non-public (internal / private nested) type(s) documented in the XML.`); const genericFallbacks = xamlFallbacks.filter(n => n.includes('`')).length; console.log(`XAML namespaces: ${xamlFallbacks.length} type(s) fell back to clr-namespace syntax ` + `(${genericFallbacks} generic, ${xamlFallbacks.length - genericFallbacks} other${xamlFallbacks.length > genericFallbacks ? `: ${xamlFallbacks.filter(n => !n.includes('`')).slice(0, 8).join(', ')}` : ''})`); diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 3c3aac7..7e920df 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -92,6 +92,10 @@ async function main(): Promise { // (XamTabControl used to get http://schemas.infragistics.com/xaml, which doesn't compile). r = await call('list_wpf_components', { filter: 'XamTabControl' }); check('XamTabControl gets the xmlns its DLL maps it to', !r.isError && textOf(r).includes('xmlns:igWindows="http://infragistics.com/Windows"'), textOf(r).slice(0, 300)); + // Internal classes are not indexed: the XML docs document them too, but no app can use them. + r = await call('list_wpf_components', { filter: 'Funnel' }); + text = textOf(r); + check('internal classes are not listed as controls', !r.isError && text.includes('XamFunnelChart') && !text.includes('XamFunnelController') && !text.includes('XamFunnelConnector'), text.slice(0, 300)); console.log('get_wpf_api_reference'); r = await call('get_wpf_api_reference', { component: 'XamDataGrid', kind: 'all' }); @@ -117,6 +121,13 @@ async function main(): Promise { // Inheritance follows the exact base type: the chart Axis used to show members of the Excel library's ChartObject. r = await call('get_wpf_api_reference', { component: 'Axis', kind: 'properties' }); check('chart Axis does not inherit from a same-named type', !r.isError && !textOf(r).includes('ChartObject')); + // Internal classes have no page, and a name an internal class used to win now documents the public class. + r = await call('get_wpf_api_reference', { component: 'ActivePaneManager' }); + check('internal class has no API page', r.isError === true, textOf(r).slice(0, 200)); + r = await call('get_wpf_api_reference', { component: 'DashboardTileGroupDescription' }); + text = textOf(r); + check('public type wins its name over an internal one', + !r.isError && text.includes('`Infragistics.Controls.Dashboards.DashboardTileGroupDescription`') && !text.includes('.Implementation.'), text.slice(0, 400)); if (IS_DOTNET) { // C# only: field label and nested-class XAML name are rendered by the C# server. r = await call('get_wpf_api_reference', { component: 'DataPresenterCommands', member: 'DeleteSelectedDataRecords' }); @@ -153,6 +164,9 @@ async function main(): Promise { console.log('search_wpf_api'); r = await call('search_wpf_api', { query: 'filter', limit: 5 }); check('keyword search returns ranked results', !r.isError && textOf(r).includes('# WPF API Search')); + // "active pane" used to rank the internal ActivePaneManager first. + r = await call('search_wpf_api', { query: 'active pane', limit: 10 }); + check('search does not return internal classes', !r.isError && !textOf(r).includes('ActivePaneManager'), textOf(r).slice(0, 300)); console.log('get_wpf_project_scaffold'); r = await call('get_wpf_project_scaffold', { components: ['XamDataGrid', 'XamCategoryChart'], projectName: 'SmokeApp', framework: 'net10.0' }); diff --git a/scripts/type-extractor/Program.cs b/scripts/type-extractor/Program.cs index 1a00fb8..423c087 100644 --- a/scripts/type-extractor/Program.cs +++ b/scripts/type-extractor/Program.cs @@ -13,6 +13,7 @@ * "TypeShortName.PropName" : { typeName, isNullable, isEnum, enumValues? } (instance and static) * "TypeShortName.FieldName" : { typeName, isNullable, isEnum, isField: true } (static ResourceKey/RoutedCommand fields) * "__xaml" : { "Assembly|Namespace.Type" (nested: "…|Namespace.Outer.Inner") : { xmlns, prefix, ns, xamlName? } } + * "__nonPublicTypes" : [ "Assembly|Namespace.Type", … ] (internal / private nested types, to leave out) */ using System; @@ -179,6 +180,22 @@ output["__baseTypes"] = baseTypes; Console.Error.WriteLine($"[TypeExtractor] phase 2: {output.Count} entries, {baseTypes.Count} base types"); +// ── Non-public types ───────────────────────────────────────────────────────── +// The XML docs also document internal and private nested classes (ActivePaneManager, XamFunnelController, …), +// which no app can use. Only types reflection positively sees as non-public are listed — a type that can't be +// loaded (e.g. a .NET Framework-only DLL) is not, so a load failure can never hide public API. +var nonPublicTypes = new SortedSet(StringComparer.Ordinal); +foreach (var asm in loaded) { + Type?[] all; + try { all = asm.GetTypes(); } catch (ReflectionTypeLoadException ex) { all = ex.Types; } catch { continue; } + foreach (var t in all) { + if (t?.FullName == null || t.IsVisible || !t.FullName.StartsWith("Infragistics.", StringComparison.Ordinal)) continue; + nonPublicTypes.Add($"{asm.GetName().Name}|{t.FullName.Replace('+', '.')}"); + } +} +output["__nonPublicTypes"] = nonPublicTypes; +Console.Error.WriteLine($"[TypeExtractor] non-public types: {nonPublicTypes.Count}"); + // ── Phase 3: the XAML namespace of every public type ───────────────────────── // Resolved with System.Xaml's XamlSchemaContext — the XmlnsDefinition rules the WPF markup compiler applies — so // a type only gets an xmlns that resolves to exactly that type, not a same-named one in another namespace or DLL. diff --git a/scripts/validate-package.ts b/scripts/validate-package.ts index 88fbfed..63b6036 100644 --- a/scripts/validate-package.ts +++ b/scripts/validate-package.ts @@ -31,7 +31,7 @@ const TOOL_DIR = 'tools/net10.0/any/'; const MIN_COMPONENTS = 150; const MIN_SEARCH_ENTRIES = 5000; const MIN_SEARCH_BYTES = 2 * 1024 * 1024; -const MIN_API_FILES = 6000; +const MIN_API_FILES = 5000; // ~5,340 public types on 26.1.21 (internal classes are not indexed) const MIN_DOC_TOPICS = 2000; const MIN_NEWER_THEMES = 5; const MIN_LEGACY_FOLDERS = 20; diff --git a/server/README.md b/server/README.md index feb5196..ee20934 100644 --- a/server/README.md +++ b/server/README.md @@ -2,7 +2,7 @@ -MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 6,900+ types, 2,600+ documentation topics, and named-theme setup with verified recolor steps. Everything an AI coding agent needs to write correct Infragistics XAML instead of guessing. +MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 5,300+ public types, 2,600+ documentation topics, and named-theme setup with verified recolor steps. Everything an AI coding agent needs to write correct Infragistics XAML instead of guessing. Packaged as a `dotnet tool`. All Infragistics data ships inside the package; nothing is downloaded at runtime, and every tool is read-only. diff --git a/server/Tools/ComponentTools.cs b/server/Tools/ComponentTools.cs index aef9dbe..1ada04e 100644 --- a/server/Tools/ComponentTools.cs +++ b/server/Tools/ComponentTools.cs @@ -166,7 +166,7 @@ private static List RenderMemberSections((ApiMemberEntry m, string kind) [Description(ToolDescriptions.GetWpfApiReference)] public CallToolResult GetApiReference( [Description(""" - Any Infragistics WPF type name — 7000+ types including controls (e.g. "XamDataGrid"), + Any public Infragistics WPF type name — 5,300+ types including controls (e.g. "XamDataGrid"), supporting types (e.g. "FieldLayout", "SummaryDefinition"), enums, strategies, indicators, and more. Case-insensitive. To find a Xam* control name use list_wpf_components. To discover any other type name use search_wpf_api. """)] From c05c166c9bb1e3dfbf9488a707365cae4299ef05 Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Tue, 20 Oct 2026 12:03:34 +0300 Subject: [PATCH 05/11] fix: ensure package IDs maintain canonical casing in validation and tests --- scripts/build-api.ts | 20 +++++++++++++++++--- scripts/smoke-test.ts | 1 + scripts/validate-package.ts | 3 +++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/build-api.ts b/scripts/build-api.ts index 4343d0a..5267c87 100644 --- a/scripts/build-api.ts +++ b/scripts/build-api.ts @@ -55,7 +55,7 @@ const EXCLUDE_CONTAINS = ['Internal', '<>', 'AnonymousType']; // XamXGrid is public and used internally for xplat components, but may // become customer-facing later, so this is a plain omission, not a "deprecated" label. -const EXCLUDE_PACKAGES = ['infragistics.wpf.controls.grids.xamxgrid.trial']; +const EXCLUDE_PACKAGES = ['infragistics.wpf.controls.grids.xamxgrid.trial']; // compared lowercase function shouldExclude(typeName: string): boolean { return ( @@ -127,6 +127,19 @@ const TFM_PREF = ['net10.0-windows7.0', 'net9.0-windows7.0', 'net8.0-windows7.0' interface XmlSource { xmlPath: string; packageId: string } +// nuget/packages/ is NuGet's global-packages layout, whose folder names are the lowercased ID. +// The .nuspec keeps the canonical casing ("Infragistics.WPF.DataGrids.Trial"), which is what +// agents copy into a user's .csproj via `dotnet add package`. +function readPackageId(versionDir: string, folderName: string): string { + const nuspec = join(versionDir, `${folderName}.nuspec`); + const id = existsSync(nuspec) ? /\s*([^<\s]+)\s*<\/id>/.exec(readFileSync(nuspec, 'utf8'))?.[1] : undefined; + assertBuildStep(id?.toLowerCase() === folderName, + `${nuspec}: expected an matching the folder name "${folderName}", got ${id ? `"${id}"` : 'none'} — ` + + `partial/failed restore? Re-run: npm run docs:restore` + ); + return id!; +} + function discoverXmlFiles(): XmlSource[] { if (!existsSync(PACKAGES_DIR)) { console.error('nuget/packages/ not found. Run: npm run docs:restore'); @@ -143,6 +156,7 @@ function discoverXmlFiles(): XmlSource[] { const versions = readdirSync(pkgDir).sort(byVersionDesc); if (!versions.length) continue; const versionDir = join(pkgDir, versions[0]); + const packageId = readPackageId(versionDir, pkgName); for (const tfm of TFM_PREF) { const libDir = join(versionDir, 'lib', tfm); @@ -153,7 +167,7 @@ function discoverXmlFiles(): XmlSource[] { const xmlPath = join(libDir, f); if (!seen.has(xmlPath)) { seen.add(xmlPath); - sources.push({ xmlPath, packageId: pkgName }); + sources.push({ xmlPath, packageId }); } } break; // use best matching TFM per package @@ -319,7 +333,7 @@ async function generate() { const typeInfo = loadTypeInfo(); const discovered = discoverXmlFiles(); - const sources = discovered.filter(s => !EXCLUDE_PACKAGES.includes(s.packageId)); + const sources = discovered.filter(s => !EXCLUDE_PACKAGES.includes(s.packageId.toLowerCase())); console.log(`Scanning ${sources.length} XML documentation files...`); const entries = new Map(); diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 56374d0..06b6ca0 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -131,6 +131,7 @@ async function main(): Promise { r = await call('get_wpf_project_scaffold', { components: ['XamDataGrid', 'XamCategoryChart'], projectName: 'SmokeApp', framework: 'net10.0' }); text = textOf(r); check('scaffold has dotnet commands and xmlns', !r.isError && text.includes('dotnet new') && text.includes('dotnet add') && text.includes('xmlns:')); + check('package IDs keep their canonical casing', text.includes('dotnet add package Infragistics.WPF.DataGrids.Trial\n'), text.slice(0, 400)); console.log('search_wpf_docs / get_wpf_doc'); r = await call('search_wpf_docs', { control: 'XamDataGrid', limit: 5 }); diff --git a/scripts/validate-package.ts b/scripts/validate-package.ts index 88fbfed..7aa64c8 100644 --- a/scripts/validate-package.ts +++ b/scripts/validate-package.ts @@ -148,6 +148,9 @@ if (components) { errors.push(`namespaces.json has ${components.length} components < ${MIN_COMPONENTS}`); } else if (components.some(c => !c.component || !c.xamlNamespace || !c.nugetPackage)) { errors.push('namespaces.json has entries missing component/xamlNamespace/nugetPackage'); + } else if (components.some(c => c.nugetPackage === c.nugetPackage.toLowerCase())) { + // Lowercase means the ID came from the nuget/packages folder name, not the .nuspec . + errors.push('namespaces.json has lowercase nugetPackage IDs (e.g. "infragistics.wpf.datagrids.trial") — build-api.ts must take the canonical casing from the .nuspec'); } else { ok('data', String(components.length), 'components in namespaces.json'); } From 15ffe1e499fd590bee2aeebfe5096e0fc61e7313 Mon Sep 17 00:00:00 2001 From: mstoyanova Date: Tue, 20 Oct 2026 12:27:02 +0300 Subject: [PATCH 06/11] feat: update MCP publisher version and checksum in workflows; enhance documentation for Claude Code integration --- .github/workflows/nuget-publish.yml | 11 ++++++++++- DEVELOPMENT.md | 2 +- README.md | 14 +++++++++++--- server/README.md | 14 +++++++++++--- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 9db21ca..cf5d526 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -218,9 +218,18 @@ jobs: done echo "::error::nuget.org still does not serve ${ID} ${VERSION}"; exit 1 + # Pinned and checksum-verified like the SHA-pinned actions: this job holds the registry + # OIDC token. linux_amd64 only, since the job runs on ubuntu-latest. To bump: take the + # mcp-publisher_linux_amd64.tar.gz line from the new release's registry__checksums.txt + # (https://github.com/modelcontextprotocol/registry/releases). - name: Download MCP Publisher + env: + MCP_PUBLISHER_VERSION: v1.8.1 + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc run: | - curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar -xz -f - mcp-publisher + curl -fsSL -o mcp-publisher.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" + echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum -c - + tar -xzf mcp-publisher.tar.gz mcp-publisher chmod +x mcp-publisher - name: Publish to MCP Registry diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 87618e2..84e8c21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -213,7 +213,7 @@ Before the first workflow run, create the three `NUGET_FEED_*` repository secret Published by the `publish-mcp-registry` job of *NuGet publish*, after `publish-nuget` and only once nuget.org serves the version (the job waits for indexing). Never for prereleases — the registry has no dist-tag equivalent, so publishing a beta there would make it the current version; the job is skipped for any version containing `-`. -The registry hosts metadata only. The namespace `io.github.Infragistics-Developer-Tools/*` is claimed automatically when `mcp-publisher login github-oidc` runs from this repository's Actions — nothing to configure. It verifies NuGet ownership by finding `mcp-name: io.github.Infragistics-Developer-Tools/wpf-mcp` in the package README (an HTML comment at the top of `server/README.md`); `validate:package` fails the build if the packed README lacks it. The nupkg also carries `server.json` as `.mcp/server.json`, which nuget.org and MCP clients read for configuration. The workflow downloads a pinned `mcp-publisher` (`MCP_PUBLISHER_VERSION` in `nuget-publish.yml`); `mcp-publisher validate` checks `server.json` against the live registry without publishing. By hand: `mcp-publisher login github && mcp-publisher publish` from the release commit (single binary from the [registry releases](https://github.com/modelcontextprotocol/registry/releases)). +The registry hosts metadata only. The namespace `io.github.Infragistics-Developer-Tools/*` is claimed automatically when `mcp-publisher login github-oidc` runs from this repository's Actions — nothing to configure. It verifies NuGet ownership by finding `mcp-name: io.github.Infragistics-Developer-Tools/wpf-mcp` in the package README (an HTML comment at the top of `server/README.md`); `validate:package` fails the build if the packed README lacks it. The nupkg also carries `server.json` as `.mcp/server.json`, which nuget.org and MCP clients read for configuration. The workflow downloads a pinned `mcp-publisher`, currently v1.8.1 (`MCP_PUBLISHER_VERSION` in `nuget-publish.yml`), and checks it against `MCP_PUBLISHER_SHA256` before running it; to bump, set both from the new release's `registry__checksums.txt` (the `mcp-publisher_linux_amd64.tar.gz` line); `mcp-publisher validate` checks `server.json` against the live registry without publishing. By hand: `mcp-publisher login github && mcp-publisher publish` from the release commit (single binary from the [registry releases](https://github.com/modelcontextprotocol/registry/releases)). ### npm (retired) diff --git a/README.md b/README.md index 6d88a6e..f7b7383 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,20 @@ Press `Ctrl+C` to stop it. To try the tools without an AI client, open it in the } ``` -**Claude Code** — from a terminal: +**Claude Code** (CLI or VS Code extension) — add `.mcp.json` to your project root: -```bash -claude mcp add infragistics-wpf -- wpf-mcp +```json +{ + "mcpServers": { + "infragistics-wpf": { + "command": "wpf-mcp" + } + } +} ``` +Approve the server when Claude Code asks, then type `/mcp` in the chat to check that `infragistics-wpf` is connected. If the `claude` command is on your `PATH` (the VS Code extension doesn't add it), you can run `claude mcp add infragistics-wpf -- wpf-mcp` instead. + To use `dnx` instead of a global install, set `"command": "dnx"` and `"args": ["Infragistics.Wpf.Mcp@0.1.0-beta.0", "--yes"]` (Claude Code: `claude mcp add infragistics-wpf -- dnx Infragistics.Wpf.Mcp@0.1.0-beta.0 --yes`). Add `"--debug"` to `args` for any client to log every tool call/response to `wpf-mcp.log` in your system temp folder (override with the `WPF_MCP_LOG` environment variable) — see [Troubleshooting](#troubleshooting). To run from a source checkout instead, build it first (see [DEVELOPMENT.md](DEVELOPMENT.md)) and point `command`/`args` at `dotnet` and `path/to/wpf-mcp/server/bin/Debug/net10.0/wpf-mcp.dll`. diff --git a/server/README.md b/server/README.md index c3bdf34..49d8b93 100644 --- a/server/README.md +++ b/server/README.md @@ -68,12 +68,20 @@ dnx Infragistics.Wpf.Mcp --prerelease --yes } ``` -**Claude Code** — from a terminal: +**Claude Code** (CLI or VS Code extension) — add `.mcp.json` to your project root: -```bash -claude mcp add infragistics-wpf -- wpf-mcp +```json +{ + "mcpServers": { + "infragistics-wpf": { + "command": "wpf-mcp" + } + } +} ``` +Approve the server when Claude Code asks, then type `/mcp` in the chat to check that `infragistics-wpf` is connected. If the `claude` command is on your `PATH` (the VS Code extension doesn't add it), you can run `claude mcp add infragistics-wpf -- wpf-mcp` instead. + To use `dnx` instead of a global install, set `"command": "dnx"` and `"args": ["Infragistics.Wpf.Mcp@0.1.0-beta.0", "--yes"]` in any of the above (`claude mcp add infragistics-wpf -- dnx Infragistics.Wpf.Mcp@0.1.0-beta.0 --yes`). ## Tools From 0c0b23fcdca216814a55e89a50ba11c864262881 Mon Sep 17 00:00:00 2001 From: onlyexeption Date: Tue, 20 Oct 2026 15:31:57 +0300 Subject: [PATCH 07/11] feat(theme): page large theme files and find entries instead of cutting --- DEVELOPMENT.md | 4 + README.md | 2 +- scripts/smoke-test.ts | 31 +++++++- server/README.md | 2 +- server/ServerInstructions.cs | 2 +- server/Tools/ThemeTools.cs | 113 +++++++++++++++++++++----- server/Tools/ToolDescriptions.cs | 6 +- server/Tools/XamlPages.cs | 131 +++++++++++++++++++++++++++++++ 8 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 server/Tools/XamlPages.cs diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5032ebe..2707e00 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -266,6 +266,10 @@ Because inherited members are copied onto every subclass, a few hundred types (m Section headings always stay, so every member name and its declaring type remain visible. A compacted response says so and points to the optional `member` parameter, a case-insensitive name substring that returns only the matching members, inherited ones included, in full detail. The guard applies to `member`-filtered responses as well, so a very broad filter can't bypass it. Measured over all 5,343 public types of the Infragistics 26.1.21 data (re-measure after a data bump): max 116,994 → 24,914 chars (`RibbonBrushKeys`, whose keys are listed as static fields), p99 58,621 → 18,007; 213 types are compacted (30 at level 1, among them `XamDataGrid` 20.5K, `XamTreeGrid` 20.5K and `XamGrid` 14.9K with every property in full; 173 at level 2, among them the charts; 10 at level 3, among them the four `…BrushKeys` classes) and all others render in full. +### `get_wpf_theme_resource` paging + +Theme files used to be cut at 24,000 characters, and 436 of the 822 (26.1.21) are larger — up to 659K (`RibbonGeneric.xaml`) — so agents fetched them from GitHub instead. Now files up to 24,000 characters come back whole (byte-identical to before); larger ones return a contents overview (pages and the `x:Key`/`TargetType` of the entries starting on each; for the 14 files with 700+ entries just the first and last per page). `page: n` returns page n of 23,000 characters of XAML, and `find: ""` returns the top-level entries whose `x:Key`, `TargetType` or element name contains it, in full, wrapped in the file's own root element (paged the same way when large). `server/Tools/XamlPages.cs` splits a file at its top-level elements (with their leading comments and indentation) using `XmlTextReader` with `Namespaces = false` (three DataPresenter files use an undeclared prefix); an element bigger than a page continues on the next at a line break, and the 76 comment-only dictionaries are paged by line. Verified over all files: every page fits in 25K, the pages concatenate to the exact file (2,674 pages), and `find` results — single- and multi-page — compile as WPF XAML. + ### Two runtimes, one data set `src/` (TypeScript, not published, frozen) and `server/` (C#, published to NuGet) serve the same nine tools over the same generated `src/data/`. The C# server started as a byte-for-byte port, but the two now diverge where C# has moved on (e.g. the size guard above), and there is no parity test. The C# server uses the official `ModelContextProtocol` SDK: tools are `[McpServerTool]` methods whose parameters become the input schema (DataAnnotations → JSON Schema constraints), models are records with a source-generated `JsonSerializerContext`, data files are `Content` items copied next to the binary (`Pack="false"`, so they travel inside the tool's `tools/net10.0/any/data/` folder rather than as NuGet content files). The nupkg also carries `PackageType` `McpServer` (nuget.org renders the MCP install snippet) and `.mcp/server.json`. diff --git a/README.md b/README.md index e8788d4..fca1e40 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ All tools are read-only (`readOnlyHint: true`, `openWorldHint: false`) — none | `search_wpf_api` | Search across all 5,300+ public types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | | `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details for just the members you need. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. Call first for any theming task. | -| `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Use the exact `path` from `setup_wpf_theme`. | +| `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Large files come in pages that concatenate to the exact file; `find` returns single styles, templates or brushes in full. Use the exact `path` from `setup_wpf_theme`. | | `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's color/brush palette — exact resource keys and values — plus verified steps to recolor that theme (control-level overrides or an extended theme) without creating a new one. | | `search_wpf_docs` | Search 2,600+ how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. | | `get_wpf_doc` | Full text (including XAML samples) of one documentation topic by slug. Use the `slug` from a `search_wpf_docs` result. | diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 7e920df..46e0cc7 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -13,7 +13,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -204,9 +204,36 @@ async function main(): Promise { console.log('get_wpf_theme_resource'); if (path) { - r = await call('get_wpf_theme_resource', { path }); + // C# returns an overview for files over 24K (this one is ~155K) and the XAML by page; the frozen TS server cuts it off. + r = await call('get_wpf_theme_resource', IS_DOTNET ? { path, page: 1 } : { path }); check(`returns XAML for ${path}`, !r.isError && textOf(r).includes(' 1 && text.includes('## Contents') && text.length <= 25_000, text.slice(0, 300)); + let joined = ''; + for (let p = 1; p <= pageCount; p++) { + const pr = await call('get_wpf_theme_resource', { path, page: p }); + joined += /```xml\n([\s\S]*)\n```$/.exec(textOf(pr))?.[1] ?? '\u0000'; + } + const onDisk = readFileSync(join(ROOT, 'src', 'data', 'theme-resources', path), 'utf8').replace(/^/, ''); + check(`its ${pageCount} pages concatenate to the exact file`, joined === onDisk, `${joined.length} vs ${onDisk.length} chars`); + // 36 matches here, so the result itself takes 2 pages: the root start tag is on the first, the end tag on the last. + r = await call('get_wpf_theme_resource', { path, find: 'Legend' }); + text = textOf(r); + const findPages = Number(/\(page 1 of (\d+)\)/.exec(text)?.[1] ?? 1); + let found = /```xml\n([\s\S]*)\n```$/.exec(text)?.[1] ?? ''; + for (let p = 2; p <= findPages; p++) + found += /```xml\n([\s\S]*)\n```$/.exec(textOf(await call('get_wpf_theme_resource', { path, find: 'Legend', page: p })))?.[1] ?? ''; + check('find returns matching entries in full, inside the root element', + !r.isError && /entr(y|ies) matching "Legend"/.test(text) && found.includes(''), + text.slice(0, 300)); + r = await call('get_wpf_theme_resource', { path, page: pageCount + 1 }); + check('a page past the end is a clear error', r.isError === true && textOf(r).includes('out of range')); + } r = await call('get_wpf_theme_resource', { path: '../../package.json' }); check('path traversal is rejected', r.isError === true); diff --git a/server/README.md b/server/README.md index ee20934..9ebb190 100644 --- a/server/README.md +++ b/server/README.md @@ -85,7 +85,7 @@ To use `dnx` instead of a global install, set `"command": "dnx"` and `"args": [" | `search_wpf_api` | Search across all types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | | `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details for just the members you need. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. | -| `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. | +| `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Large files come in pages that concatenate to the exact file; `find` returns single styles, templates or brushes in full. | | `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's color/brush palette — exact resource keys and values — plus verified steps to recolor that theme (control-level overrides or an extended theme) without creating a new one. | | `search_wpf_docs` | Search how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. | | `get_wpf_doc` | Full text (including XAML samples) of one documentation topic by slug. | diff --git a/server/ServerInstructions.cs b/server/ServerInstructions.cs index 0a07232..0df0def 100644 --- a/server/ServerInstructions.cs +++ b/server/ServerInstructions.cs @@ -14,7 +14,7 @@ public static class ServerInstructions 3. Whenever the task needs HOW-TO guidance beyond "what members exist" — layouts/nesting, styling/theming, data binding, editing/validation, filtering/sorting/grouping/summaries, exporting, performance, commands, known issues, etc. — call search_wpf_docs(query, control: component), passing the SAME component name from step 1/2 to scope the search. 4. get_wpf_doc(slug) on the most relevant result from step 3 to read the full XAML example or how-to text before writing any code or giving usage advice. 5. For new projects, get_wpf_project_scaffold(components) after step 1 for dotnet CLI + xmlns setup, then still run steps 2-4 per component before writing real XAML. - 6. Establish the visual theme EARLY — treat it as a default step for any new window/app, not an optional afterthought. Call setup_wpf_theme(component?, theme?) to pick a named theme and get the exact ready-to-paste apply steps (NuGet package + App.xaml.cs ThemeManager call for the newer family, or Theme="" for the legacy family) plus the resource file paths; then get_wpf_theme_resource(path) only if you need to copy/override a specific Style/ControlTemplate. When the goal is to RE-COLOR an existing theme (brand colors, a different accent) rather than restyle one control, call get_wpf_theme_palette(theme) for that theme's colors plus the verified recolor steps — overriding the palette keys themselves does NOT recolor a theme. + 6. Establish the visual theme EARLY — treat it as a default step for any new window/app, not an optional afterthought. Call setup_wpf_theme(component?, theme?) to pick a named theme and get the exact ready-to-paste apply steps (NuGet package + App.xaml.cs ThemeManager call for the newer family, or Theme="" for the legacy family) plus the resource file paths; then get_wpf_theme_resource(path) only if you need to copy/override a specific Style/ControlTemplate (find: "" returns just that entry; large files come in pages). When the goal is to RE-COLOR an existing theme (brand colors, a different accent) rather than restyle one control, call get_wpf_theme_palette(theme) for that theme's colors plus the verified recolor steps — overriding the palette keys themselves does NOT recolor a theme. ALWAYS call list_wpf_components/search_wpf_api before writing any XAML to get the correct xmlns namespace URI; wrong values cause immediate compile errors. ALWAYS establish a theme via setup_wpf_theme (then get_wpf_theme_palette to recolor if needed) BEFORE writing any Style, Setter, Brush, or color attribute on an Infragistics control. Do NOT hand-author a color scheme (dark, neon, corporate, etc.) to approximate a look — a named theme plus the verified recolor steps from get_wpf_theme_palette is the correct, lower-maintenance path. diff --git a/server/Tools/ThemeTools.cs b/server/Tools/ThemeTools.cs index 7948324..0a1007b 100644 --- a/server/Tools/ThemeTools.cs +++ b/server/Tools/ThemeTools.cs @@ -229,9 +229,11 @@ componentFilter is null || // ── get_wpf_theme_resource ─────────────────────────────────────────────── - // Style files average ~60KB and can reach ~660KB; 24000 chars (~5-6k tokens) fits a - // full ControlTemplate while capping the largest blobs. + // Files up to this size come back whole, as they always have. Larger ones (436 of 822 on 26.1.21, up to 659K) + // used to be cut off here; now they come as a contents overview plus pages, or just the entries `find` matches. private const int MaxThemeFileChars = 24000; + // XAML per page; with the response's header this stays under the 25K get_wpf_api_reference keeps to as well. + private const int ThemePageChars = 23000; [McpServerTool(Name = "get_wpf_theme_resource", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description(ToolDescriptions.GetWpfThemeResource)] @@ -242,10 +244,23 @@ public CallToolResult GetThemeResource( Call setup_wpf_theme first — never guess this path. """)] [MinLength(1), MaxLength(200)] - string path) + string path, + [Description(""" + Optional: return only the file's top-level entries (Style, ControlTemplate, brush, …) whose x:Key, TargetType + or element name contains this text (case-insensitive), each in full, e.g. "CheckBox", "ButtonStyle", "Brush". + The result is wrapped in the file's own root element, so its xmlns declarations come along. + """)] + [MaxLength(64)] + string? find = null, + [Description(""" + Optional page number for files too large for one response (the response says how many pages there are). + Pages split between top-level elements and concatenate to the exact file. Also pages through large find results. + """)] + [Range(1, 1000)] + int? page = null) { var timer = Stopwatch.StartNew(); - var input = new { path }; + var input = new { path, find, page }; var content = store.LoadThemeResource(path); if (content is null) @@ -271,23 +286,83 @@ Call setup_wpf_theme first — never guess this path. ? "_Newer family: apply via `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` (the IG theme's class is `IgTheme`) — do not merge this file directly into Application.Resources._" : "_Legacy family: the named theme is already embedded in the component assembly — set `Theme=\"...\"` on the control. Use this file to copy/override specific styles only._"; - var shown = content.Length > MaxThemeFileChars - ? $"{content[..MaxThemeFileChars]}\n" - : content; + CallToolResult Respond(string text, bool isError = false) + { + log.Write("get_wpf_theme_resource", input, text, timer); + return isError ? ToolResult.Error(text) : ToolResult.Text(text); + } + string Xaml(string heading, string note, string xaml) => + string.Join('\n', new[] { heading, "", mechanismNote, "", note, "", "```xml", xaml, "```" }); + string? PageError(int count) => page is { } p && (p < 1 || p > count) + ? $"page {p} is out of range: \"{path}\"{(find is null ? "" : $" filtered by find \"{find.Trim()}\"")} has {count} page{(count == 1 ? "" : "s")} (page: 1{(count > 1 ? $" to {count}" : "")})." + : null; + + var parts = XamlPages.Parse(content); // null for a dictionary without child elements + var findText = find?.Trim(); - var text = string.Join('\n', new[] + // find: only the matching top-level entries, in full, inside the file's own root element. + if (!string.IsNullOrEmpty(findText)) { - $"# {path}", - "", - mechanismNote, - "", - "```xml", - shown, - "```", - }); + var matches = parts?.Entries.Where(e => e.Search.Contains(findText, StringComparison.OrdinalIgnoreCase)).ToList() ?? []; + if (matches.Count == 0) + return Respond(string.Join('\n', new[] + { + $"# {path}", "", + $"_No top-level entry in this file has an x:Key, TargetType or element name containing \"{findText}\". Call get_wpf_theme_resource(\"{path}\") without find to see what the file contains._", + })); + var matchPages = XamlPages.Paginate( + new[] { (parts!.Head, "") }.Concat(matches.Select(e => (e.Text, e.Label))).Append((parts.Tail, "")), ThemePageChars); + if (PageError(matchPages.Count) is { } findPageError) return Respond(findPageError, isError: true); + var n = page ?? 1; + return Respond(Xaml( + $"# {path}: {matches.Count} entr{(matches.Count == 1 ? "y" : "ies")} matching \"{findText}\"{(matchPages.Count > 1 ? $" (page {n} of {matchPages.Count})" : "")}", + "_The matching top-level entries in full, inside the file's own root element so its xmlns declarations come along. " + + "They may use other resources via StaticResource — defined in this file or in a file its MergedDictionaries list " + + "(e.g. an implicit style BasedOn a keyed one in .Styles.xaml); get those the same way, by key." + + (matchPages.Count > 1 ? $" The matches take {matchPages.Count} pages; {(n < matchPages.Count ? $"next: page: {n + 1}." : "this is the last one.")}" : "") + "_", + matchPages[n - 1].Text)); + } - log.Write("get_wpf_theme_resource", input, text, timer); - return ToolResult.Text(text); + // Small files: the whole file, exactly as before. + if (content.Length <= MaxThemeFileChars) + { + if (PageError(1) is { } smallPageError) return Respond(smallPageError, isError: true); + return Respond(string.Join('\n', new[] { $"# {path}", "", mechanismNote, "", "```xml", content, "```" })); + } + + var pages = XamlPages.Paginate( + parts is null ? [(content, "")] : new[] { (parts.Head, "") }.Concat(parts.Entries.Select(e => (e.Text, e.Label))).Append((parts.Tail, "")), + ThemePageChars); + if (PageError(pages.Count) is { } pageError) return Respond(pageError, isError: true); + + if (page is { } pageNumber) + return Respond(Xaml( + $"# {path} (page {pageNumber} of {pages.Count})", + $"_Pages split between top-level elements (an element bigger than a page continues on the next) and concatenate to the exact file. " + + $"{(pageNumber < pages.Count ? $"Next: page: {pageNumber + 1}." : "This is the last page.")}_", + pages[pageNumber - 1].Text)); + + // Large file, no page or find: an overview of what is on each page. + var lead = new List + { + $"# {path}", "", mechanismNote, "", + $"_This file is {content.Length} characters, more than one response holds, so it comes in {pages.Count} pages that split between top-level elements " + + "(an element bigger than a page continues on the next). Read it in order with page: 1, 2, … — the pages concatenate to the exact file — " + + "or get specific styles, templates or brushes in full with find: \"\"._", + "", "## Contents", + }; + string Label(string label) => $"`{label}`"; + var full = pages.Select((p, i) => $"- Page {i + 1}: {(p.Labels.Count > 0 ? string.Join(", ", p.Labels.Select(Label)) : "(continued)")}").ToList(); + var overview = string.Join('\n', lead.Concat(full)); + if (overview.Length > MaxThemeFileChars) + // The full list doesn't fit (a few brush files have 700+ entries): first and last entry per page. + overview = string.Join('\n', lead.Concat(pages.Select((p, i) => p.Labels.Count switch + { + 0 => $"- Page {i + 1}: (continued)", + 1 => $"- Page {i + 1}: {Label(p.Labels[0])}", + _ => $"- Page {i + 1}: {p.Labels.Count} entries, {Label(p.Labels[0])} … {Label(p.Labels[^1])}", + })).Append("").Append("_The full list of entries doesn't fit here; use find to get specific ones._")); + return Respond(overview); } // ── get_wpf_theme_palette ──────────────────────────────────────────────── @@ -459,7 +534,7 @@ protected override void ConfigureControlMappings() - **Charts, gauges, standard controls and other controls:** copy the control's theme file and the files it merges (such as `{{{stylesFile}}}` and `{{{colorsFile}}}`) into your project, change the accent colors in the copied `{{{colorsFile}}}`, and remap that file as above. Standard WPF controls use `{{{msFile}}}`. - **Grid:** its theme file only wraps compiled themes. Remap it to your own dictionary that merges `` plus copies of the grid's theme files from `DefaultStyles/DataPresenter/` — `{{{dpClass}}}.xaml`, `{{{dpClass}}}_Express.xaml`, `{{{dpClass}}}_Brushes.xaml` and the `DataPresenterGeneric*.xaml` files they merge, with the `Images` folder as `Resource` items — and change the accent in the copied `{{{dpClass}}}_Brushes.xaml`. If the Excel-style filter checkboxes keep the old color (seen on IG), they are styled by the standard-controls file, `{{{msFile}}}` — remap that too. - **DockManager:** remap its file to a dictionary that merges ``, `` and your brush-key overrides — no copied templates needed. - - Copied theme files must be re-copied on every Infragistics upgrade. `get_wpf_theme_resource` returns them, but files over 24,000 characters are cut off; the full sources are in the Infragistics/wpf-resources repository on GitHub. + - Copied theme files must be re-copied on every Infragistics upgrade. `get_wpf_theme_resource` returns them completely: large files come in pages that concatenate to the exact file (page: 1, 2, …), and find: "" returns single entries in full. _Read-only tool: it returns the theme's real keys and verified steps; it does not modify your project._ """; diff --git a/server/Tools/ToolDescriptions.cs b/server/Tools/ToolDescriptions.cs index 1279cba..65cde92 100644 --- a/server/Tools/ToolDescriptions.cs +++ b/server/Tools/ToolDescriptions.cs @@ -88,7 +88,11 @@ public static class ToolDescriptions Use the exact `path` returned by setup_wpf_theme (e.g. "Themes/MetroDark/MetroDark.xamDataChart.xaml" or "DefaultStyles/Ribbon/RibbonMetroDark.xaml") — never guess this path. This is always the second step after setup_wpf_theme, never the first call. - Use this when the user wants to customize/override part of a control's themed appearance (copy the real Style/ControlTemplate as a starting point instead of guessing property names or template structure), or wants to see exactly what a named theme changes for a given control. Large files are truncated with a note — ask for a narrower/more specific file via setup_wpf_theme if you need a part that got cut off. + Use this when the user wants to customize/override part of a control's themed appearance (copy the real Style/ControlTemplate as a starting point instead of guessing property names or template structure), or wants to see exactly what a named theme changes for a given control. + + Nothing is cut off. Files up to ~24K characters come back whole. Larger ones (about half of them, up to ~660K) first return a contents overview: how many pages there are and which entries (x:Key or TargetType) start on each. Then either: + • find: "" — just the matching Style/ControlTemplate/brush entries, each in full, wrapped in the file's own root element so its xmlns declarations come along (e.g. find: "CheckBox"). The fastest way to the one style you need. An entry may be BasedOn or reference resources in a file its MergedDictionaries list (e.g. .Styles.xaml) — get those the same way. + • page: 1, 2, … — the file in order. Pages split between top-level elements (one bigger than a page continues on the next), and concatenated they are the exact file, e.g. for copying a whole theme file. """; public const string GetWpfThemePalette = """ diff --git a/server/Tools/XamlPages.cs b/server/Tools/XamlPages.cs new file mode 100644 index 0000000..3c73640 --- /dev/null +++ b/server/Tools/XamlPages.cs @@ -0,0 +1,131 @@ +using System.Text; +using System.Xml; + +namespace Infragistics.Wpf.Mcp.Tools; + +/// Splits a XAML resource dictionary into pages at its top-level elements, so a theme file of any size can be read +/// completely: the pages concatenate back to the exact file. On the 26.1.21 data, 436 of 822 theme files are over +/// the 24,000-character cut-off get_wpf_theme_resource used to apply, up to 659K (RibbonGeneric.xaml, 33 pages). +internal static class XamlPages +{ + /// One top-level element with the comments and indentation that lead into it. + internal sealed record Entry(string Text, string Label, string Search); + + /// Head: prolog and root start tag. Tail: root end tag and anything after it. + internal sealed record Parts(string Head, IReadOnlyList Entries, string Tail); + + internal sealed record Page(string Text, IReadOnlyList Labels); + + /// The file split into top-level elements, or null if it isn't well-formed XML or has no child elements. + public static Parts? Parse(string text) + { + var lineStarts = new List { 0 }; + for (var i = 0; i < text.Length; i++) + if (text[i] == '\n') lineStarts.Add(i + 1); + int Offset(int line, int position) => lineStarts[line - 1] + position - 1; + + var starts = new List<(int At, string Label, string Search)>(); + int? leadingComment = null; + var rootEnd = -1; + try + { + // Namespaces = false: a few Infragistics files use a prefix they never declare (DataPresenterHulk.xaml). + using var reader = new XmlTextReader(new StringReader(text)) + { + Namespaces = false, DtdProcessing = DtdProcessing.Ignore, WhitespaceHandling = WhitespaceHandling.All, + }; + var positioned = false; // Skip() already moved to the next node + while (positioned || reader.Read()) + { + positioned = false; + if (reader.Depth == 1 && reader.NodeType == XmlNodeType.Comment) + { + leadingComment ??= text.LastIndexOf("