From 2fae941fb11591f6cffb64168f98140862294fa0 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Wed, 3 Jun 2026 06:09:06 +0200 Subject: [PATCH] feat: reject unknown parameters in workspace component create --- .../TemplateParameterValidator.cs | 75 +++++++++++++++++++ .../TemplateParameterValidatorTests.cs | 69 +++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tests/TALXIS.CLI.Tests/TemplateEngine/TemplateParameterValidatorTests.cs diff --git a/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterValidator.cs b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterValidator.cs index 0da772ca..53366a6f 100644 --- a/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterValidator.cs +++ b/src/TALXIS.CLI.Features.Workspace/TemplateEngine/TemplateParameterValidator.cs @@ -19,6 +19,15 @@ public void ValidateParameters(ITemplateInfo template, IDictionary p.Name != "type" && p.Name != "language" && p.Name != "name") .ToList(); + // Reject parameters the template doesn't define. Silently ignoring an + // unknown --param is the most dangerous failure mode: the command "succeeds" + // but the value has no effect (e.g. a hallucinated 'FormatName'). + var knownNames = new HashSet( + template.ParameterDefinitions.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + var suggestionCandidates = templateParameters.Select(p => p.Name).ToList(); + var templateName = template.ShortNameList?.FirstOrDefault() ?? template.Name; + errors.AddRange(FindUnknownParameters(userParameters.Keys, knownNames, suggestionCandidates, templateName)); + foreach (var templateParam in templateParameters) { var paramName = templateParam.Name; @@ -98,5 +107,71 @@ private static void ValidateParameterValue(ITemplateParameter templateParam, str break; } } + + /// + /// Returns an error for every user-supplied parameter key the template does not + /// define, with a best-effort "did you mean" suggestion (closest known name). + /// + public static IReadOnlyList FindUnknownParameters( + IEnumerable userKeys, + ISet knownNames, + IReadOnlyList suggestionCandidates, + string templateName) + { + var errors = new List(); + foreach (var key in userKeys) + { + if (knownNames.Contains(key)) + { + continue; + } + + var suggestion = FindClosest(key, suggestionCandidates); + var hint = suggestion is null ? string.Empty : $" Did you mean '{suggestion}'?"; + errors.Add($"Unknown parameter '{key}' for template '{templateName}'.{hint}"); + } + return errors; + } + + /// + /// Finds the candidate closest to by Levenshtein distance, + /// within a small typo-sized threshold. Returns null when nothing is close. + /// + private static string? FindClosest(string input, IReadOnlyList candidates) + { + string? best = null; + int bestDistance = int.MaxValue; + int threshold = Math.Max(2, input.Length / 3); + + foreach (var candidate in candidates) + { + var distance = Levenshtein(input.ToLowerInvariant(), candidate.ToLowerInvariant()); + if (distance < bestDistance) + { + bestDistance = distance; + best = candidate; + } + } + + return bestDistance <= threshold ? best : null; + } + + private static int Levenshtein(string a, string b) + { + var d = new int[a.Length + 1, b.Length + 1]; + for (int i = 0; i <= a.Length; i++) d[i, 0] = i; + for (int j = 0; j <= b.Length; j++) d[0, j] = j; + + for (int i = 1; i <= a.Length; i++) + { + for (int j = 1; j <= b.Length; j++) + { + int cost = a[i - 1] == b[j - 1] ? 0 : 1; + d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); + } + } + + return d[a.Length, b.Length]; + } } } diff --git a/tests/TALXIS.CLI.Tests/TemplateEngine/TemplateParameterValidatorTests.cs b/tests/TALXIS.CLI.Tests/TemplateEngine/TemplateParameterValidatorTests.cs new file mode 100644 index 00000000..45e2a2ba --- /dev/null +++ b/tests/TALXIS.CLI.Tests/TemplateEngine/TemplateParameterValidatorTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using TALXIS.CLI.Features.Workspace.TemplateEngine; +using Xunit; + +namespace TALXIS.CLI.Tests.TemplateEngine; + +public class TemplateParameterValidatorTests +{ + private static readonly string[] Known = { "TextFormat", "MaxLength", "DisplayName", "type", "language", "name" }; + private static readonly string[] Suggest = { "TextFormat", "MaxLength", "DisplayName" }; + + private static ISet KnownSet() => new HashSet(Known, StringComparer.OrdinalIgnoreCase); + + [Fact] + public void KnownParameter_NoError() + { + var errors = TemplateParameterValidator.FindUnknownParameters( + new[] { "TextFormat" }, KnownSet(), Suggest, "pp-entity-attribute"); + Assert.Empty(errors); + } + + [Fact] + public void KnownParameter_CaseInsensitive_NoError() + { + var errors = TemplateParameterValidator.FindUnknownParameters( + new[] { "textformat" }, KnownSet(), Suggest, "pp-entity-attribute"); + Assert.Empty(errors); + } + + [Fact] + public void SystemParameter_NoError() + { + var errors = TemplateParameterValidator.FindUnknownParameters( + new[] { "name" }, KnownSet(), Suggest, "pp-entity-attribute"); + Assert.Empty(errors); + } + + [Fact] + public void UnknownParameter_ProducesError_WithTemplateName() + { + var errors = TemplateParameterValidator.FindUnknownParameters( + new[] { "Xyzzy" }, KnownSet(), Suggest, "pp-entity-attribute"); + + var msg = Assert.Single(errors); + Assert.Contains("Unknown parameter 'Xyzzy'", msg); + Assert.Contains("pp-entity-attribute", msg); + } + + [Fact] + public void UnknownParameter_Typo_SuggestsClosest() + { + var errors = TemplateParameterValidator.FindUnknownParameters( + new[] { "TextFromat" }, KnownSet(), Suggest, "pp-entity-attribute"); // 'rm' transposed + + var msg = Assert.Single(errors); + Assert.Contains("Did you mean 'TextFormat'?", msg); + } + + [Fact] + public void UnknownParameter_FarFromAll_NoSuggestion() + { + var errors = TemplateParameterValidator.FindUnknownParameters( + new[] { "CompletelyUnrelatedThing" }, KnownSet(), Suggest, "pp-entity-attribute"); + + var msg = Assert.Single(errors); + Assert.DoesNotContain("Did you mean", msg); + } +}