From 05567507f40cefa4e332f8d69bf0c9ff68af3731 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Thu, 4 Jun 2026 12:48:04 +0200 Subject: [PATCH 1/2] feat: guide recipes include real template parameters (#92) --- .../ComponentParameterListCliCommand.cs | 17 ++ src/TALXIS.CLI.MCP/GuideHandler.cs | 26 ++- src/TALXIS.CLI.MCP/Program.cs | 6 +- .../SubprocessTemplateParameterProvider.cs | 97 ++++++++++ .../TemplateParameterEnricher.cs | 160 ++++++++++++++++ src/TALXIS.CLI.MCP/TemplateParameterInfo.cs | 54 ++++++ .../MCP/TemplateParameterEnricherTests.cs | 172 ++++++++++++++++++ 7 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 src/TALXIS.CLI.MCP/SubprocessTemplateParameterProvider.cs create mode 100644 src/TALXIS.CLI.MCP/TemplateParameterEnricher.cs create mode 100644 src/TALXIS.CLI.MCP/TemplateParameterInfo.cs create mode 100644 tests/TALXIS.CLI.Tests/MCP/TemplateParameterEnricherTests.cs diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs index 1287aed2..d4281c81 100644 --- a/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs @@ -52,6 +52,10 @@ protected override async Task ExecuteAsync() defaultValue = (string?)null, required = true, choices = (string?)null, + // appliesWhen/requiredWhen carry the template's conditional logic (e.g. a + // parameter that only applies when AttributeType == "Text"). Null = unconditional. + appliesWhen = (string?)null, + requiredWhen = (string?)null, description = "Specifies the target project folder path where the component will be created. " + "Required for both creating new projects (solution, plugin, PCF, etc.) and adding components to existing projects. " + "Format: \"src\\Solutions.DataModel\"" @@ -65,6 +69,13 @@ protected override async Task ExecuteAsync() ? string.Join(", ", p.Choices.Keys) : null; + // Surface the template's conditional precedence so callers (and the MCP guide) + // know a parameter only applies / is only required under a condition. + var appliesWhen = string.IsNullOrWhiteSpace(p.Precedence.IsEnabledCondition) + ? null : p.Precedence.IsEnabledCondition; + var requiredWhen = string.IsNullOrWhiteSpace(p.Precedence.IsRequiredCondition) + ? null : p.Precedence.IsRequiredCondition; + projected.Add(new { name = p.Name, @@ -73,6 +84,8 @@ protected override async Task ExecuteAsync() defaultValue = p.DefaultValue?.ToString(), required = isRequired, choices = choiceList, + appliesWhen, + requiredWhen, description = p.Description }); } @@ -99,6 +112,10 @@ protected override async Task ExecuteAsync() if (!string.IsNullOrEmpty((string?)param.choices)) sb.Append($" choices: {param.choices}"); OutputWriter.WriteLine(sb.ToString()); + if (!string.IsNullOrEmpty((string?)param.appliesWhen)) + OutputWriter.WriteLine($" applies when: {param.appliesWhen}"); + if (!string.IsNullOrEmpty((string?)param.requiredWhen)) + OutputWriter.WriteLine($" required when: {param.requiredWhen}"); if (!string.IsNullOrEmpty((string?)param.description)) OutputWriter.WriteLine($" {param.description}"); } diff --git a/src/TALXIS.CLI.MCP/GuideHandler.cs b/src/TALXIS.CLI.MCP/GuideHandler.cs index fcc8026a..e17991c1 100644 --- a/src/TALXIS.CLI.MCP/GuideHandler.cs +++ b/src/TALXIS.CLI.MCP/GuideHandler.cs @@ -18,12 +18,18 @@ public class GuideHandler private readonly ToolCatalog _catalog; private readonly ActiveToolSet _activeToolSet; private readonly GuideReasoningEngine? _reasoningEngine; + private readonly ITemplateParameterProvider? _templateParams; - public GuideHandler(ToolCatalog catalog, ActiveToolSet activeToolSet, GuideReasoningEngine? reasoningEngine = null) + public GuideHandler( + ToolCatalog catalog, + ActiveToolSet activeToolSet, + GuideReasoningEngine? reasoningEngine = null, + ITemplateParameterProvider? templateParams = null) { _catalog = catalog; _activeToolSet = activeToolSet; _reasoningEngine = reasoningEngine; + _templateParams = templateParams; } /// @@ -76,6 +82,9 @@ public async Task HandleAsync( var toolDefinitions = entries.Select(McpToolRegistry.BuildToolDefinition).ToList(); _activeToolSet.InjectTools(toolDefinitions); + // Attach authoritative template parameters when the recipe scaffolds. + recipeText = await EnrichRecipeWithTemplateParamsAsync(recipeText, entries, ct); + // Build structured response (includes recipe when available from sampling) var response = BuildGuidanceResponse(entries, query, recipeText); @@ -114,6 +123,9 @@ public async Task HandleWorkflowGuideAsync( var toolDefinitions = entries.Select(McpToolRegistry.BuildToolDefinition).ToList(); _activeToolSet.InjectTools(toolDefinitions); + // Attach authoritative template parameters when the recipe scaffolds. + recipeText = await EnrichRecipeWithTemplateParamsAsync(recipeText, entries, ct); + var response = BuildGuidanceResponse(entries, query, recipeText); return McpToolResultFactory.BuildTextResult(response); @@ -179,6 +191,7 @@ 3. Then a numbered recipe with concrete steps - Environment operations take 30s-5min — only use for inspection/deployment - If a step depends on a previous step's output, say so - Include error recovery hints for common failures (TALXISXSD001 = schema validation, TALXISGUID001 = duplicate GUIDs) +- If any step scaffolds a component via workspace_component_create, append a line ""TEMPLATES: [, ...]"" (e.g. ""TEMPLATES: pp-entity, pp-entity-attribute"") naming the exact template short-names used. The real parameter list for those templates will be attached below your recipe — use those exact parameter names and choices, do not invent parameters. {catalogPrompt}{internalSkillsContext}"; @@ -208,6 +221,17 @@ 3. Then a numbered recipe with concrete steps return ParseToolNamesAndRecipeFromSamplingResponse(responseText); } + /// + /// When the recipe scaffolds via workspace_component_create, fetches the real + /// parameters for each referenced template and appends an authoritative block so the + /// model uses genuine parameter names/types/choices/conditions instead of inventing + /// them. Best-effort: any lookup failure leaves the recipe unchanged. + /// + private Task EnrichRecipeWithTemplateParamsAsync( + string? recipeText, List entries, CancellationToken ct) + => TemplateParameterEnricher.EnrichAsync( + recipeText, entries.Select(e => e.Descriptor.Name), _templateParams, ct); + /// /// Parses tool names and recipe text from a sampling response. /// Scans each line for a valid JSON string array (tool names), then extracts recipe text from lines after. diff --git a/src/TALXIS.CLI.MCP/Program.cs b/src/TALXIS.CLI.MCP/Program.cs index e5db958a..1ce6fc2e 100644 --- a/src/TALXIS.CLI.MCP/Program.cs +++ b/src/TALXIS.CLI.MCP/Program.cs @@ -39,7 +39,11 @@ // Session-scoped active tool set — starts with always-on tools only var activeToolSet = new ActiveToolSet(); -var guideHandler = new GuideHandler(mcpToolRegistry.Catalog, activeToolSet, reasoningEngine); +// Resolves the workspace root lazily — rootsService is assigned later in startup, +// so the closure reads it at call time, not now. +var templateParameterProvider = new SubprocessTemplateParameterProvider( + async ct => rootsService is not null ? await rootsService.GetWorkingDirectoryAsync(ct) : null); +var guideHandler = new GuideHandler(mcpToolRegistry.Catalog, activeToolSet, reasoningEngine, templateParameterProvider); // Register always-on tools (these are the only tools visible at session start) RegisterAlwaysOnTools(activeToolSet, mcpToolRegistry, publicSkillLoader); diff --git a/src/TALXIS.CLI.MCP/SubprocessTemplateParameterProvider.cs b/src/TALXIS.CLI.MCP/SubprocessTemplateParameterProvider.cs new file mode 100644 index 00000000..60224f7c --- /dev/null +++ b/src/TALXIS.CLI.MCP/SubprocessTemplateParameterProvider.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using TALXIS.CLI.Core; + +namespace TALXIS.CLI.MCP; + +/// +/// Production : shells out to +/// `txc workspace component parameter list <type> --format json` (the same +/// CLI subprocess path the rest of the MCP server uses) and caches the result for +/// the lifetime of the process — template parameters don't change at runtime. +/// +internal sealed class SubprocessTemplateParameterProvider : ITemplateParameterProvider +{ + private readonly Func> _workingDirectoryProvider; + private readonly ConcurrentDictionary?> _cache = + new(StringComparer.OrdinalIgnoreCase); + + public SubprocessTemplateParameterProvider(Func> workingDirectoryProvider) + { + _workingDirectoryProvider = workingDirectoryProvider; + } + + public async Task?> GetParametersAsync(string templateShortName, CancellationToken ct) + { + if (_cache.TryGetValue(templateShortName, out var cached)) + return cached; + + IReadOnlyList? parsed = null; + try + { + var workingDirectory = await _workingDirectoryProvider(ct).ConfigureAwait(false); + var args = new[] { "workspace", "component", "parameter", "list", templateShortName, "--format", "json" }; + var handler = new StdoutCaptureHandler(); + var result = await CliSubprocessRunner.RunAsync(args, handler, ct, workingDirectory).ConfigureAwait(false); + + if (result.ExitCode == 0) + parsed = ParseParameters(handler.GetStdout()); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + // Enrichment is best-effort — a lookup failure must never break the guide. + parsed = null; + } + + _cache[templateShortName] = parsed; + return parsed; + } + + /// + /// Extracts the JSON array from captured stdout (tolerant of any leading/trailing + /// noise) and deserializes it into parameter records. + /// + private static IReadOnlyList? ParseParameters(string stdout) + { + if (string.IsNullOrWhiteSpace(stdout)) + return null; + + var start = stdout.IndexOf('['); + var end = stdout.LastIndexOf(']'); + if (start < 0 || end <= start) + return null; + + var json = stdout[start..(end + 1)]; + try + { + return JsonSerializer.Deserialize>(json, TxcOutputJsonOptions.Default); + } + catch (JsonException) + { + return null; + } + } + + /// Collects subprocess stdout lines; ignores stderr (JSON log lines in MCP mode). + private sealed class StdoutCaptureHandler : ISubprocessOutputHandler + { + private readonly StringBuilder _stdout = new(); + + public Task OnStdoutLineAsync(string line) + { + _stdout.AppendLine(line); + return Task.CompletedTask; + } + + public Task OnStderrLineAsync(string line) => Task.CompletedTask; + + public Task OnProcessExitedAsync(int exitCode) => Task.CompletedTask; + + public string GetStdout() => _stdout.ToString(); + } +} diff --git a/src/TALXIS.CLI.MCP/TemplateParameterEnricher.cs b/src/TALXIS.CLI.MCP/TemplateParameterEnricher.cs new file mode 100644 index 00000000..50d9e81b --- /dev/null +++ b/src/TALXIS.CLI.MCP/TemplateParameterEnricher.cs @@ -0,0 +1,160 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace TALXIS.CLI.MCP; + +/// +/// Pure helpers to pull the component template short-names a scaffolding recipe targets, +/// and render an authoritative parameter block so the model uses real parameter +/// names/types/choices/conditions instead of inventing them. +/// +public static class TemplateParameterEnricher +{ + // Matches template short-names like pp-entity, pp-entity-attribute, pp-api-endpoint. + private static readonly Regex ShortNameToken = new(@"\bpp-[a-z0-9]+(?:-[a-z0-9]+)*\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// + /// The tool whose presence in a recipe means scaffolding is happening. + /// + public const string ScaffoldToolName = "workspace_component_create"; + + /// + /// If the recipe scaffolds via , resolves the real + /// parameters for each referenced template through and + /// appends an authoritative block. Best-effort: missing provider, no scaffolding, + /// no templates, or failed lookups all leave the recipe unchanged. + /// + public static async Task EnrichAsync( + string? recipe, + IEnumerable matchedToolNames, + ITemplateParameterProvider? provider, + CancellationToken ct) + { + if (provider is null || string.IsNullOrWhiteSpace(recipe)) + return recipe; + + var scaffolds = matchedToolNames.Any(n => + string.Equals(n, ScaffoldToolName, StringComparison.OrdinalIgnoreCase)); + if (!scaffolds) + return recipe; + + var shortNames = ExtractTemplateShortNames(recipe); + if (shortNames.Count == 0) + return recipe; + + var resolved = new List<(string Template, IReadOnlyList Parameters)>(); + foreach (var shortName in shortNames) + { + var parameters = await provider.GetParametersAsync(shortName, ct); + if (parameters is { Count: > 0 }) + resolved.Add((shortName, parameters)); + } + + if (resolved.Count == 0) + return recipe; + + return recipe.TrimEnd() + "\n\n" + BuildAuthoritativeBlock(resolved); + } + + /// + /// Extracts the component template short-names referenced by a recipe. Prefers an + /// explicit TEMPLATES: a, b line the model is asked to emit; otherwise falls + /// back to scanning for pp-* tokens. Returns a de-duplicated, order-preserving list. + /// + public static IReadOnlyList ExtractTemplateShortNames(string? recipe) + { + if (string.IsNullOrWhiteSpace(recipe)) + return Array.Empty(); + + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(string name) + { + var trimmed = name.Trim(); + if (trimmed.Length > 0 && seen.Add(trimmed)) + result.Add(trimmed); + } + + // Primary: an explicit "TEMPLATES: pp-entity, pp-entity-attribute" line. + foreach (var rawLine in recipe.ReplaceLineEndings("\n").Split('\n')) + { + var line = rawLine.Trim(); + if (line.StartsWith("TEMPLATES:", StringComparison.OrdinalIgnoreCase)) + { + var list = line["TEMPLATES:".Length..]; + foreach (var part in list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + // Keep only token-like values; ignore prose the model may append. + var m = ShortNameToken.Match(part); + Add(m.Success ? m.Value : part); + } + } + } + + // Fallback: any pp-* token in the recipe body. + if (result.Count == 0) + { + foreach (Match m in ShortNameToken.Matches(recipe)) + Add(m.Value); + } + + return result; + } + + /// + /// Renders an authoritative markdown block listing the real parameters per template. + /// + public static string BuildAuthoritativeBlock( + IReadOnlyList<(string Template, IReadOnlyList Parameters)> templates) + { + var sb = new StringBuilder(); + sb.AppendLine("## Exact template parameters (authoritative)"); + sb.AppendLine(); + sb.AppendLine("Use these real parameter names, types, and choices for `workspace_component_create` — do not invent or guess. Pass them as `Param` entries in `key=value` form."); + + foreach (var (template, parameters) in templates) + { + sb.AppendLine(); + sb.AppendLine($"### {template}"); + + var required = parameters.Where(p => p.Required).ToList(); + var optional = parameters.Where(p => !p.Required).ToList(); + + if (required.Count > 0) + { + sb.AppendLine("Required:"); + foreach (var p in required) + sb.AppendLine(FormatParam(p)); + } + if (optional.Count > 0) + { + sb.AppendLine("Optional:"); + foreach (var p in optional) + sb.AppendLine(FormatParam(p)); + } + } + + return sb.ToString().TrimEnd(); + } + + private static string FormatParam(TemplateParameterInfo p) + { + var sb = new StringBuilder(); + sb.Append($"- `{p.Name}`"); + if (!string.IsNullOrEmpty(p.DataType)) + sb.Append($" ({p.DataType})"); + if (!string.IsNullOrEmpty(p.Choices)) + sb.Append($" — choices: {p.Choices}"); + if (!string.IsNullOrEmpty(p.DefaultValue)) + sb.Append($" [default: {p.DefaultValue}]"); + if (!string.IsNullOrEmpty(p.AppliesWhen)) + sb.Append($" — applies when: {p.AppliesWhen}"); + if (!string.IsNullOrEmpty(p.RequiredWhen)) + sb.Append($" — required when: {p.RequiredWhen}"); + if (!string.IsNullOrEmpty(p.Description)) + sb.Append($" — {p.Description}"); + return sb.ToString(); + } +} diff --git a/src/TALXIS.CLI.MCP/TemplateParameterInfo.cs b/src/TALXIS.CLI.MCP/TemplateParameterInfo.cs new file mode 100644 index 00000000..d4deed16 --- /dev/null +++ b/src/TALXIS.CLI.MCP/TemplateParameterInfo.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace TALXIS.CLI.MCP; + +/// +/// A single scaffolding parameter for a component template, mirroring the JSON shape +/// emitted by `txc workspace component parameter list <type> --format json`. +/// Used by the guide tools to attach authoritative parameter details to scaffolding +/// recipes instead of letting the model invent names. +/// +public sealed class TemplateParameterInfo +{ + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; init; } + + [JsonPropertyName("dataType")] + public string? DataType { get; init; } + + [JsonPropertyName("defaultValue")] + public string? DefaultValue { get; init; } + + [JsonPropertyName("required")] + public bool Required { get; init; } + + [JsonPropertyName("choices")] + public string? Choices { get; init; } + + /// Condition under which the parameter applies (e.g. AttributeType == "Text"); null if unconditional. + [JsonPropertyName("appliesWhen")] + public string? AppliesWhen { get; init; } + + /// Condition under which the parameter is required; null if unconditional. + [JsonPropertyName("requiredWhen")] + public string? RequiredWhen { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } +} + +/// +/// Supplies the real parameter list for a component template. Implemented over a +/// subprocess call to the CLI in production; faked in tests. +/// +public interface ITemplateParameterProvider +{ + /// + /// Returns the parameters for a template short-name (e.g. pp-entity-attribute), + /// or null if the template is unknown or the lookup failed. + /// + Task?> GetParametersAsync(string templateShortName, CancellationToken ct); +} diff --git a/tests/TALXIS.CLI.Tests/MCP/TemplateParameterEnricherTests.cs b/tests/TALXIS.CLI.Tests/MCP/TemplateParameterEnricherTests.cs new file mode 100644 index 00000000..a2bb57e2 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/MCP/TemplateParameterEnricherTests.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using TALXIS.CLI.Core; +using TALXIS.CLI.MCP; +using Xunit; + +namespace TALXIS.CLI.Tests.MCP; + +/// +/// The guide must attach real template parameters to scaffolding recipes. +/// These cover the pure pieces — extracting the template short-names from a recipe and +/// rendering the authoritative parameter block — plus the DTO round-trip that the +/// subprocess provider relies on. +/// +public class TemplateParameterEnricherTests +{ + [Fact] + public void ExtractTemplateShortNames_PrefersExplicitTemplatesLine() + { + var recipe = """ + 1. Create the entity + 2. Add an attribute + + TEMPLATES: pp-entity, pp-entity-attribute + """; + + var names = TemplateParameterEnricher.ExtractTemplateShortNames(recipe); + + Assert.Equal(new[] { "pp-entity", "pp-entity-attribute" }, names); + } + + [Fact] + public void ExtractTemplateShortNames_FallsBackToScanningPpTokens() + { + var recipe = "Run execute_operation workspace_component_create with type=pp-entity, then add a pp-api-endpoint."; + + var names = TemplateParameterEnricher.ExtractTemplateShortNames(recipe); + + Assert.Equal(new[] { "pp-entity", "pp-api-endpoint" }, names); + } + + [Fact] + public void ExtractTemplateShortNames_DeduplicatesCaseInsensitively() + { + var recipe = "TEMPLATES: pp-entity, PP-Entity, pp-entity"; + + var names = TemplateParameterEnricher.ExtractTemplateShortNames(recipe); + + Assert.Single(names); + Assert.Equal("pp-entity", names[0]); + } + + [Fact] + public void ExtractTemplateShortNames_EmptyOrNull_ReturnsEmpty() + { + Assert.Empty(TemplateParameterEnricher.ExtractTemplateShortNames(null)); + Assert.Empty(TemplateParameterEnricher.ExtractTemplateShortNames("just prose, no templates here")); + } + + [Fact] + public void BuildAuthoritativeBlock_GroupsRequiredOptional_AndRendersChoicesAndConditions() + { + var parameters = new List + { + new() { Name = "AttributeType", DataType = "choice", Required = true, Choices = "Text, Lookup, Decimal", Description = "Data type" }, + new() { Name = "MaxLength", DataType = "int", Required = false, DefaultValue = "100", AppliesWhen = "AttributeType == \"Text\"" }, + new() { Name = "ReferencedEntityName", DataType = "text", Required = false, RequiredWhen = "AttributeType == \"Lookup\"" }, + }; + + var block = TemplateParameterEnricher.BuildAuthoritativeBlock( + new[] { ("pp-entity-attribute", (IReadOnlyList)parameters) }); + + // Header + template heading present. + Assert.Contains("authoritative", block, StringComparison.OrdinalIgnoreCase); + Assert.Contains("### pp-entity-attribute", block); + // Required/optional split. + Assert.Contains("Required:", block); + Assert.Contains("Optional:", block); + // Real names, choices, conditions surfaced. + Assert.Contains("`AttributeType`", block); + Assert.Contains("choices: Text, Lookup, Decimal", block); + Assert.Contains("applies when: AttributeType == \"Text\"", block); + Assert.Contains("required when: AttributeType == \"Lookup\"", block); + } + + private sealed class FakeProvider : ITemplateParameterProvider + { + private readonly Dictionary?> _map; + public int Calls { get; private set; } + public FakeProvider(Dictionary?> map) => _map = map; + public Task?> GetParametersAsync(string templateShortName, CancellationToken ct) + { + Calls++; + _map.TryGetValue(templateShortName, out var v); + return Task.FromResult(v); + } + } + + private static readonly IReadOnlyList EntityParams = new[] + { + new TemplateParameterInfo { Name = "EntitySchemaName", DataType = "text", Required = true }, + new TemplateParameterInfo { Name = "PublisherPrefix", DataType = "text", Required = false }, + }; + + [Fact] + public async Task EnrichAsync_AppendsAuthoritativeBlock_WhenScaffoldingAndTemplateResolves() + { + var provider = new FakeProvider(new() { ["pp-entity"] = EntityParams }); + var recipe = "1. scaffold\n\nTEMPLATES: pp-entity"; + + var result = await TemplateParameterEnricher.EnrichAsync( + recipe, new[] { "workspace_component_create" }, provider, CancellationToken.None); + + Assert.Contains("Exact template parameters", result); + Assert.Contains("`EntitySchemaName`", result); + Assert.StartsWith(recipe.TrimEnd(), result); // original recipe preserved, block appended + } + + [Fact] + public async Task EnrichAsync_NoOp_WhenComponentCreateNotMatched() + { + var provider = new FakeProvider(new() { ["pp-entity"] = EntityParams }); + var recipe = "1. do something\n\nTEMPLATES: pp-entity"; + + var result = await TemplateParameterEnricher.EnrichAsync( + recipe, new[] { "environment_solution_export" }, provider, CancellationToken.None); + + Assert.Equal(recipe, result); + Assert.Equal(0, provider.Calls); // provider not even consulted + } + + [Fact] + public async Task EnrichAsync_NoOp_WhenProviderNullOrTemplateUnknown() + { + var recipe = "TEMPLATES: pp-entity"; + // null provider + Assert.Equal(recipe, await TemplateParameterEnricher.EnrichAsync( + recipe, new[] { "workspace_component_create" }, null, CancellationToken.None)); + // provider returns null for the template + var provider = new FakeProvider(new() { ["pp-entity"] = null }); + Assert.Equal(recipe, await TemplateParameterEnricher.EnrichAsync( + recipe, new[] { "workspace_component_create" }, provider, CancellationToken.None)); + } + + [Fact] + public void TemplateParameterInfo_RoundTrips_FromParameterListJsonShape() + { + // Mirrors the shape emitted by `workspace component parameter list --format json`. + var payload = new[] + { + new + { + name = "ReferencedEntityName", + displayName = "", + dataType = "text", + required = false, + choices = (string?)null, + appliesWhen = (string?)null, + requiredWhen = "AttributeType == \"Lookup\"", + description = "Logical name of the referenced entity", + } + }; + var json = JsonSerializer.Serialize(payload, TxcOutputJsonOptions.Default); + + var parsed = JsonSerializer.Deserialize>(json, TxcOutputJsonOptions.Default); + + var p = Assert.Single(parsed!); + Assert.Equal("ReferencedEntityName", p.Name); + Assert.Equal("text", p.DataType); + Assert.False(p.Required); + Assert.Equal("AttributeType == \"Lookup\"", p.RequiredWhen); + } +} From b0d98d0119d3172c15f864b33f05ea0df46bbbbe Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Thu, 4 Jun 2026 13:08:52 +0200 Subject: [PATCH 2/2] feat: filter parameter listing by isEnabled condition (#88) --- .../ComponentParameterListCliCommand.cs | 25 ++++ .../TemplateConditionExpression.cs | 135 ++++++++++++++++++ .../TemplateParameterConditionEvaluator.cs | 57 ++++++++ ...emplateParameterConditionEvaluatorTests.cs | 67 +++++++++ 4 files changed, 284 insertions(+) create mode 100644 src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateConditionExpression.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterConditionEvaluator.cs create mode 100644 tests/TALXIS.CLI.Tests/Workspace/TemplateParameterConditionEvaluatorTests.cs diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs index d4281c81..e379db0a 100644 --- a/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentParameterListCliCommand.cs @@ -23,6 +23,9 @@ public class ComponentParameterListCliCommand : TxcLeafCommand [CliArgument(Description = "Component type name, alias, template short name, or integer code (e.g. 'Entity', 'Table', 'pp-entity', '1').")] public required string ShortName { get; set; } + [CliOption(Description = "Known parameter values in key=value form (repeatable). When given, only parameters whose isEnabled condition holds are listed (e.g. --param AttributeType=Text hides number/date/boolean-only parameters).")] + public List Param { get; set; } = new(); + protected override async Task ExecuteAsync() { using var scaffolder = new TemplateInvoker(); @@ -33,6 +36,15 @@ protected override async Task ExecuteAsync() var resolvedShortName = resolved?.ShortNameList.FirstOrDefault() ?? ShortName; var parameters = await scaffolder.ListParametersForTemplateAsync(resolvedShortName); + + // When the caller supplies known values, drop parameters whose isEnabled condition + // evaluates to false for those values — keeps the listing relevant to the chosen type. + var providedValues = ParseParamValues(Param); + if (parameters != null && providedValues.Count > 0) + { + parameters = TemplateEngine.TemplateParameterConditionEvaluator + .FilterEnabled(parameters, providedValues); + } if (parameters == null || parameters.Count == 0) { OutputFormatter.WriteList(Array.Empty(), _ => @@ -123,4 +135,17 @@ protected override async Task ExecuteAsync() return ExitSuccess; } + + private static Dictionary ParseParamValues(IEnumerable pairs) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in pairs) + { + var idx = pair.IndexOf('='); + if (idx <= 0 || idx == pair.Length - 1) + throw new ArgumentException($"Invalid parameter format: '{pair}'. Use key=value."); + values[pair[..idx]] = pair[(idx + 1)..]; + } + return values; + } } diff --git a/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateConditionExpression.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateConditionExpression.cs new file mode 100644 index 00000000..3b8cc5cb --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateConditionExpression.cs @@ -0,0 +1,135 @@ +namespace TALXIS.CLI.Features.Workspace.TemplateEngine; + +/// +/// Minimal evaluator for the boolean expressions templates use in isEnabled / +/// isRequired conditions — string equality/inequality combined with &&, +/// || and parentheses, e.g. (AttributeType == "Lookup") || (AttributeType == "Customer"). +/// Identifiers resolve to supplied values (missing = empty string); a bare identifier is +/// truthy when its value is "true". +/// +public static class TemplateConditionExpression +{ + /// Evaluates against . + public static bool Evaluate(string expression, IReadOnlyDictionary variables) + { + var tokens = Tokenize(expression); + var pos = 0; + var value = ParseOr(tokens, ref pos, variables); + return value; + } + + private enum Kind { LParen, RParen, Or, And, Eq, Neq, Str, Ident } + private readonly record struct Token(Kind Kind, string Text); + + private static List Tokenize(string s) + { + var tokens = new List(); + var i = 0; + while (i < s.Length) + { + var c = s[i]; + if (char.IsWhiteSpace(c)) { i++; continue; } + switch (c) + { + case '(': tokens.Add(new(Kind.LParen, "(")); i++; break; + case ')': tokens.Add(new(Kind.RParen, ")")); i++; break; + case '|' when Next(s, i) == '|': tokens.Add(new(Kind.Or, "||")); i += 2; break; + case '&' when Next(s, i) == '&': tokens.Add(new(Kind.And, "&&")); i += 2; break; + case '=' when Next(s, i) == '=': tokens.Add(new(Kind.Eq, "==")); i += 2; break; + case '!' when Next(s, i) == '=': tokens.Add(new(Kind.Neq, "!=")); i += 2; break; + case '"': + case '\'': + { + var quote = c; + var start = ++i; + while (i < s.Length && s[i] != quote) i++; + tokens.Add(new(Kind.Str, s[start..Math.Min(i, s.Length)])); + i++; // closing quote + break; + } + default: + { + var start = i; + while (i < s.Length && (char.IsLetterOrDigit(s[i]) || s[i] is '_' or '.' or '(' or ')')) + { + // Identifiers like OptionSet(Global) contain parens; only absorb them + // when they're clearly part of a value token, not grouping. + if (s[i] is '(' or ')') break; + i++; + } + if (i == start) { i++; break; } // skip unknown char + tokens.Add(new(Kind.Ident, s[start..i])); + break; + } + } + } + return tokens; + } + + private static char Next(string s, int i) => i + 1 < s.Length ? s[i + 1] : '\0'; + + private static bool ParseOr(List t, ref int pos, IReadOnlyDictionary vars) + { + var left = ParseAnd(t, ref pos, vars); + while (Peek(t, pos)?.Kind == Kind.Or) + { + pos++; + var right = ParseAnd(t, ref pos, vars); + left = left || right; + } + return left; + } + + private static bool ParseAnd(List t, ref int pos, IReadOnlyDictionary vars) + { + var left = ParsePrimary(t, ref pos, vars); + while (Peek(t, pos)?.Kind == Kind.And) + { + pos++; + var right = ParsePrimary(t, ref pos, vars); + left = left && right; + } + return left; + } + + private static bool ParsePrimary(List t, ref int pos, IReadOnlyDictionary vars) + { + var tok = Peek(t, pos); + if (tok is null) return false; + + if (tok.Value.Kind == Kind.LParen) + { + pos++; // ( + var inner = ParseOr(t, ref pos, vars); + if (Peek(t, pos)?.Kind == Kind.RParen) pos++; // ) + return inner; + } + + // Comparison: operand (== | !=) operand, or a bare operand used as a boolean. + var left = ParseOperand(t, ref pos, vars); + var op = Peek(t, pos); + if (op?.Kind == Kind.Eq || op?.Kind == Kind.Neq) + { + pos++; + var right = ParseOperand(t, ref pos, vars); + var equal = string.Equals(left, right, StringComparison.Ordinal); + return op.Value.Kind == Kind.Eq ? equal : !equal; + } + return string.Equals(left, "true", StringComparison.OrdinalIgnoreCase); + } + + private static string ParseOperand(List t, ref int pos, IReadOnlyDictionary vars) + { + var tok = Peek(t, pos); + if (tok is null) return ""; + pos++; + return tok.Value.Kind switch + { + Kind.Str => tok.Value.Text, + Kind.Ident => vars.TryGetValue(tok.Value.Text, out var v) ? v : "", + _ => "", + }; + } + + private static Token? Peek(List t, int pos) => pos < t.Count ? t[pos] : null; +} diff --git a/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterConditionEvaluator.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterConditionEvaluator.cs new file mode 100644 index 00000000..a6b764d0 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterConditionEvaluator.cs @@ -0,0 +1,57 @@ +using Microsoft.TemplateEngine.Abstractions; + +namespace TALXIS.CLI.Features.Workspace.TemplateEngine; + +/// +/// Evaluates a template parameter's isEnabled condition (e.g. +/// (AttributeType == "Text")) against a set of supplied values, so the parameter +/// listing can hide type-specific parameters that don't apply to the chosen values. +/// +public static class TemplateParameterConditionEvaluator +{ + /// + /// Returns the names of the parameters that are enabled given . + /// A parameter with no condition is always enabled. Each condition is evaluated against a + /// variable set built from every parameter's effective value (supplied value, else its default, + /// else empty), mirroring how the engine resolves disabled parameters to their defaults. + /// + public static IReadOnlyList SelectEnabled( + IReadOnlyList<(string Name, string? EnabledCondition, string? DefaultValue)> parameters, + IReadOnlyDictionary providedValues) + { + var variables = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in parameters) + variables[p.Name] = providedValues.TryGetValue(p.Name, out var supplied) + ? supplied + : p.DefaultValue ?? ""; + // Include any supplied keys that aren't declared parameters so conditions referencing + // them still resolve. + foreach (var kv in providedValues) + variables[kv.Key] = kv.Value; + + var enabled = new List(); + foreach (var p in parameters) + { + if (string.IsNullOrWhiteSpace(p.EnabledCondition) + || TemplateConditionExpression.Evaluate(p.EnabledCondition!, variables)) + { + enabled.Add(p.Name); + } + } + return enabled; + } + + /// + /// Filters template parameters down to those enabled for the supplied values. + /// + public static IReadOnlyList FilterEnabled( + IReadOnlyList parameters, + IReadOnlyDictionary providedValues) + { + var enabledNames = SelectEnabled( + parameters.Select(p => (p.Name, p.Precedence?.IsEnabledCondition, p.DefaultValue?.ToString())).ToList(), + providedValues).ToHashSet(StringComparer.Ordinal); + + return parameters.Where(p => enabledNames.Contains(p.Name)).ToList(); + } +} diff --git a/tests/TALXIS.CLI.Tests/Workspace/TemplateParameterConditionEvaluatorTests.cs b/tests/TALXIS.CLI.Tests/Workspace/TemplateParameterConditionEvaluatorTests.cs new file mode 100644 index 00000000..047011ea --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Workspace/TemplateParameterConditionEvaluatorTests.cs @@ -0,0 +1,67 @@ +using TALXIS.CLI.Features.Workspace.TemplateEngine; +using Xunit; + +namespace TALXIS.CLI.Tests.Workspace; + +/// +/// Covers isEnabled-condition filtering for the parameter listing: a parameter whose +/// condition references the chosen AttributeType should drop out when it doesn't match, +/// while unconditional parameters always stay. +/// +public class TemplateParameterConditionEvaluatorTests +{ + // (name, isEnabledCondition, defaultValue) — mirrors what pp-entity-attribute defines. + private static readonly List<(string, string?, string?)> Params = new() + { + ("AttributeType", null, null), // unconditional + ("TextFormat", "(AttributeType == \"Text\")", "text"), + ("WholeNumberFormat", "(AttributeType == \"WholeNumber\")", "none"), + ("DecimalPrecision", "(AttributeType == \"Decimal\" || AttributeType == \"Float\" || AttributeType == \"Money\")", "2"), + ("LookupTarget", "(AttributeType == \"Lookup\") || (AttributeType == \"Customer\")", null), + }; + + private static IReadOnlyList Select(params (string k, string v)[] provided) + => TemplateParameterConditionEvaluator.SelectEnabled( + Params, + provided.ToDictionary(p => p.k, p => p.v, StringComparer.OrdinalIgnoreCase)); + + [Fact] + public void Text_KeepsTextFormat_DropsOtherTypeSpecific() + { + var enabled = Select(("AttributeType", "Text")); + + Assert.Contains("AttributeType", enabled); // unconditional always kept + Assert.Contains("TextFormat", enabled); + Assert.DoesNotContain("WholeNumberFormat", enabled); + Assert.DoesNotContain("DecimalPrecision", enabled); + Assert.DoesNotContain("LookupTarget", enabled); + } + + [Fact] + public void Decimal_KeepsDecimalPrecision_ViaOrCondition() + { + var enabled = Select(("AttributeType", "Float")); + + Assert.Contains("DecimalPrecision", enabled); + Assert.DoesNotContain("TextFormat", enabled); + } + + [Fact] + public void Lookup_KeepsLookupTarget() + { + var enabled = Select(("AttributeType", "Lookup")); + + Assert.Contains("LookupTarget", enabled); + Assert.DoesNotContain("TextFormat", enabled); + } + + [Fact] + public void NoProvidedValues_StillEvaluates_UnconditionalAlwaysKept() + { + // With no AttributeType supplied, defaults are empty so type-specific conditions are + // false, but unconditional parameters remain. + var enabled = Select(); + + Assert.Contains("AttributeType", enabled); + } +}