From a6b5722d2d470b3306e715940b7f0169e04cda0d Mon Sep 17 00:00:00 2001 From: Sundaram Ramaswamy <29264916+sundaramramaswamy@users.noreply.github.com> Date: Wed, 20 May 2026 22:53:50 +0530 Subject: [PATCH 01/21] Add Find data model POCOs Scenario, ScenarioCatalogue, SearchResult records with System.Text.Json source-gen for AOT compat. Spec 043 Phase 1, work item 1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Reactor.Cli/Find/Models.cs | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/Reactor.Cli/Find/Models.cs diff --git a/src/Reactor.Cli/Find/Models.cs b/src/Reactor.Cli/Find/Models.cs new file mode 100644 index 000000000..5604916bc --- /dev/null +++ b/src/Reactor.Cli/Find/Models.cs @@ -0,0 +1,37 @@ +#nullable enable + +using System.Text.Json.Serialization; + +namespace Microsoft.UI.Reactor.Cli.Find; + +public record Scenario( + string Id, + string Category, + string Title, + string Intent, + string[] Tags, + string[] FactoryAnchors, + string? NotesKey, + string[] RelatedIds, + string Priority, + string Code, + string RawCode +); + +public record ScenarioCatalogue( + Scenario[] Scenarios, + string GeneratedAt +); + +public record SearchResult( + Scenario Scenario, + double Score +); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(ScenarioCatalogue))] +[JsonSerializable(typeof(Scenario))] +[JsonSerializable(typeof(Scenario[]))] +internal partial class FindJsonContext : JsonSerializerContext +{ +} \ No newline at end of file From a60da16f21ce34272da950366f32fca72803489c Mon Sep 17 00:00:00 2001 From: Sundaram Ramaswamy <29264916+sundaramramaswamy@users.noreply.github.com> Date: Wed, 20 May 2026 22:54:08 +0530 Subject: [PATCH 02/21] Add stub scenario catalogue One scenario (use-state-basic) + README with authoring contract. Spec 043 Phase 1, item 5. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/scenarios/README.md | 47 +++++++++++++++++++ .../hooks/use-state-basic/Scenario.cs | 20 ++++++++ .../hooks/use-state-basic/scenario.json | 11 +++++ 3 files changed, 78 insertions(+) create mode 100644 samples/scenarios/README.md create mode 100644 samples/scenarios/hooks/use-state-basic/Scenario.cs create mode 100644 samples/scenarios/hooks/use-state-basic/scenario.json diff --git a/samples/scenarios/README.md b/samples/scenarios/README.md new file mode 100644 index 000000000..4db7dd68d --- /dev/null +++ b/samples/scenarios/README.md @@ -0,0 +1,47 @@ +# Reactor Sample Catalogue + +Curated, compilable single-file Reactor scenarios indexed by `mur find`. + +## Authoring contract + +Every scenario folder contains exactly two files: + +- **`Scenario.cs`** — a complete single-file Reactor app +- **`scenario.json`** — sidecar metadata for the search index + +### `Scenario.cs` format + +```csharp +// id: +// intent: +#:package Microsoft.UI.Reactor@0.0.0-local +#:property Platform=ARM64 + +using Microsoft.UI.Reactor; +using static Microsoft.UI.Reactor.Factories; + +ReactorApp.Run("Title", width: 400, height: 200); + +class App : Component +{ + public override Element Render() { /* ... */ } +} +``` + +### `scenario.json` schema + +```json +{ + "id": "kebab-case-name", + "category": "hooks|layout|inputs|...", + "title": "Human-readable title", + "intent": "search-friendly description of what this demonstrates", + "tags": ["keyword1", "keyword2"], + "factoryAnchors": ["FactoryName1", "FactoryName2"], + "notesKey": "FactoryOrHookName", + "relatedIds": ["other-scenario-id"], + "priority": "P0" +} +``` + +The folder name IS the scenario id. diff --git a/samples/scenarios/hooks/use-state-basic/Scenario.cs b/samples/scenarios/hooks/use-state-basic/Scenario.cs new file mode 100644 index 000000000..8623c9f12 --- /dev/null +++ b/samples/scenarios/hooks/use-state-basic/Scenario.cs @@ -0,0 +1,20 @@ +// id: use-state-basic +// intent: count clicks; demonstrate UseState with primitive value +#:package Microsoft.UI.Reactor@0.0.0-local +#:property Platform=ARM64 + +using Microsoft.UI.Reactor; +using static Microsoft.UI.Reactor.Factories; + +ReactorApp.Run("UseStateBasic", width: 400, height: 200); + +class App : Component +{ + public override Element Render() + { + var (count, setCount) = UseState(0); + return VStack( + Heading($"Count: {count}"), + Button("+1", () => setCount(count + 1))); + } +} diff --git a/samples/scenarios/hooks/use-state-basic/scenario.json b/samples/scenarios/hooks/use-state-basic/scenario.json new file mode 100644 index 000000000..84b442882 --- /dev/null +++ b/samples/scenarios/hooks/use-state-basic/scenario.json @@ -0,0 +1,11 @@ +{ + "id": "use-state-basic", + "category": "hooks", + "title": "Counter with UseState", + "intent": "increment a primitive value on click", + "tags": ["state", "counter", "hook", "useState", "primitive"], + "factoryAnchors": ["UseState", "Button", "VStack"], + "notesKey": "UseState", + "relatedIds": ["use-state-list-pitfall", "use-reducer-list"], + "priority": "P0" +} From cbb9a4af3c6c62d96c1a9017bcbd2ad6b25af3d6 Mon Sep 17 00:00:00 2001 From: Sundaram Ramaswamy <29264916+sundaramramaswamy@users.noreply.github.com> Date: Wed, 20 May 2026 23:00:03 +0530 Subject: [PATCH 03/21] Add DataLoader and Notes for Find DataLoader reads embedded scenarios.json via manifest resource. Notes seeds 5 pitfall entries. Spec 043 Phase 1, work item 3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Reactor.Cli/Find/DataLoader.cs | 17 ++++++++++++ src/Reactor.Cli/Find/Notes.cs | 43 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/Reactor.Cli/Find/DataLoader.cs create mode 100644 src/Reactor.Cli/Find/Notes.cs diff --git a/src/Reactor.Cli/Find/DataLoader.cs b/src/Reactor.Cli/Find/DataLoader.cs new file mode 100644 index 000000000..b6141325f --- /dev/null +++ b/src/Reactor.Cli/Find/DataLoader.cs @@ -0,0 +1,17 @@ +#nullable enable + +using System.Text.Json; + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal static class DataLoader +{ + public static ScenarioCatalogue Load() + { + var assembly = typeof(DataLoader).Assembly; + using var stream = assembly.GetManifestResourceStream("scenarios.json") + ?? throw new InvalidOperationException("Embedded scenarios.json not found."); + return JsonSerializer.Deserialize(stream, FindJsonContext.Default.ScenarioCatalogue) + ?? throw new InvalidOperationException("Failed to deserialize scenarios.json."); + } +} diff --git a/src/Reactor.Cli/Find/Notes.cs b/src/Reactor.Cli/Find/Notes.cs new file mode 100644 index 000000000..5f030653e --- /dev/null +++ b/src/Reactor.Cli/Find/Notes.cs @@ -0,0 +1,43 @@ +#nullable enable + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal static class Notes +{ + public static string[]? GetNotes(string? notesKey) + { + if (notesKey is null) return null; + return _notes.GetValueOrDefault(notesKey); + } + + private static readonly Dictionary _notes = new() + { + ["UseState"] = + [ + "UseState with a List does NOT re-render on `.Add()` / `.Remove()` — same reference. Use UseReducer for collections.", + "UseState returns (value, setter). The setter is stable across renders — safe to omit from dependency arrays.", + "Call UseState unconditionally at the top of Render. Hooks track slot identity by call order." + ], + ["UseEffect"] = + [ + "Effects run AFTER render commits. Don't read state set inside the same render unless via UseEffect's cleanup or a deps change.", + "Return a cleanup lambda when the effect subscribes to anything. The cleanup runs before the next effect AND on unmount.", + "Empty deps `[]` means 'run once on mount' — but the effect still re-runs if the component remounts due to key change." + ], + ["lists"] = + [ + "Lists produced by `items.Select(...).ToArray()` MUST include `.WithKey(item.Id)` on every element. Without keys, focus, animation, and child state drift across reorders.", + "`UseState>` mutating in place does not re-render. Use `UseReducer` or `UseCollection`." + ], + ["WithKey"] = + [ + "Required on every element produced from `.Select(...)` inside a layout container. Without it the analyzer emits `REACTOR_DSL_001` and reordering breaks focus/animation.", + "Key must be stable across renders. Don't key by index for reorderable lists — that defeats the purpose." + ], + ["ContentDialog"] = + [ + "ContentDialog is non-routed — show it via `UseDialog().Show(...)` from a hook, not by mounting it as a child element.", + "Primary/secondary/close buttons map to the three result branches. For yes/no/cancel, provide all three texts." + ] + }; +} From 793afea99ab807e48bf2e226d754f4996bc20c60 Mon Sep 17 00:00:00 2001 From: Sundaram Ramaswamy <29264916+sundaramramaswamy@users.noreply.github.com> Date: Wed, 20 May 2026 23:00:12 +0530 Subject: [PATCH 04/21] Add SampleCatalogue build-time extractor Walks samples/scenarios/, validates JSON+CS, strips metadata headers, emits scenarios.json. Spec 043 Phase 1, work item 6. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/Reactor.SampleCatalogue/Program.cs | 210 ++++++++++++++++++ .../Reactor.SampleCatalogue.csproj | 11 + .../Reactor.SampleCatalogue/ScenarioWalker.cs | 10 + 3 files changed, 231 insertions(+) create mode 100644 tools/Reactor.SampleCatalogue/Program.cs create mode 100644 tools/Reactor.SampleCatalogue/Reactor.SampleCatalogue.csproj create mode 100644 tools/Reactor.SampleCatalogue/ScenarioWalker.cs diff --git a/tools/Reactor.SampleCatalogue/Program.cs b/tools/Reactor.SampleCatalogue/Program.cs new file mode 100644 index 000000000..604b6cbf9 --- /dev/null +++ b/tools/Reactor.SampleCatalogue/Program.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +try +{ + return Run(args); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"error: {ex.Message}"); + return 1; +} + +static int Run(string[] args) +{ + var repoRoot = ResolveRepoRoot(args); + var scenariosRoot = Path.Combine(repoRoot, "samples", "scenarios"); + var scenarioPaths = Directory.Exists(scenariosRoot) + ? ScenarioWalker.FindScenarios(scenariosRoot) + : []; + + if (scenarioPaths.Length == 0) + { + Console.WriteLine($"warning: no scenarios found under {scenariosRoot}"); + return 0; + } + + var scenarios = new List(scenarioPaths.Length); + + foreach (var scenarioJsonPath in scenarioPaths) + { + var metadata = LoadMetadata(scenarioJsonPath); + var scenarioDirectory = Path.GetDirectoryName(scenarioJsonPath)!; + var scenarioCodePath = Path.Combine(scenarioDirectory, "Scenario.cs"); + + if (!File.Exists(scenarioCodePath)) + { + throw new InvalidOperationException($"missing Scenario.cs next to {scenarioJsonPath}"); + } + + var rawCode = File.ReadAllText(scenarioCodePath); + var code = StripMetadataHeaders(rawCode); + + scenarios.Add(new Scenario( + metadata.Id!, + metadata.Category!, + metadata.Title!, + metadata.Intent!, + metadata.Tags!, + metadata.FactoryAnchors!, + metadata.NotesKey, + metadata.RelatedIds ?? [], + metadata.Priority!, + code, + rawCode)); + } + + var outputPath = Path.Combine(repoRoot, "samples", "scenarios", "_generated", "scenarios.json"); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + + var catalogue = new ScenarioCatalogue( + [.. scenarios], + DateTimeOffset.UtcNow.ToString("O")); + + var json = JsonSerializer.Serialize(catalogue, SampleCatalogueJsonContext.Default.ScenarioCatalogue); + File.WriteAllText(outputPath, json); + + Console.WriteLine($"Extracted {scenarios.Count} scenarios → {outputPath}"); + return 0; +} + +static string ResolveRepoRoot(string[] args) +{ + if (args.Length > 0 && !string.IsNullOrWhiteSpace(args[0])) + { + return Path.GetFullPath(args[0]); + } + + var directory = new DirectoryInfo(Environment.CurrentDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "Reactor.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate repo root (expected Reactor.slnx)."); +} + +static ScenarioMetadata LoadMetadata(string scenarioJsonPath) +{ + ScenarioMetadata? metadata; + + try + { + metadata = JsonSerializer.Deserialize( + File.ReadAllText(scenarioJsonPath), + SampleCatalogueJsonContext.Default.ScenarioMetadata); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Invalid JSON in {scenarioJsonPath}: {ex.Message}", ex); + } + + if (metadata is null) + { + throw new InvalidOperationException($"Empty scenario metadata in {scenarioJsonPath}."); + } + + var missingFields = new List(); + + if (string.IsNullOrWhiteSpace(metadata.Id)) missingFields.Add("id"); + if (string.IsNullOrWhiteSpace(metadata.Category)) missingFields.Add("category"); + if (string.IsNullOrWhiteSpace(metadata.Title)) missingFields.Add("title"); + if (string.IsNullOrWhiteSpace(metadata.Intent)) missingFields.Add("intent"); + if (metadata.Tags is null) missingFields.Add("tags"); + if (metadata.FactoryAnchors is null) missingFields.Add("factoryAnchors"); + if (string.IsNullOrWhiteSpace(metadata.Priority)) missingFields.Add("priority"); + + if (missingFields.Count > 0) + { + throw new InvalidOperationException( + $"Missing required field(s) in {scenarioJsonPath}: {string.Join(", ", missingFields)}"); + } + + return metadata with + { + Tags = metadata.Tags ?? [], + FactoryAnchors = metadata.FactoryAnchors ?? [], + RelatedIds = metadata.RelatedIds ?? [] + }; +} + +static string StripMetadataHeaders(string rawCode) +{ + var lines = rawCode.Replace("\r\n", "\n").Split('\n'); + var output = new List(lines.Length); + var inLeadingHeader = true; + + foreach (var line in lines) + { + var trimmed = line.TrimStart(); + + if (inLeadingHeader) + { + if (trimmed.StartsWith("//", StringComparison.Ordinal) || + trimmed.StartsWith("#:", StringComparison.Ordinal) || + trimmed.Length == 0) + { + continue; + } + + inLeadingHeader = false; + } + + if (trimmed.StartsWith("#:", StringComparison.Ordinal)) + { + continue; + } + + output.Add(line); + } + + while (output.Count > 0 && string.IsNullOrWhiteSpace(output[0])) + { + output.RemoveAt(0); + } + + return string.Join(Environment.NewLine, output); +} + +internal sealed record Scenario( + string Id, + string Category, + string Title, + string Intent, + string[] Tags, + string[] FactoryAnchors, + string? NotesKey, + string[] RelatedIds, + string Priority, + string Code, + string RawCode); + +internal sealed record ScenarioCatalogue( + Scenario[] Scenarios, + string GeneratedAt); + +internal sealed record ScenarioMetadata( + string? Id, + string? Category, + string? Title, + string? Intent, + string[]? Tags, + string[]? FactoryAnchors, + string? NotesKey, + string[]? RelatedIds, + string? Priority); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] +[JsonSerializable(typeof(ScenarioCatalogue))] +[JsonSerializable(typeof(Scenario))] +[JsonSerializable(typeof(Scenario[]))] +[JsonSerializable(typeof(ScenarioMetadata))] +internal partial class SampleCatalogueJsonContext : JsonSerializerContext +{ +} diff --git a/tools/Reactor.SampleCatalogue/Reactor.SampleCatalogue.csproj b/tools/Reactor.SampleCatalogue/Reactor.SampleCatalogue.csproj new file mode 100644 index 000000000..40e2192d7 --- /dev/null +++ b/tools/Reactor.SampleCatalogue/Reactor.SampleCatalogue.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tools/Reactor.SampleCatalogue/ScenarioWalker.cs b/tools/Reactor.SampleCatalogue/ScenarioWalker.cs new file mode 100644 index 000000000..b5800ee27 --- /dev/null +++ b/tools/Reactor.SampleCatalogue/ScenarioWalker.cs @@ -0,0 +1,10 @@ +internal static class ScenarioWalker +{ + public static string[] FindScenarios(string scenariosRoot) + { + return Directory.GetFiles(scenariosRoot, "scenario.json", SearchOption.AllDirectories) + .Where(p => !p.Contains("_generated")) + .OrderBy(p => p) + .ToArray(); + } +} From 2767eb85bea12918b377c17d6850f4a43653372f Mon Sep 17 00:00:00 2001 From: Sundaram Ramaswamy <29264916+sundaramramaswamy@users.noreply.github.com> Date: Wed, 20 May 2026 23:01:16 +0530 Subject: [PATCH 05/21] Add BM25 search engine for Find Two-layer BM25 scorer, stop words, synonym/phrase maps, SearchEngine with factory grouping. Spec 043 Phase 1, work item 2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Reactor.Cli/Find/BM25.cs | 45 ++++++ src/Reactor.Cli/Find/SearchEngine.cs | 199 +++++++++++++++++++++++++++ src/Reactor.Cli/Find/StopWords.cs | 55 ++++++++ src/Reactor.Cli/Find/Synonyms.cs | 119 ++++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 src/Reactor.Cli/Find/BM25.cs create mode 100644 src/Reactor.Cli/Find/SearchEngine.cs create mode 100644 src/Reactor.Cli/Find/StopWords.cs create mode 100644 src/Reactor.Cli/Find/Synonyms.cs diff --git a/src/Reactor.Cli/Find/BM25.cs b/src/Reactor.Cli/Find/BM25.cs new file mode 100644 index 000000000..6f139db42 --- /dev/null +++ b/src/Reactor.Cli/Find/BM25.cs @@ -0,0 +1,45 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal static class BM25 +{ + private const double K1 = 1.2; + private const double B = 0.75; + + public static double Score(string[] queryTerms, WeightedDoc doc, CorpusStats stats) + { + ArgumentNullException.ThrowIfNull(queryTerms); + ArgumentNullException.ThrowIfNull(doc); + ArgumentNullException.ThrowIfNull(stats); + + if (queryTerms.Length == 0 || stats.DocCount <= 0 || doc.DocLength <= 0 || stats.AvgDocLength <= 0) + { + return 0.0; + } + + var score = 0.0; + var norm = K1 * (1.0 - B + B * (doc.DocLength / stats.AvgDocLength)); + + foreach (var term in queryTerms) + { + if (!doc.TermWeights.TryGetValue(term, out var tf) || tf <= 0.0) + { + continue; + } + + stats.DocFrequency.TryGetValue(term, out var n); + var idf = Math.Log(((stats.DocCount - n + 0.5) / (n + 0.5)) + 1.0); + score += idf * ((tf * (K1 + 1.0)) / (tf + norm)); + } + + return score; + } +} + +internal record WeightedDoc(Dictionary TermWeights, int DocLength); + +internal record CorpusStats(int DocCount, double AvgDocLength, Dictionary DocFrequency); diff --git a/src/Reactor.Cli/Find/SearchEngine.cs b/src/Reactor.Cli/Find/SearchEngine.cs new file mode 100644 index 000000000..cc80407e2 --- /dev/null +++ b/src/Reactor.Cli/Find/SearchEngine.cs @@ -0,0 +1,199 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal partial class SearchEngine +{ + private readonly ScenarioCatalogue _catalogue; + private readonly CorpusStats _stats; + private readonly ScenarioEntry[] _entries; + + public SearchEngine(ScenarioCatalogue catalogue) + { + _catalogue = catalogue ?? throw new ArgumentNullException(nameof(catalogue)); + _entries = catalogue.Scenarios.Select(CreateEntry).ToArray(); + _stats = BuildStats(_entries.Select(entry => entry.ScenarioDoc)); + } + + public SearchResult[] Search(string query, int maxResults = 5, string? category = null, bool includeAntiPatterns = false) + { + ArgumentNullException.ThrowIfNull(query); + + if (maxResults <= 0 || _catalogue.Scenarios.Length == 0) + { + return []; + } + + var queryTerms = Synonyms.ProcessQuery(query); + if (queryTerms.Length == 0) + { + return []; + } + + var normalizedCategory = string.IsNullOrWhiteSpace(category) + ? null + : category.Trim(); + + var filteredEntries = _entries.Where(entry => + (includeAntiPatterns || !string.Equals(entry.Scenario.Priority, "anti-pattern", StringComparison.OrdinalIgnoreCase)) && + (normalizedCategory is null || string.Equals(entry.Scenario.Category, normalizedCategory, StringComparison.OrdinalIgnoreCase))); + + var filteredByFactory = filteredEntries + .GroupBy(entry => entry.Factory, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + + if (filteredByFactory.Count == 0) + { + return []; + } + + var factoryStats = BuildStats(filteredByFactory.Values.Select(entries => MergeDocs(entries.Select(entry => entry.FactoryDoc)))); + var rawTerms = Tokenize(query); + + var topFactories = filteredByFactory + .Select(pair => + { + var factoryDoc = MergeDocs(pair.Value.Select(entry => entry.FactoryDoc)); + var score = BM25.Score(queryTerms, factoryDoc, factoryStats); + if (score > 0.0 && rawTerms.Contains(pair.Key, StringComparer.Ordinal)) + { + score *= 2.0; + } + + return new FactoryScore(pair.Key, score); + }) + .Where(item => item.Score > 0.0) + .OrderByDescending(item => item.Score) + .ThenBy(item => item.Factory, StringComparer.Ordinal) + .Take(Math.Max(maxResults, 5)) + .ToArray(); + + if (topFactories.Length == 0) + { + return []; + } + + var scenarioStats = includeAntiPatterns && normalizedCategory is null + ? _stats + : BuildStats(filteredByFactory.Values.SelectMany(entries => entries).Select(entry => entry.ScenarioDoc)); + + var results = topFactories + .SelectMany(factory => filteredByFactory[factory.Factory]) + .Select(entry => new SearchResult(entry.Scenario, BM25.Score(queryTerms, entry.ScenarioDoc, scenarioStats))) + .Where(result => result.Score > 0.0) + .OrderByDescending(result => result.Score) + .ThenBy(result => result.Scenario.Title, StringComparer.Ordinal) + .Take(maxResults) + .ToArray(); + + return results; + } + + private static ScenarioEntry CreateEntry(Scenario scenario) + { + var factory = scenario.FactoryAnchors.FirstOrDefault() ?? string.Empty; + var factoryDoc = BuildWeightedDoc( + [ + (scenario.FactoryAnchors, 3.0), + (scenario.Tags, 3.0), + (new[] { scenario.Title }, 2.0), + (new[] { scenario.Intent }, 1.5) + ]); + var scenarioDoc = BuildWeightedDoc( + [ + (scenario.Tags, 1.0), + (new[] { scenario.Title }, 1.0), + (new[] { scenario.Intent }, 1.0) + ]); + + return new ScenarioEntry(scenario, factory, factoryDoc, scenarioDoc); + } + + private static WeightedDoc BuildWeightedDoc((IEnumerable Values, double Weight)[] fields) + { + var termWeights = new Dictionary(StringComparer.Ordinal); + var docLength = 0; + + foreach (var (values, weight) in fields) + { + foreach (var value in values) + { + foreach (var term in Tokenize(value)) + { + termWeights[term] = termWeights.TryGetValue(term, out var existing) + ? existing + weight + : weight; + docLength++; + } + } + } + + return new WeightedDoc(termWeights, docLength); + } + + private static WeightedDoc MergeDocs(IEnumerable docs) + { + var mergedWeights = new Dictionary(StringComparer.Ordinal); + var docLength = 0; + + foreach (var doc in docs) + { + docLength += doc.DocLength; + foreach (var (term, weight) in doc.TermWeights) + { + mergedWeights[term] = mergedWeights.TryGetValue(term, out var existing) + ? existing + weight + : weight; + } + } + + return new WeightedDoc(mergedWeights, docLength); + } + + private static CorpusStats BuildStats(IEnumerable docs) + { + var docCount = 0; + var totalDocLength = 0; + var docFrequency = new Dictionary(StringComparer.Ordinal); + + foreach (var doc in docs) + { + docCount++; + totalDocLength += doc.DocLength; + + foreach (var term in doc.TermWeights.Keys) + { + docFrequency[term] = docFrequency.TryGetValue(term, out var count) + ? count + 1 + : 1; + } + } + + var avgDocLength = docCount == 0 ? 0.0 : (double)totalDocLength / docCount; + return new CorpusStats(docCount, avgDocLength, docFrequency); + } + + private static string[] Tokenize(string text) + { + ArgumentNullException.ThrowIfNull(text); + + return TokenRegex() + .Matches(text.ToLowerInvariant()) + .Cast() + .Select(match => match.Value) + .Where(term => term.Length > 0 && !StopWords.IsStopWord(term)) + .ToArray(); + } + + [GeneratedRegex("[a-z0-9]+", RegexOptions.CultureInvariant)] + private static partial Regex TokenRegex(); + + private sealed record ScenarioEntry(Scenario Scenario, string Factory, WeightedDoc FactoryDoc, WeightedDoc ScenarioDoc); + + private sealed record FactoryScore(string Factory, double Score); +} diff --git a/src/Reactor.Cli/Find/StopWords.cs b/src/Reactor.Cli/Find/StopWords.cs new file mode 100644 index 000000000..fa3122439 --- /dev/null +++ b/src/Reactor.Cli/Find/StopWords.cs @@ -0,0 +1,55 @@ +#nullable enable + +using System.Collections.Frozen; + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal static class StopWords +{ + private static readonly FrozenSet _set = new[] + { + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "been", + "by", + "can", + "could", + "did", + "do", + "does", + "for", + "from", + "had", + "has", + "have", + "hook", + "in", + "is", + "it", + "may", + "might", + "of", + "on", + "or", + "reactor", + "should", + "that", + "the", + "this", + "to", + "was", + "were", + "will", + "with", + "would", + "element", + "factory" + }.ToFrozenSet(); + + public static bool IsStopWord(string term) => _set.Contains(term); +} diff --git a/src/Reactor.Cli/Find/Synonyms.cs b/src/Reactor.Cli/Find/Synonyms.cs new file mode 100644 index 000000000..89954b6d2 --- /dev/null +++ b/src/Reactor.Cli/Find/Synonyms.cs @@ -0,0 +1,119 @@ +#nullable enable + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal static partial class Synonyms +{ + private static readonly FrozenDictionary _phraseMap = new Dictionary(StringComparer.Ordinal) + { + ["content dialog"] = "contentdialog", + ["context menu"] = "contextmenu", + ["dark mode"] = "theme", + ["data grid"] = "datagrid", + ["global state"] = "context", + ["infinite scroll"] = "infinite", + ["master detail"] = "masterdetail", + ["pull to refresh"] = "pulltorefresh", + ["sidebar nav"] = "sidebar", + ["theme switch"] = "theme", + ["use context"] = "usecontext", + ["use effect"] = "useeffect", + ["use memo"] = "usememo", + ["use mutation"] = "usemutation", + ["use reducer"] = "usereducer", + ["use ref"] = "useref", + ["use resource"] = "useresource", + ["use state"] = "usestate" + }.ToFrozenDictionary(StringComparer.Ordinal); + + private static readonly FrozenDictionary _synonymMap = new Dictionary(StringComparer.Ordinal) + { + ["btn"] = ["button"], + ["button"] = ["button"], + ["card"] = ["card", "border"], + ["div"] = ["flexrow", "flexcolumn", "vstack", "hstack"], + ["drawer"] = ["navigationview", "splitview"], + ["dropdown"] = ["combobox"], + ["flex"] = ["flexrow", "flexcolumn"], + ["form"] = ["formfield", "usevalidationcontext"], + ["grid"] = ["grid", "gridview"], + ["img"] = ["image"], + ["input"] = ["textfield", "numberbox"], + ["list"] = ["listview", "foreach"], + ["loader"] = ["progressring", "progressbar"], + ["modal"] = ["contentdialog", "dialog"], + ["nav"] = ["navigationview", "usenavigation"], + ["popup"] = ["flyout", "contentdialog"], + ["select"] = ["combobox"], + ["sidebar"] = ["navigationview", "splitview"], + ["spinner"] = ["progressring"], + ["tabs"] = ["tabview", "pivot"], + ["toast"] = ["infobar"], + ["txt"] = ["textblock", "textfield"], + ["usecallback"] = ["usecallback"], + ["usecontext"] = ["usecontext"], + ["useeffect"] = ["useeffect"], + ["usememo"] = ["usememo"], + ["usereducer"] = ["usereducer"], + ["useref"] = ["useref"], + ["usestate"] = ["usestate"] + }.ToFrozenDictionary(StringComparer.Ordinal); + + public static string CollapsePhrase(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var collapsed = query.ToLowerInvariant(); + foreach (var (phrase, token) in _phraseMap) + { + collapsed = Regex.Replace( + collapsed, + $@"\b{Regex.Escape(phrase)}\b", + token, + RegexOptions.CultureInvariant); + } + + return collapsed; + } + + public static string[] Expand(string term) + { + ArgumentNullException.ThrowIfNull(term); + + var normalized = term.ToLowerInvariant(); + return _synonymMap.TryGetValue(normalized, out var expanded) + ? expanded + : [normalized]; + } + + public static string[] ProcessQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + + return Tokenize(CollapsePhrase(query)) + .Where(term => !StopWords.IsStopWord(term)) + .SelectMany(Expand) + .Where(term => !StopWords.IsStopWord(term)) + .ToArray(); + } + + private static IEnumerable Tokenize(string text) + { + foreach (Match match in TokenRegex().Matches(text.ToLowerInvariant())) + { + if (match.Value.Length > 0) + { + yield return match.Value; + } + } + } + + [GeneratedRegex("[a-z0-9]+", RegexOptions.CultureInvariant)] + private static partial Regex TokenRegex(); +} From a49731a4f2ba2538d9cc20b0b44a2a23a59e5036 Mon Sep 17 00:00:00 2001 From: Sundaram Ramaswamy <29264916+sundaramramaswamy@users.noreply.github.com> Date: Wed, 20 May 2026 23:01:24 +0530 Subject: [PATCH 06/21] Wire find/get/list CLI commands FindCommand uses SearchEngine for BM25 ranking. GetCommand shows scenario + notes + related. ListCommand groups by category. Program.cs dispatch. Spec 043 Phase 1, work item 4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Reactor.Cli/Find/FindCommand.cs | 89 ++++++++++++++++++++++++++++ src/Reactor.Cli/Find/GetCommand.cs | 91 +++++++++++++++++++++++++++++ src/Reactor.Cli/Find/ListCommand.cs | 78 +++++++++++++++++++++++++ src/Reactor.Cli/Program.cs | 18 ++++++ 4 files changed, 276 insertions(+) create mode 100644 src/Reactor.Cli/Find/FindCommand.cs create mode 100644 src/Reactor.Cli/Find/GetCommand.cs create mode 100644 src/Reactor.Cli/Find/ListCommand.cs diff --git a/src/Reactor.Cli/Find/FindCommand.cs b/src/Reactor.Cli/Find/FindCommand.cs new file mode 100644 index 000000000..cb04a6c06 --- /dev/null +++ b/src/Reactor.Cli/Find/FindCommand.cs @@ -0,0 +1,89 @@ +#nullable enable + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal static class FindCommand +{ + public static int Run(string[] args) + { + if (args.Any(static a => a is "--help" or "-h" or "-?")) + { + ShowHelp(); + return 0; + } + + var maxResults = 5; + string? category = null; + var includeAntiPatterns = false; + var queryParts = new List(); + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--max": + if (i + 1 >= args.Length || !int.TryParse(args[++i], out maxResults) || maxResults <= 0) + { + Console.Error.WriteLine("Error: --max requires a positive integer."); + return 1; + } + break; + + case "--category": + if (i + 1 >= args.Length) + { + Console.Error.WriteLine("Error: --category requires a value."); + return 1; + } + category = args[++i]; + break; + + case "--include-anti-patterns": + includeAntiPatterns = true; + break; + + default: + if (args[i].StartsWith("-", StringComparison.Ordinal)) + { + Console.Error.WriteLine($"Error: Unknown option '{args[i]}'."); + return 1; + } + + queryParts.Add(args[i]); + break; + } + } + + if (queryParts.Count == 0) + { + ShowHelp(); + return 1; + } + + var query = string.Join(" ", queryParts); + var catalogue = DataLoader.Load(); + var engine = new SearchEngine(catalogue); + var results = engine.Search(query, maxResults, category, includeAntiPatterns); + + if (results.Length == 0) + { + Console.WriteLine($"No matches found for \"{query}\"."); + return 0; + } + + Console.WriteLine($"Found {results.Length} matches for \"{query}\":"); + foreach (var result in results) + { + Console.WriteLine($" {result.Scenario.Id.PadRight(24)} {result.Scenario.Title.PadRight(40)} → SKILL: {result.Scenario.Category}"); + } + + Console.WriteLine("To get full code: mur get "); + return 0; + } + + private static void ShowHelp() + { + Console.WriteLine("Usage: mur find [--max N] [--category ] [--include-anti-patterns]"); + Console.WriteLine("Search the sample catalogue."); + } +} diff --git a/src/Reactor.Cli/Find/GetCommand.cs b/src/Reactor.Cli/Find/GetCommand.cs new file mode 100644 index 000000000..5b9200381 --- /dev/null +++ b/src/Reactor.Cli/Find/GetCommand.cs @@ -0,0 +1,91 @@ +#nullable enable + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal static class GetCommand +{ + public static int Run(string[] args) + { + if (args.Any(static a => a is "--help" or "-h" or "-?")) + { + ShowHelp(); + return 0; + } + + var raw = false; + string? scenarioId = null; + + foreach (var arg in args) + { + switch (arg) + { + case "--raw": + raw = true; + break; + + default: + if (arg.StartsWith("-", StringComparison.Ordinal)) + { + Console.Error.WriteLine($"Error: Unknown option '{arg}'."); + return 1; + } + + if (scenarioId is not null) + { + Console.Error.WriteLine("Error: Only one scenario id may be provided."); + return 1; + } + + scenarioId = arg; + break; + } + } + + if (string.IsNullOrWhiteSpace(scenarioId)) + { + ShowHelp(); + return 1; + } + + var catalogue = DataLoader.Load(); + var scenario = catalogue.Scenarios.FirstOrDefault(s => string.Equals(s.Id, scenarioId, StringComparison.OrdinalIgnoreCase)); + if (scenario is null) + { + Console.WriteLine($"Scenario '{scenarioId}' not found. Use 'mur list' to see all scenarios."); + return 1; + } + + Console.WriteLine($"## {scenario.Title}"); + Console.WriteLine($"*Category: {scenario.Category} · Intent: {scenario.Intent}*"); + Console.WriteLine(); + Console.WriteLine("**C#:**"); + Console.WriteLine("```csharp"); + Console.WriteLine(raw ? scenario.RawCode : scenario.Code); + Console.WriteLine("```"); + + var notes = Notes.GetNotes(scenario.NotesKey); + if (notes is { Length: > 0 } && scenario.NotesKey is not null) + { + Console.WriteLine(); + Console.WriteLine($"**Important (Notes for `{scenario.NotesKey}`):**"); + foreach (var note in notes) + { + Console.WriteLine($"- {note}"); + } + } + + if (scenario.RelatedIds.Length > 0) + { + Console.WriteLine(); + Console.WriteLine($"**See also:** {string.Join(", ", scenario.RelatedIds.Select(id => $"`{id}`"))}"); + } + + return 0; + } + + private static void ShowHelp() + { + Console.WriteLine("Usage: mur get [--raw]"); + Console.WriteLine("Show a sample scenario."); + } +} diff --git a/src/Reactor.Cli/Find/ListCommand.cs b/src/Reactor.Cli/Find/ListCommand.cs new file mode 100644 index 000000000..ea2bfef78 --- /dev/null +++ b/src/Reactor.Cli/Find/ListCommand.cs @@ -0,0 +1,78 @@ +#nullable enable + +namespace Microsoft.UI.Reactor.Cli.Find; + +internal static class ListCommand +{ + public static int Run(string[] args) + { + if (args.Any(static a => a is "--help" or "-h" or "-?")) + { + ShowHelp(); + return 0; + } + + string? category = null; + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--category": + if (i + 1 >= args.Length) + { + Console.Error.WriteLine("Error: --category requires a value."); + return 1; + } + + category = args[++i]; + break; + + default: + Console.Error.WriteLine($"Error: Unknown option '{args[i]}'."); + return 1; + } + } + + var catalogue = DataLoader.Load(); + var scenarios = catalogue.Scenarios.AsEnumerable(); + if (category is not null) + { + scenarios = scenarios.Where(s => string.Equals(s.Category, category, StringComparison.OrdinalIgnoreCase)); + } + + var groups = scenarios + .GroupBy(s => s.Category, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (groups.Count == 0 && category is not null) + { + Console.WriteLine($"No scenarios in category '{category}'."); + return 0; + } + + for (var index = 0; index < groups.Count; index++) + { + var group = groups[index]; + Console.WriteLine(group.Key.ToUpperInvariant()); + foreach (var scenario in group.OrderBy(s => s.Id, StringComparer.OrdinalIgnoreCase)) + { + Console.WriteLine($" {scenario.Id.PadRight(24)} {scenario.Title}"); + } + + if (index < groups.Count - 1) + { + Console.WriteLine(); + } + } + + return 0; + } + + private static void ShowHelp() + { + Console.WriteLine("Usage: mur list [--category ]"); + Console.WriteLine("List all scenarios."); + } +} diff --git a/src/Reactor.Cli/Program.cs b/src/Reactor.Cli/Program.cs index efc9e9a44..9ef9079f4 100644 --- a/src/Reactor.Cli/Program.cs +++ b/src/Reactor.Cli/Program.cs @@ -64,6 +64,21 @@ return Microsoft.UI.Reactor.Cli.Docs.DocsCommand.Run(args.Skip(1).ToArray()); } +if (arg == "find") +{ + return Microsoft.UI.Reactor.Cli.Find.FindCommand.Run(args.Skip(1).ToArray()); +} + +if (arg == "get") +{ + return Microsoft.UI.Reactor.Cli.Find.GetCommand.Run(args.Skip(1).ToArray()); +} + +if (arg == "list") +{ + return Microsoft.UI.Reactor.Cli.Find.ListCommand.Run(args.Skip(1).ToArray()); +} + if (arg == "devtools") { return Microsoft.UI.Reactor.Cli.Devtools.DevtoolsSupervisor.Run(args.Skip(1).ToArray()); @@ -114,6 +129,9 @@ void ShowHelp() Console.WriteLine(" loc status Show translation coverage per locale"); Console.WriteLine(" loc prune Find unused localization keys"); Console.WriteLine(" docs compile Compile documentation from templates and doc apps"); + Console.WriteLine(" find Search the sample catalogue"); + Console.WriteLine(" get Show a sample scenario"); + Console.WriteLine(" list List all scenarios"); Console.WriteLine(" devtools Launch project with --devtools run and supervise reloads"); Console.WriteLine(" check [path] Build and emit one-line diagnostics with skill-file pointers"); Console.WriteLine(" pack-local Pack the in-source framework to /local-nupkgs/ as 0.0.0-local"); From ae8842cd829a31abf5d4099e30987b784d7c9dce Mon Sep 17 00:00:00 2001 From: Sundaram Ramaswamy <29264916+sundaramramaswamy@users.noreply.github.com> Date: Wed, 20 May 2026 23:03:12 +0530 Subject: [PATCH 07/21] Add unit tests for Find subsystem BM25, SearchEngine, Synonyms, Notes tests. Spec 043 Phase 1, work item 7. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Reactor.Tests/Find/BM25Tests.cs | 57 ++++++++++++ tests/Reactor.Tests/Find/NotesTests.cs | 30 +++++++ tests/Reactor.Tests/Find/SearchEngineTests.cs | 87 +++++++++++++++++++ tests/Reactor.Tests/Find/SynonymsTests.cs | 50 +++++++++++ 4 files changed, 224 insertions(+) create mode 100644 tests/Reactor.Tests/Find/BM25Tests.cs create mode 100644 tests/Reactor.Tests/Find/NotesTests.cs create mode 100644 tests/Reactor.Tests/Find/SearchEngineTests.cs create mode 100644 tests/Reactor.Tests/Find/SynonymsTests.cs diff --git a/tests/Reactor.Tests/Find/BM25Tests.cs b/tests/Reactor.Tests/Find/BM25Tests.cs new file mode 100644 index 000000000..0289cc746 --- /dev/null +++ b/tests/Reactor.Tests/Find/BM25Tests.cs @@ -0,0 +1,57 @@ +#nullable enable + +using Microsoft.UI.Reactor.Cli.Find; +using Xunit; + +namespace Reactor.Tests.Find; + +public class BM25Tests +{ + [Fact] + public void Score_MatchingTerm_ReturnsPositive() + { + var doc = new WeightedDoc(new Dictionary { ["button"] = 1.0 }, 1); + var stats = new CorpusStats(1, 1, new Dictionary { ["button"] = 1 }); + + var score = BM25.Score(["button"], doc, stats); + + Assert.True(score > 0); + } + + [Fact] + public void Score_NoMatch_ReturnsZero() + { + var doc = new WeightedDoc(new Dictionary { ["button"] = 1.0 }, 1); + var stats = new CorpusStats(1, 1, new Dictionary { ["button"] = 1 }); + + var score = BM25.Score(["dialog"], doc, stats); + + Assert.Equal(0, score); + } + + [Fact] + public void Score_HigherWeight_ScoresHigher() + { + var lowWeightDoc = new WeightedDoc(new Dictionary { ["button"] = 1.0 }, 10); + var highWeightDoc = new WeightedDoc(new Dictionary { ["button"] = 3.0 }, 10); + var stats = new CorpusStats(2, 10, new Dictionary { ["button"] = 2 }); + + var lowScore = BM25.Score(["button"], lowWeightDoc, stats); + var highScore = BM25.Score(["button"], highWeightDoc, stats); + + Assert.True(highScore > lowScore); + } + + [Fact] + public void Score_ShorterDoc_ScoresHigher() + { + var shortDoc = new WeightedDoc(new Dictionary { ["button"] = 1.0 }, 4); + var longDoc = new WeightedDoc(new Dictionary { ["button"] = 1.0 }, 20); + var stats = new CorpusStats(2, 12, new Dictionary { ["button"] = 2 }); + + var shortScore = BM25.Score(["button"], shortDoc, stats); + var longScore = BM25.Score(["button"], longDoc, stats); + + Assert.True(shortScore > longScore); + } +} diff --git a/tests/Reactor.Tests/Find/NotesTests.cs b/tests/Reactor.Tests/Find/NotesTests.cs new file mode 100644 index 000000000..ac3c3fffd --- /dev/null +++ b/tests/Reactor.Tests/Find/NotesTests.cs @@ -0,0 +1,30 @@ +#nullable enable + +using Microsoft.UI.Reactor.Cli.Find; +using Xunit; + +namespace Reactor.Tests.Find; + +public class NotesTests +{ + [Fact] + public void GetNotes_KnownKey_ReturnsNotes() + { + var notes = Notes.GetNotes("UseState"); + + Assert.NotNull(notes); + Assert.NotEmpty(notes!); + } + + [Fact] + public void GetNotes_UnknownKey_ReturnsNull() + { + Assert.Null(Notes.GetNotes("UnknownKey")); + } + + [Fact] + public void GetNotes_Null_ReturnsNull() + { + Assert.Null(Notes.GetNotes(null)); + } +} diff --git a/tests/Reactor.Tests/Find/SearchEngineTests.cs b/tests/Reactor.Tests/Find/SearchEngineTests.cs new file mode 100644 index 000000000..398b7d97d --- /dev/null +++ b/tests/Reactor.Tests/Find/SearchEngineTests.cs @@ -0,0 +1,87 @@ +#nullable enable + +using Microsoft.UI.Reactor.Cli.Find; +using Xunit; + +namespace Reactor.Tests.Find; + +public class SearchEngineTests +{ + private static ScenarioCatalogue CreateTestCatalogue() => new( + [ + new Scenario("use-state-basic", "hooks", "Counter with UseState", "increment a primitive value on click", + ["state", "counter", "hook"], ["UseState", "Button", "VStack"], "UseState", [], "P0", "// code", "// raw"), + new Scenario("button-label", "buttons", "Button with label and onClick", "basic button with click handler", + ["button", "click", "handler"], ["Button"], null, [], "P0", "// code", "// raw"), + new Scenario("sidebar-nav", "navigation", "Sidebar with NavigationView", "sidebar pane with two items", + ["sidebar", "navigation", "nav"], ["NavigationView"], null, [], "P0", "// code", "// raw"), + new Scenario("anti-pattern-1", "hooks", "Bad useState with list", "mutating list in place", + ["state", "list", "anti-pattern"], ["UseState"], null, ["use-state-basic"], "anti-pattern", "// code", "// raw"), + ], + "2026-01-01T00:00:00Z" + ); + + [Fact] + public void Search_ExactFactoryName_ReturnsMatch() + { + var engine = new SearchEngine(CreateTestCatalogue()); + + var results = engine.Search("UseState", 5, null, includeAntiPatterns: false); + + Assert.NotEmpty(results); + Assert.Equal("use-state-basic", results[0].Scenario.Id); + } + + [Fact] + public void Search_SynonymExpansion_FindsMatch() + { + var engine = new SearchEngine(CreateTestCatalogue()); + + var results = engine.Search("nav", 5, null, includeAntiPatterns: false); + + Assert.Contains(results, result => result.Scenario.Id == "sidebar-nav"); + } + + [Fact] + public void Search_CategoryFilter_RestrictsResults() + { + var engine = new SearchEngine(CreateTestCatalogue()); + + var results = engine.Search("button state sidebar", 10, "hooks", includeAntiPatterns: true); + + Assert.NotEmpty(results); + Assert.All(results, result => Assert.Equal("hooks", result.Scenario.Category)); + Assert.DoesNotContain(results, result => result.Scenario.Id == "button-label"); + Assert.DoesNotContain(results, result => result.Scenario.Id == "sidebar-nav"); + } + + [Fact] + public void Search_AntiPatternsExcludedByDefault() + { + var engine = new SearchEngine(CreateTestCatalogue()); + + var results = engine.Search("mutating list usestate", 10, null, includeAntiPatterns: false); + + Assert.DoesNotContain(results, result => result.Scenario.Id == "anti-pattern-1"); + } + + [Fact] + public void Search_AntiPatternsIncludedWhenRequested() + { + var engine = new SearchEngine(CreateTestCatalogue()); + + var results = engine.Search("mutating list usestate", 10, null, includeAntiPatterns: true); + + Assert.Contains(results, result => result.Scenario.Id == "anti-pattern-1"); + } + + [Fact] + public void Search_MaxResults_Respected() + { + var engine = new SearchEngine(CreateTestCatalogue()); + + var results = engine.Search("button state sidebar", 1, null, includeAntiPatterns: true); + + Assert.Single(results); + } +} diff --git a/tests/Reactor.Tests/Find/SynonymsTests.cs b/tests/Reactor.Tests/Find/SynonymsTests.cs new file mode 100644 index 000000000..c4232cead --- /dev/null +++ b/tests/Reactor.Tests/Find/SynonymsTests.cs @@ -0,0 +1,50 @@ +#nullable enable + +using Microsoft.UI.Reactor.Cli.Find; +using Xunit; + +namespace Reactor.Tests.Find; + +public class SynonymsTests +{ + [Fact] + public void CollapsePhrase_MultiWord_CollapsesToToken() + { + Assert.Equal("usestate", Synonyms.CollapsePhrase("use state")); + } + + [Fact] + public void Expand_KnownSynonym_ReturnsTargets() + { + Assert.Equal(["contentdialog", "dialog"], Synonyms.Expand("modal")); + } + + [Fact] + public void Expand_Unknown_ReturnsSelf() + { + Assert.Equal(["foobar"], Synonyms.Expand("foobar")); + } + + [Fact] + public void ProcessQuery_FullPipeline() + { + var terms = Synonyms.ProcessQuery("use state counter"); + + Assert.Contains("usestate", terms); + Assert.Contains("counter", terms); + Assert.DoesNotContain("use", terms); + Assert.DoesNotContain("state", terms); + } + + [Fact] + public void ProcessQuery_RemovesStopWords() + { + var terms = Synonyms.ProcessQuery("the button for the form"); + + Assert.DoesNotContain("the", terms); + Assert.DoesNotContain("for", terms); + Assert.Contains("button", terms); + Assert.Contains("formfield", terms); + Assert.Contains("usevalidationcontext", terms); + } +} From d8c2ffe815e6c5755f79b547acda2566765a6453 Mon Sep 17 00:00:00 2001 From: Sundaram Ramaswamy <29264916+sundaramramaswamy@users.noreply.github.com> Date: Wed, 20 May 2026 23:06:03 +0530 Subject: [PATCH 08/21] Wire scenarios.json as embedded resource Generate via SampleCatalogue extractor, embed in Reactor.Cli.csproj. Remove unnecessary PackageRef from extractor. Spec 043 Phase 1, wiring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/scenarios/_generated/scenarios.json | 31 +++++++++++++++++++ src/Reactor.Cli/Reactor.Cli.csproj | 2 ++ .../Reactor.SampleCatalogue.csproj | 3 -- 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 samples/scenarios/_generated/scenarios.json diff --git a/samples/scenarios/_generated/scenarios.json b/samples/scenarios/_generated/scenarios.json new file mode 100644 index 000000000..bcf3a2dfb --- /dev/null +++ b/samples/scenarios/_generated/scenarios.json @@ -0,0 +1,31 @@ +{ + "scenarios": [ + { + "id": "use-state-basic", + "category": "hooks", + "title": "Counter with UseState", + "intent": "increment a primitive value on click", + "tags": [ + "state", + "counter", + "hook", + "useState", + "primitive" + ], + "factoryAnchors": [ + "UseState", + "Button", + "VStack" + ], + "notesKey": "UseState", + "relatedIds": [ + "use-state-list-pitfall", + "use-reducer-list" + ], + "priority": "P0", + "code": "using Microsoft.UI.Reactor;\r\nusing static Microsoft.UI.Reactor.Factories;\r\n\r\nReactorApp.Run\u003CApp\u003E(\u0022UseStateBasic\u0022, width: 400, height: 200);\r\n\r\nclass App : Component\r\n{\r\n public override Element Render()\r\n {\r\n var (count, setCount) = UseState(0);\r\n return VStack(\r\n Heading($\u0022Count: {count}\u0022),\r\n Button(\u0022\u002B1\u0022, () =\u003E setCount(count \u002B 1)));\r\n }\r\n}\r\n", + "rawCode": "// id: use-state-basic\r\n// intent: count clicks; demonstrate UseState with primitive value\r\n#:package Microsoft.UI.Reactor@0.0.0-local\r\n#:property Platform=ARM64\r\n\r\nusing Microsoft.UI.Reactor;\r\nusing static Microsoft.UI.Reactor.Factories;\r\n\r\nReactorApp.Run\u003CApp\u003E(\u0022UseStateBasic\u0022, width: 400, height: 200);\r\n\r\nclass App : Component\r\n{\r\n public override Element Render()\r\n {\r\n var (count, setCount) = UseState(0);\r\n return VStack(\r\n Heading($\u0022Count: {count}\u0022),\r\n Button(\u0022\u002B1\u0022, () =\u003E setCount(count \u002B 1)));\r\n }\r\n}\r\n" + } + ], + "generatedAt": "2026-05-20T17:33:23.8920168\u002B00:00" +} \ No newline at end of file diff --git a/src/Reactor.Cli/Reactor.Cli.csproj b/src/Reactor.Cli/Reactor.Cli.csproj index f66673fb5..738808989 100644 --- a/src/Reactor.Cli/Reactor.Cli.csproj +++ b/src/Reactor.Cli/Reactor.Cli.csproj @@ -50,6 +50,8 @@ +