From 4d465daa3be3eefd6f40f674970a94b74bd091f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 07:47:11 +0000 Subject: [PATCH 01/37] Initial plan From 46ebe3f9c43d57272abbd8c7a6ca9ff3be1f8f4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:01:00 +0000 Subject: [PATCH 02/37] Fix critical nullable reference warnings in AstVisitorExplainer Co-authored-by: Jawz84 <26464885+Jawz84@users.noreply.github.com> --- .../AstVisitorExplainer_classes.cs | 12 ++++++++---- .../AstVisitorExplainer_command.cs | 10 +++++----- .../AstVisitorExplainer_expressions.cs | 2 +- .../AstVisitorExplainer_general.cs | 2 +- .../AstVisitorExplainer_helpers.cs | 10 +++++----- .../Helpers/MatchParam.cs | 4 ++-- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs b/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs index 5458d86..eba5211 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs @@ -119,20 +119,24 @@ public override AstVisitAction VisitFunctionMember(FunctionMemberAst functionMem public override AstVisitAction VisitPropertyMember(PropertyMemberAst propertyMemberAst) { - HelpEntity helpResult = null; + HelpEntity? helpResult = null; var description = ""; - if ((propertyMemberAst.Parent as TypeDefinitionAst).IsClass) + var parentType = propertyMemberAst.Parent as TypeDefinitionAst; + if (parentType?.IsClass == true) { var attributes = propertyMemberAst.Attributes.Count >= 0 ? $", with attributes '{string.Join(", ", propertyMemberAst.Attributes.Select(p => p.TypeName.Name))}'." : "."; description = $"Property '{propertyMemberAst.Name}' of type '{propertyMemberAst.PropertyType.TypeName.FullName}'{attributes}"; helpResult = HelpTableQuery("about_classes"); - helpResult.DocumentationLink += "#class-properties"; + if (helpResult != null) + { + helpResult.DocumentationLink += "#class-properties"; + } } - if ((propertyMemberAst.Parent as TypeDefinitionAst).IsEnum) + if (parentType?.IsEnum == true) { description = $"Enum label '{propertyMemberAst.Name}', with value '{propertyMemberAst.InitialValue}'."; helpResult = HelpTableQuery("about_enum"); diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_command.cs b/explainpowershell.analysisservice/AstVisitorExplainer_command.cs index 7a2679c..e187521 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_command.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_command.cs @@ -23,12 +23,12 @@ public override AstVisitAction VisitCommand(CommandAst commandAst) string resolvedCmd = Helpers.ResolveAlias(cmdName) ?? cmdName; - HelpEntity helpResult; + HelpEntity? helpResult; if (string.IsNullOrEmpty(moduleName)) { var helpResults = HelpTableQueryRange(resolvedCmd); helpResult = helpResults?.FirstOrDefault(); - if (helpResults.Count > 1) + if (helpResults != null && helpResults.Count > 1) { this.errorMessage = $"The command '{helpResult?.CommandName}' is present in more than one module: '{string.Join("', '", helpResults.Select(r => r.ModuleName))}'. Explicitly prepend the module name to the command to select one: '{helpResults.First().ModuleName}\\{helpResult?.CommandName}'"; } @@ -131,12 +131,12 @@ public override AstVisitAction VisitCommandParameter(CommandParameterAst command var parentCommandExplanation = explanations.FirstOrDefault(e => e.Id == exp.ParentId); - ParameterData matchedParameter; - if (parentCommandExplanation.HelpResult?.Parameters != null) + ParameterData? matchedParameter = null; + if (parentCommandExplanation?.HelpResult?.Parameters != null) { try { - matchedParameter = Helpers.MatchParam(commandParameterAst.ParameterName, parentCommandExplanation.HelpResult?.Parameters); + matchedParameter = Helpers.MatchParam(commandParameterAst.ParameterName, parentCommandExplanation.HelpResult.Parameters); if (matchedParameter != null) { diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs index 4dbbac7..84aa443 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs @@ -209,7 +209,7 @@ public override AstVisitAction VisitTypeExpression(TypeExpressionAst typeExpress typeExpressionAst.Parent is CommandExpressionAst || typeExpressionAst.Parent is AssignmentStatementAst) { - HelpEntity help = null; + HelpEntity? help = null; var description = string.Empty; if (typeExpressionAst.TypeName.IsArray) diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_general.cs b/explainpowershell.analysisservice/AstVisitorExplainer_general.cs index 7515034..c1f3857 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_general.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_general.cs @@ -175,7 +175,7 @@ public override AstVisitAction VisitTypeConstraint(TypeConstraintAst typeConstra var typeName = typeConstraintAst.TypeName.Name; var accelerator = "."; var cmdName = "Type constraint"; - HelpEntity help = null; + HelpEntity? help = null; var (acceleratorName, acceleratorFullTypeName) = Helpers.ResolveAccelerator(typeName); if (acceleratorName != null) diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs index a11b821..9653005 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs @@ -18,12 +18,12 @@ public partial class AstVisitorExplainer : AstVisitor2 private const char separatorChar = ' '; private const string PartitionKey = "CommandHelp"; private readonly List explanations = new(); - private string errorMessage; + private string errorMessage = string.Empty; private string extent; private int offSet = 0; private readonly TableClient tableClient; private readonly ILogger log; - private readonly Token[] tokens; + private readonly Token[]? tokens; public AnalysisResult GetAnalysisResult() { @@ -84,7 +84,7 @@ private void ExplainSemiColons() } } - public AstVisitorExplainer(string extentText, TableClient client, ILogger log, Token[] tokens) + public AstVisitorExplainer(string extentText, TableClient client, ILogger log, Token[]? tokens) { tableClient = client; this.log = log; @@ -100,7 +100,7 @@ private static bool HasSpecialVars(string varName) return false; } - private HelpEntity HelpTableQuery(string resolvedCmd) + private HelpEntity? HelpTableQuery(string resolvedCmd) { string filter = TableServiceClient.CreateQueryFilter($"PartitionKey eq {PartitionKey} and RowKey eq {resolvedCmd.ToLower()}"); var entities = tableClient.Query(filter: filter); @@ -108,7 +108,7 @@ private HelpEntity HelpTableQuery(string resolvedCmd) return helpResult; } - private HelpEntity HelpTableQuery(string resolvedCmd, string moduleName) + private HelpEntity? HelpTableQuery(string resolvedCmd, string moduleName) { var rowKey = $"{resolvedCmd.ToLower()}{separatorChar}{moduleName.ToLower()}"; return HelpTableQuery(rowKey); diff --git a/explainpowershell.analysisservice/Helpers/MatchParam.cs b/explainpowershell.analysisservice/Helpers/MatchParam.cs index 024846f..951829a 100644 --- a/explainpowershell.analysisservice/Helpers/MatchParam.cs +++ b/explainpowershell.analysisservice/Helpers/MatchParam.cs @@ -10,9 +10,9 @@ namespace ExplainPowershell.SyntaxAnalyzer { public static partial class Helpers { - public static ParameterData MatchParam(string foundParameter, string json) + public static ParameterData? MatchParam(string foundParameter, string json) { - List doc; + List? doc; List matchedParam = new(); try { From 4d3cda86c7b82e25594a158d1e4a04fee1989ed5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:04:14 +0000 Subject: [PATCH 03/37] Extract magic strings into Constants class for improved maintainability Co-authored-by: Jawz84 <26464885+Jawz84@users.noreply.github.com> --- .../AstVisitorExplainer_classes.cs | 4 +- .../AstVisitorExplainer_expressions.cs | 4 +- .../AstVisitorExplainer_general.cs | 8 +-- .../AstVisitorExplainer_helpers.cs | 8 +-- .../Constants.cs | 60 +++++++++++++++++++ .../SyntaxAnalyzer.cs | 3 +- 6 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 explainpowershell.analysisservice/Constants.cs diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs b/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs index eba5211..858e6f5 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs @@ -129,7 +129,7 @@ public override AstVisitAction VisitPropertyMember(PropertyMemberAst propertyMem $", with attributes '{string.Join(", ", propertyMemberAst.Attributes.Select(p => p.TypeName.Name))}'." : "."; description = $"Property '{propertyMemberAst.Name}' of type '{propertyMemberAst.PropertyType.TypeName.FullName}'{attributes}"; - helpResult = HelpTableQuery("about_classes"); + helpResult = HelpTableQuery(Constants.AboutTopics.AboutClasses); if (helpResult != null) { helpResult.DocumentationLink += "#class-properties"; @@ -139,7 +139,7 @@ public override AstVisitAction VisitPropertyMember(PropertyMemberAst propertyMem if (parentType?.IsEnum == true) { description = $"Enum label '{propertyMemberAst.Name}', with value '{propertyMemberAst.InitialValue}'."; - helpResult = HelpTableQuery("about_enum"); + helpResult = HelpTableQuery(Constants.AboutTopics.AboutEnum); } explanations.Add(new Explanation() diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs index 84aa443..1d5295a 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs @@ -216,14 +216,14 @@ typeExpressionAst.Parent is CommandExpressionAst || { description = $"Array of '{typeExpressionAst.TypeName.Name}'"; help = new HelpEntity() { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/scripting/lang-spec/chapter-04" + DocumentationLink = Constants.Documentation.Chapter04TypeSystem }; } else if (typeExpressionAst.TypeName.IsGeneric) { description = $"Generic type"; help = new HelpEntity() { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/scripting/lang-spec/chapter-04#44-generic-types" + DocumentationLink = Constants.Documentation.Chapter04GenericTypes }; } diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_general.cs b/explainpowershell.analysisservice/AstVisitorExplainer_general.cs index c1f3857..c571fe0 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_general.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_general.cs @@ -22,7 +22,7 @@ public override AstVisitAction VisitAttribute(AttributeAst attributeAst) new Explanation() { CommandName = "CmdletBinding Attribute", - HelpResult = HelpTableQuery("about_Functions_CmdletBindingAttribute"), + HelpResult = HelpTableQuery(Constants.AboutTopics.AboutFunctionsCmdletBindingAttribute), Description = "The CmdletBinding attribute adds common parameters to your script or function, among other things.", }.AddDefaults(attributeAst, explanations)); @@ -46,7 +46,7 @@ public override AstVisitAction VisitFileRedirection(FileRedirectionAst redirecti { Description = $"{redirectsOrAppends} output {fromStream}to location '{redirectionAst.Location}'.", CommandName = "File redirection operator", - HelpResult = HelpTableQuery("about_redirection"), + HelpResult = HelpTableQuery(Constants.AboutTopics.AboutRedirection), TextToHighlight = ">" }.AddDefaults(redirectionAst, explanations)); @@ -70,7 +70,7 @@ public override AstVisitAction VisitHashtable(HashtableAst hashtableAst) { Description = $"An object that holds key-value pairs, optimized for hash-searching for keys.{keysString}", CommandName = "Hash table", - HelpResult = HelpTableQuery("about_hash_tables"), + HelpResult = HelpTableQuery(Constants.AboutTopics.AboutHashTables), TextToHighlight = "@{" }.AddDefaults(hashtableAst, explanations)); @@ -182,7 +182,7 @@ public override AstVisitAction VisitTypeConstraint(TypeConstraintAst typeConstra { typeName = acceleratorName; accelerator = $", which is a type accelerator for '{acceleratorFullTypeName}'"; - help = HelpTableQuery("about_type_accelerators"); + help = HelpTableQuery(Constants.AboutTopics.AboutTypeAccelerators); cmdName = "Type accelerator"; } else if (typeConstraintAst.Parent is ConvertExpressionAst) diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs index 9653005..85c09ca 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs @@ -14,9 +14,9 @@ namespace ExplainPowershell.SyntaxAnalyzer { public partial class AstVisitorExplainer : AstVisitor2 { - private const char filterChar = '!'; - private const char separatorChar = ' '; - private const string PartitionKey = "CommandHelp"; + private const char filterChar = Constants.TableStorage.RangeFilterChar; + private const char separatorChar = Constants.TableStorage.CommandModuleSeparator; + private const string PartitionKey = Constants.TableStorage.CommandHelpPartitionKey; private readonly List explanations = new(); private string errorMessage = string.Empty; private string extent; @@ -70,7 +70,7 @@ private void ExplainSemiColons() var (description, _) = Helpers.TokenExplainer(TokenKind.Semi); var help = new HelpEntity { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/scripting/lang-spec/chapter-08#82-pipeline-statements" + DocumentationLink = Constants.Documentation.Chapter08PipelineStatements }; explanations.Add( diff --git a/explainpowershell.analysisservice/Constants.cs b/explainpowershell.analysisservice/Constants.cs new file mode 100644 index 0000000..e00659d --- /dev/null +++ b/explainpowershell.analysisservice/Constants.cs @@ -0,0 +1,60 @@ +namespace ExplainPowershell.SyntaxAnalyzer +{ + /// + /// Application-wide constants + /// + public static class Constants + { + /// + /// Azure Table Storage constants + /// + public static class TableStorage + { + /// + /// The partition key used for command help entries in Azure Table Storage + /// + public const string CommandHelpPartitionKey = "CommandHelp"; + + /// + /// The default table name for help data + /// + public const string HelpDataTableName = "HelpData"; + + /// + /// Character used for filtering in range queries (ASCII 33 = '!') + /// + public const char RangeFilterChar = '!'; + + /// + /// Separator character used between command name and module name (ASCII 32 = ' ') + /// + public const char CommandModuleSeparator = ' '; + } + + /// + /// PowerShell documentation link constants + /// + public static class Documentation + { + public const string MicrosoftDocsBase = "https://docs.microsoft.com/en-us/powershell/scripting/lang-spec"; + public const string Chapter04TypeSystem = MicrosoftDocsBase + "/chapter-04"; + public const string Chapter04GenericTypes = Chapter04TypeSystem + "#44-generic-types"; + public const string Chapter08PipelineStatements = MicrosoftDocsBase + "/chapter-08#82-pipeline-statements"; + } + + /// + /// PowerShell about topics + /// + public static class AboutTopics + { + public const string AboutClasses = "about_classes"; + public const string AboutEnum = "about_enum"; + public const string AboutFunctions = "about_functions"; + public const string AboutFunctionsCmdletBindingAttribute = "about_Functions_CmdletBindingAttribute"; + public const string AboutHashTables = "about_hash_tables"; + public const string AboutOperators = "about_operators"; + public const string AboutRedirection = "about_redirection"; + public const string AboutTypeAccelerators = "about_type_accelerators"; + } + } +} diff --git a/explainpowershell.analysisservice/SyntaxAnalyzer.cs b/explainpowershell.analysisservice/SyntaxAnalyzer.cs index 8d127fd..7c5d2a2 100644 --- a/explainpowershell.analysisservice/SyntaxAnalyzer.cs +++ b/explainpowershell.analysisservice/SyntaxAnalyzer.cs @@ -13,7 +13,6 @@ namespace ExplainPowershell.SyntaxAnalyzer { public sealed class SyntaxAnalyzerFunction { - private const string HelpTableName = "HelpData"; private readonly ILogger logger; private readonly IAiExplanationService aiExplanationService; @@ -27,7 +26,7 @@ public SyntaxAnalyzerFunction(ILogger logger, IAiExplana public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) { - var tableClient = TableClientFactory.Create(HelpTableName); + var tableClient = TableClientFactory.Create(Constants.TableStorage.HelpDataTableName); string requestBody; using (var reader = new StreamReader(req.Body)) { From b3695ae3e231fd80fb8ebf870916cb24308a855c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:09:29 +0000 Subject: [PATCH 04/37] Add repository pattern for improved testability and separation of concerns Co-authored-by: Jawz84 <26464885+Jawz84@users.noreply.github.com> --- .../tests/AstVisitorExplainer.tests.cs | 3 +- .../AstVisitorExplainer_command.tests.cs | 3 +- .../AstVisitorExplainer_helpers.cs | 31 ++------- .../Repositories/IHelpRepository.cs | 32 ++++++++++ .../TableStorageHelpRepository.cs | 64 +++++++++++++++++++ .../SyntaxAnalyzer.cs | 4 +- 6 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 explainpowershell.analysisservice/Repositories/IHelpRepository.cs create mode 100644 explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs index 2e0b868..603482b 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs @@ -24,10 +24,11 @@ public void Setup() var tableClient = new TableClient( "AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;DefaultEndpointsProtocol=http;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;", "HelpData"); + var helpRepository = new ExplainPowershell.SyntaxAnalyzer.Repositories.TableStorageHelpRepository(tableClient); explainer = new( extentText: string.Empty, - client: tableClient, + helpRepository: helpRepository, log: mockILogger, tokens: null); } diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs index a9d836f..fe024ed 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs @@ -24,10 +24,11 @@ public void Setup() var tableClient = new TableClient( "AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;DefaultEndpointsProtocol=http;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;", "HelpData"); + var helpRepository = new ExplainPowershell.SyntaxAnalyzer.Repositories.TableStorageHelpRepository(tableClient); explainer = new( extentText: string.Empty, - client: tableClient, + helpRepository: helpRepository, log: mockILogger, tokens: null); } diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs index 85c09ca..ff32623 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs @@ -7,7 +7,7 @@ using explainpowershell.models; using explainpowershell.SyntaxAnalyzer.ExtensionMethods; -using Azure.Data.Tables; +using ExplainPowershell.SyntaxAnalyzer.Repositories; using Microsoft.Extensions.Logging; namespace ExplainPowershell.SyntaxAnalyzer @@ -21,7 +21,7 @@ public partial class AstVisitorExplainer : AstVisitor2 private string errorMessage = string.Empty; private string extent; private int offSet = 0; - private readonly TableClient tableClient; + private readonly IHelpRepository helpRepository; private readonly ILogger log; private readonly Token[]? tokens; @@ -84,9 +84,9 @@ private void ExplainSemiColons() } } - public AstVisitorExplainer(string extentText, TableClient client, ILogger log, Token[]? tokens) + public AstVisitorExplainer(string extentText, IHelpRepository helpRepository, ILogger log, Token[]? tokens) { - tableClient = client; + this.helpRepository = helpRepository ?? throw new ArgumentNullException(nameof(helpRepository)); this.log = log; extent = extentText; this.tokens = tokens; @@ -102,34 +102,17 @@ private static bool HasSpecialVars(string varName) private HelpEntity? HelpTableQuery(string resolvedCmd) { - string filter = TableServiceClient.CreateQueryFilter($"PartitionKey eq {PartitionKey} and RowKey eq {resolvedCmd.ToLower()}"); - var entities = tableClient.Query(filter: filter); - var helpResult = entities.FirstOrDefault(); - return helpResult; + return helpRepository.GetHelpForCommand(resolvedCmd); } private HelpEntity? HelpTableQuery(string resolvedCmd, string moduleName) { - var rowKey = $"{resolvedCmd.ToLower()}{separatorChar}{moduleName.ToLower()}"; - return HelpTableQuery(rowKey); + return helpRepository.GetHelpForCommand(resolvedCmd, moduleName); } private List HelpTableQueryRange(string resolvedCmd) { - if (string.IsNullOrEmpty(resolvedCmd)) - { - return new List { new HelpEntity() }; - } - - // Getting a range from Azure Table storage works based on ascii char filtering. You can match prefixes. I use a space ' ' (char)32 as a divider - // between the name of a command and the name of its module for commands that appear in more than one module. Filtering this way makes sure I - // only match entries with ' '. - // filterChar = (char)33 = '!'. - string rowKeyFilter = $"{resolvedCmd.ToLower()}{filterChar}"; - string filter = TableServiceClient.CreateQueryFilter( - $"PartitionKey eq {PartitionKey} and RowKey ge {resolvedCmd.ToLower()} and RowKey lt {rowKeyFilter}"); - var entities = tableClient.Query(filter: filter); - return entities.ToList(); + return helpRepository.GetHelpForCommandRange(resolvedCmd); } private void ExpandAliasesInExtent(CommandAst cmd, string resolvedCmd) diff --git a/explainpowershell.analysisservice/Repositories/IHelpRepository.cs b/explainpowershell.analysisservice/Repositories/IHelpRepository.cs new file mode 100644 index 0000000..b7197a1 --- /dev/null +++ b/explainpowershell.analysisservice/Repositories/IHelpRepository.cs @@ -0,0 +1,32 @@ +using explainpowershell.models; + +namespace ExplainPowershell.SyntaxAnalyzer.Repositories +{ + /// + /// Repository interface for accessing PowerShell help data + /// + public interface IHelpRepository + { + /// + /// Query help data for a specific command + /// + /// The command name to query + /// The help entity if found, null otherwise + HelpEntity? GetHelpForCommand(string commandName); + + /// + /// Query help data for a specific command in a specific module + /// + /// The command name to query + /// The module name containing the command + /// The help entity if found, null otherwise + HelpEntity? GetHelpForCommand(string commandName, string moduleName); + + /// + /// Query help data for commands matching a prefix + /// + /// The command name prefix to query + /// List of matching help entities + List GetHelpForCommandRange(string commandName); + } +} diff --git a/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs b/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs new file mode 100644 index 0000000..7645f1d --- /dev/null +++ b/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs @@ -0,0 +1,64 @@ +using Azure.Data.Tables; +using explainpowershell.models; + +namespace ExplainPowershell.SyntaxAnalyzer.Repositories +{ + /// + /// Implementation of IHelpRepository using Azure Table Storage + /// + public class TableStorageHelpRepository : IHelpRepository + { + private readonly TableClient tableClient; + + public TableStorageHelpRepository(TableClient tableClient) + { + this.tableClient = tableClient ?? throw new ArgumentNullException(nameof(tableClient)); + } + + /// + public HelpEntity? GetHelpForCommand(string commandName) + { + if (string.IsNullOrEmpty(commandName)) + { + return null; + } + + string filter = TableServiceClient.CreateQueryFilter( + $"PartitionKey eq {Constants.TableStorage.CommandHelpPartitionKey} and RowKey eq {commandName.ToLower()}"); + var entities = tableClient.Query(filter: filter); + return entities.FirstOrDefault(); + } + + /// + public HelpEntity? GetHelpForCommand(string commandName, string moduleName) + { + if (string.IsNullOrEmpty(commandName) || string.IsNullOrEmpty(moduleName)) + { + return null; + } + + var rowKey = $"{commandName.ToLower()}{Constants.TableStorage.CommandModuleSeparator}{moduleName.ToLower()}"; + return GetHelpForCommand(rowKey); + } + + /// + public List GetHelpForCommandRange(string commandName) + { + if (string.IsNullOrEmpty(commandName)) + { + return new List { new HelpEntity() }; + } + + // Getting a range from Azure Table storage works based on ascii char filtering. You can match prefixes. + // We use a space ' ' (char)32 as a divider between the name of a command and the name of its module + // for commands that appear in more than one module. Filtering this way makes sure we only match + // entries with ' '. + // filterChar = (char)33 = '!'. + string rowKeyFilter = $"{commandName.ToLower()}{Constants.TableStorage.RangeFilterChar}"; + string filter = TableServiceClient.CreateQueryFilter( + $"PartitionKey eq {Constants.TableStorage.CommandHelpPartitionKey} and RowKey ge {commandName.ToLower()} and RowKey lt {rowKeyFilter}"); + var entities = tableClient.Query(filter: filter); + return entities.ToList(); + } + } +} diff --git a/explainpowershell.analysisservice/SyntaxAnalyzer.cs b/explainpowershell.analysisservice/SyntaxAnalyzer.cs index 7c5d2a2..b03f15c 100644 --- a/explainpowershell.analysisservice/SyntaxAnalyzer.cs +++ b/explainpowershell.analysisservice/SyntaxAnalyzer.cs @@ -4,6 +4,7 @@ using explainpowershell.analysisservice; using explainpowershell.analysisservice.Services; using explainpowershell.models; +using ExplainPowershell.SyntaxAnalyzer.Repositories; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -27,6 +28,7 @@ public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) { var tableClient = TableClientFactory.Create(Constants.TableStorage.HelpDataTableName); + var helpRepository = new TableStorageHelpRepository(tableClient); string requestBody; using (var reader = new StreamReader(req.Body)) { @@ -54,7 +56,7 @@ public async Task Run( AnalysisResult analysisResult; try { - var visitor = new AstVisitorExplainer(ast.Extent.Text, tableClient, logger, tokens); + var visitor = new AstVisitorExplainer(ast.Extent.Text, helpRepository, logger, tokens); ast.Visit(visitor); analysisResult = visitor.GetAnalysisResult(); } From 88a95163abf0bb9df4ef917332a6b1691db5920e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:12:37 +0000 Subject: [PATCH 05/37] Add InMemoryHelpRepository with comprehensive unit tests Co-authored-by: Jawz84 <26464885+Jawz84@users.noreply.github.com> --- .../helpers/InMemoryHelpRepository.cs | 88 +++++++++++ .../tests/InMemoryHelpRepository.tests.cs | 143 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs create mode 100644 explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs diff --git a/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs b/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs new file mode 100644 index 0000000..d19b6cb --- /dev/null +++ b/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using explainpowershell.models; +using ExplainPowershell.SyntaxAnalyzer.Repositories; + +namespace ExplainPowershell.SyntaxAnalyzer.Tests +{ + /// + /// In-memory implementation of IHelpRepository for testing + /// + public class InMemoryHelpRepository : IHelpRepository + { + private readonly Dictionary helpData = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Add help data for testing + /// + public void AddHelpEntity(HelpEntity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var key = entity.CommandName ?? string.Empty; + if (!string.IsNullOrEmpty(entity.ModuleName)) + { + key = $"{key} {entity.ModuleName}"; + } + + helpData[key] = entity; + } + + /// + /// Clear all help data + /// + public void Clear() + { + helpData.Clear(); + } + + /// + public HelpEntity? GetHelpForCommand(string commandName) + { + if (string.IsNullOrEmpty(commandName)) + { + return null; + } + + // First try exact match + if (helpData.TryGetValue(commandName, out var entity)) + { + return entity; + } + + // If not found, try to find a command with this name in any module + var key = helpData.Keys.FirstOrDefault(k => + k.Equals(commandName, StringComparison.OrdinalIgnoreCase) || + k.StartsWith($"{commandName} ", StringComparison.OrdinalIgnoreCase)); + + return key != null ? helpData[key] : null; + } + + /// + public HelpEntity? GetHelpForCommand(string commandName, string moduleName) + { + if (string.IsNullOrEmpty(commandName) || string.IsNullOrEmpty(moduleName)) + { + return null; + } + + var key = $"{commandName} {moduleName}"; + return helpData.TryGetValue(key, out var entity) ? entity : null; + } + + /// + public List GetHelpForCommandRange(string commandName) + { + if (string.IsNullOrEmpty(commandName)) + { + return new List { new HelpEntity() }; + } + + return helpData + .Where(kvp => kvp.Key.StartsWith(commandName, StringComparison.OrdinalIgnoreCase)) + .Select(kvp => kvp.Value) + .ToList(); + } + } +} diff --git a/explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs b/explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs new file mode 100644 index 0000000..e2014a9 --- /dev/null +++ b/explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs @@ -0,0 +1,143 @@ +using System.Linq; +using explainpowershell.models; +using NUnit.Framework; + +namespace ExplainPowershell.SyntaxAnalyzer.Tests +{ + [TestFixture] + public class InMemoryHelpRepositoryTests + { + private InMemoryHelpRepository repository; + + [SetUp] + public void Setup() + { + repository = new InMemoryHelpRepository(); + } + + [Test] + public void GetHelpForCommand_WithValidCommand_ReturnsEntity() + { + // Arrange + var entity = new HelpEntity + { + CommandName = "Get-Process", + Synopsis = "Gets the processes running on the local computer.", + ModuleName = "Microsoft.PowerShell.Management" + }; + repository.AddHelpEntity(entity); + + // Act + var result = repository.GetHelpForCommand("get-process"); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("Get-Process", result.CommandName); + } + + [Test] + public void GetHelpForCommand_WithInvalidCommand_ReturnsNull() + { + // Act + var result = repository.GetHelpForCommand("NonExistentCommand"); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void GetHelpForCommand_WithModule_ReturnsCorrectEntity() + { + // Arrange + var entity1 = new HelpEntity + { + CommandName = "Test-Command", + ModuleName = "Module1", + Synopsis = "Test command from Module1" + }; + var entity2 = new HelpEntity + { + CommandName = "Test-Command", + ModuleName = "Module2", + Synopsis = "Test command from Module2" + }; + repository.AddHelpEntity(entity1); + repository.AddHelpEntity(entity2); + + // Act + var result1 = repository.GetHelpForCommand("Test-Command", "Module1"); + var result2 = repository.GetHelpForCommand("Test-Command", "Module2"); + + // Assert + Assert.IsNotNull(result1); + Assert.AreEqual("Module1", result1.ModuleName); + Assert.IsNotNull(result2); + Assert.AreEqual("Module2", result2.ModuleName); + } + + [Test] + public void GetHelpForCommandRange_WithMatchingPrefix_ReturnsMultipleEntities() + { + // Arrange + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Service" }); + repository.AddHelpEntity(new HelpEntity { CommandName = "Set-Service" }); + + // Act + var result = repository.GetHelpForCommandRange("Get-"); + + // Assert + Assert.AreEqual(2, result.Count); + Assert.IsTrue(result.Any(e => e.CommandName == "Get-Process")); + Assert.IsTrue(result.Any(e => e.CommandName == "Get-Service")); + } + + [Test] + public void GetHelpForCommandRange_WithNoMatches_ReturnsEmptyList() + { + // Arrange + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); + + // Act + var result = repository.GetHelpForCommandRange("Set-"); + + // Assert + Assert.AreEqual(0, result.Count); + } + + [Test] + public void Clear_RemovesAllEntities() + { + // Arrange + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Service" }); + + // Act + repository.Clear(); + var result = repository.GetHelpForCommand("Get-Process"); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void GetHelpForCommand_IsCaseInsensitive() + { + // Arrange + var entity = new HelpEntity { CommandName = "Get-Process" }; + repository.AddHelpEntity(entity); + + // Act + var result1 = repository.GetHelpForCommand("GET-PROCESS"); + var result2 = repository.GetHelpForCommand("get-process"); + var result3 = repository.GetHelpForCommand("Get-Process"); + + // Assert + Assert.IsNotNull(result1); + Assert.IsNotNull(result2); + Assert.IsNotNull(result3); + Assert.AreEqual(result1.CommandName, result2.CommandName); + Assert.AreEqual(result2.CommandName, result3.CommandName); + } + } +} From 5ebb7d864916dc6c876f001eaa80e00d48dab440 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:16:44 +0000 Subject: [PATCH 06/37] Address code review feedback: fix inconsistent empty list handling Co-authored-by: Jawz84 <26464885+Jawz84@users.noreply.github.com> --- .../helpers/InMemoryHelpRepository.cs | 2 +- .../AstVisitorExplainer_command.cs | 2 +- .../Repositories/TableStorageHelpRepository.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs b/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs index d19b6cb..f2ee7e2 100644 --- a/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs +++ b/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs @@ -76,7 +76,7 @@ public List GetHelpForCommandRange(string commandName) { if (string.IsNullOrEmpty(commandName)) { - return new List { new HelpEntity() }; + return new List(); } return helpData diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_command.cs b/explainpowershell.analysisservice/AstVisitorExplainer_command.cs index e187521..0d9bc0b 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_command.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_command.cs @@ -28,7 +28,7 @@ public override AstVisitAction VisitCommand(CommandAst commandAst) { var helpResults = HelpTableQueryRange(resolvedCmd); helpResult = helpResults?.FirstOrDefault(); - if (helpResults != null && helpResults.Count > 1) + if (helpResults.Count > 1) { this.errorMessage = $"The command '{helpResult?.CommandName}' is present in more than one module: '{string.Join("', '", helpResults.Select(r => r.ModuleName))}'. Explicitly prepend the module name to the command to select one: '{helpResults.First().ModuleName}\\{helpResult?.CommandName}'"; } diff --git a/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs b/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs index 7645f1d..4eb8ad6 100644 --- a/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs +++ b/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs @@ -46,7 +46,7 @@ public List GetHelpForCommandRange(string commandName) { if (string.IsNullOrEmpty(commandName)) { - return new List { new HelpEntity() }; + return new List(); } // Getting a range from Azure Table storage works based on ascii char filtering. You can match prefixes. From e18fe259da109fe86a94429156881093a4c0e206 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Wed, 10 Dec 2025 17:30:08 +0100 Subject: [PATCH 07/37] Update package references to latest stable versions in project files --- .../explainpowershell.analysisservice.tests.csproj | 2 +- .../explainpowershell.csproj | 2 +- .../explainpowershell.frontend.csproj | 4 ++-- tools/bump_versions.ps1 | 13 ++++++++++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj b/explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj index 3847500..6365930 100644 --- a/explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj +++ b/explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/explainpowershell.analysisservice/explainpowershell.csproj b/explainpowershell.analysisservice/explainpowershell.csproj index c2eb5ba..0944cb7 100644 --- a/explainpowershell.analysisservice/explainpowershell.csproj +++ b/explainpowershell.analysisservice/explainpowershell.csproj @@ -12,7 +12,7 @@ - + diff --git a/explainpowershell.frontend/explainpowershell.frontend.csproj b/explainpowershell.frontend/explainpowershell.frontend.csproj index 1f254f4..75722c7 100644 --- a/explainpowershell.frontend/explainpowershell.frontend.csproj +++ b/explainpowershell.frontend/explainpowershell.frontend.csproj @@ -3,8 +3,8 @@ net10.0 - - + + diff --git a/tools/bump_versions.ps1 b/tools/bump_versions.ps1 index bd6e6ba..603c270 100644 --- a/tools/bump_versions.ps1 +++ b/tools/bump_versions.ps1 @@ -1,10 +1,10 @@ -$ErrorActionPreference = 'stop' - [CmdletBinding()] param( [string]$TargetDotnetVersion ) +$ErrorActionPreference = 'stop' + $currentGitBranch = git branch --show-current if ($currentGitBranch -eq 'main') { throw "You are on 'main' branch, create a new branch first" @@ -52,6 +52,7 @@ Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForE $xml.Save($_.FullName) } +Write-Host "Updating Azure Functions settings to runtime ~$($functionsToolsVersion.Major)" $vsCodeSettingsFile = "$PSScriptRoot/../.vscode/settings.json" if (Test-Path $vsCodeSettingsFile) { $settings = Get-Content -Path $vsCodeSettingsFile | ConvertFrom-Json @@ -60,6 +61,7 @@ if (Test-Path $vsCodeSettingsFile) { $settings | ConvertTo-Json -Depth 5 | Out-File -Path $vsCodeSettingsFile -Force } +Write-Host "Updating GitHub Actions workflows" $deployAppWorkflow = "$PSScriptRoot/../.github/workflows/deploy_app.yml" if (Test-Path $deployAppWorkflow) { $ghDeployAction = Get-Content -Path $deployAppWorkflow | ConvertFrom-Yaml @@ -93,6 +95,7 @@ if (Test-Path $deployInfraWorkflow) { | Set-Content -Path $deployInfraWorkflow -Force } +Write-Host "Updating NuGet package versions to latest stable" ## Update packages (mirrors Pester checks: dotnet list package --outdated) $outdated = dotnet list "$PSScriptRoot/../explainpowershell.sln" package --outdated $outdated += dotnet list "$PSScriptRoot/../explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj" package --outdated @@ -103,7 +106,9 @@ $targetVersions = $outdated | Select-String '^ >' | ForEach-Object { PackageName = $parts[0].Trim('>',' ') LatestVersion = [version]$parts[-1] } -} +} | Sort-Object PackageName -Unique + +Write-Host "Found $($targetVersions.Count) packages to update: $($targetVersions.PackageName -join ', ')" Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForEach-Object { $xml = [xml](Get-Content $_.FullName) @@ -111,6 +116,7 @@ Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForE foreach ($package in @($itemGroup.PackageReference)) { if ($package.Include -in $targetVersions.PackageName) { $latest = ($targetVersions | Where-Object PackageName -EQ $package.Include).LatestVersion + Write-Debug "Updating '$($package.Include)' to version '$latest' in '$($_.FullName)'" if ($latest) { $package.Version = $latest.ToString() } @@ -120,6 +126,7 @@ Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForE $xml.Save($_.FullName) } +Write-Host "Restoring and cleaning solution" Push-Location $PSScriptRoot/.. dotnet restore dotnet clean --verbosity minimal From adc73b32b5aa2a6c37ad6266a2a69c2f63361276 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Wed, 10 Dec 2025 17:45:23 +0100 Subject: [PATCH 08/37] Refactor GitHub Actions workflow update for dotnet version handling --- tools/bump_versions.ps1 | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/tools/bump_versions.ps1 b/tools/bump_versions.ps1 index 603c270..43e44f3 100644 --- a/tools/bump_versions.ps1 +++ b/tools/bump_versions.ps1 @@ -64,35 +64,12 @@ if (Test-Path $vsCodeSettingsFile) { Write-Host "Updating GitHub Actions workflows" $deployAppWorkflow = "$PSScriptRoot/../.github/workflows/deploy_app.yml" if (Test-Path $deployAppWorkflow) { - $ghDeployAction = Get-Content -Path $deployAppWorkflow | ConvertFrom-Yaml - foreach ($jobName in 'buildFrontend','buildBackend') { - $job = $ghDeployAction.jobs.$jobName - if ($null -eq $job) { continue } - foreach ($step in $job.steps) { - if ($step.with.'dotnet-version') { - $step.with.'dotnet-version' = $dotNetShortVersion - } - if ($step.with.path) { - $step.with.path = $step.with.path -replace 'net\d+\.\d+', "net$dotNetShortVersion" - } - } - } - $ghDeployAction - | ConvertTo-Yaml + $ghFlow = Get-Content -Path $deployAppWorkflow + $ghFlow | ForEach-Object { $_ -replace 'dotnet-version: "\d+.0"', "dotnet-version: `"$($dotNetVersion.Major).0`"" } | Set-Content -Path $deployAppWorkflow -Force } - -$deployInfraWorkflow = "$PSScriptRoot/../.github/workflows/deploy_azure_infra.yml" -if (Test-Path $deployInfraWorkflow) { - $ghDeployInfra = Get-Content -Path $deployInfraWorkflow | ConvertFrom-Yaml - foreach ($step in $ghDeployInfra.jobs.deploy.steps) { - if ($step.run) { - $step.run = $step.run -replace 'FUNCTIONS_EXTENSION_VERSION=~\d+', "FUNCTIONS_EXTENSION_VERSION=~$($functionsToolsVersion.Major)" - } - } - $ghDeployInfra - | ConvertTo-Yaml - | Set-Content -Path $deployInfraWorkflow -Force +else { + Write-Host "No deploy_app.yml workflow found, skipping update." } Write-Host "Updating NuGet package versions to latest stable" From 005a27d2c2068ae84f6aa64cfb294750d656dbae Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Fri, 12 Dec 2025 17:54:53 +0100 Subject: [PATCH 09/37] Refactor code to improve null handling and make properties nullable where appropriate --- .../helpers/InMemoryHelpRepository.cs | 4 +-- .../AstVisitorExplainer_classes.cs | 6 ++-- .../AstVisitorExplainer_command.cs | 6 ++-- .../AstVisitorExplainer_expressions.cs | 6 ++-- .../AstVisitorExplainer_statements.cs | 2 +- .../SyntaxAnalyzerExtensions.cs | 9 +++-- .../Helpers/GetParameterSetData.cs | 6 ++-- .../Helpers/MatchParam.cs | 8 ++--- explainpowershell.analysisservice/MetaData.cs | 2 +- explainpowershell.analysisservice/Program.cs | 4 +-- .../Services/AiExplanationService.cs | 4 +-- explainpowershell.frontend/App.razor | 2 +- .../Pages/Index.razor.cs | 2 +- .../tools/DeCompress.cs | 2 +- explainpowershell.models/AnalysisResult.cs | 6 ++-- explainpowershell.models/Code.cs | 2 +- explainpowershell.models/Explanation.cs | 14 ++++---- explainpowershell.models/HelpEntity.cs | 35 +++++++++---------- explainpowershell.models/HelpMetaData.cs | 8 ++--- explainpowershell.models/Module.cs | 2 +- explainpowershell.models/ParameterData.cs | 18 +++++----- explainpowershell.models/ParameterSetData.cs | 8 ++--- 22 files changed, 80 insertions(+), 76 deletions(-) diff --git a/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs b/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs index f2ee7e2..fc0d924 100644 --- a/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs +++ b/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs @@ -38,7 +38,7 @@ public void Clear() } /// - public HelpEntity? GetHelpForCommand(string commandName) + public HelpEntity GetHelpForCommand(string commandName) { if (string.IsNullOrEmpty(commandName)) { @@ -60,7 +60,7 @@ public void Clear() } /// - public HelpEntity? GetHelpForCommand(string commandName, string moduleName) + public HelpEntity GetHelpForCommand(string commandName, string moduleName) { if (string.IsNullOrEmpty(commandName) || string.IsNullOrEmpty(moduleName)) { diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs b/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs index 858e6f5..95404e4 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs @@ -94,12 +94,12 @@ public override AstVisitAction VisitFunctionMember(FunctionMemberAst functionMem var howManyParameters = functionMemberAst.Parameters.Count == 0 ? string.Empty : $"has {functionMemberAst.Parameters.Count} parameters and "; - description = $"A constructor, a special method, used to set things up within the object. Constructors have the same name as the class. This constructor {howManyParameters}is called when [{(functionMemberAst.Parent as TypeDefinitionAst).Name}]::new({parameterSignature}) is used."; - helpResult.DocumentationLink += "#constructor"; + description = $"A constructor, a special method, used to set things up within the object. Constructors have the same name as the class. This constructor {howManyParameters}is called when [{(functionMemberAst.Parent as TypeDefinitionAst)?.Name ?? "Unknown"}]::new({parameterSignature}) is used."; + helpResult?.DocumentationLink += "#constructor"; } else { - helpResult.DocumentationLink += "#class-methods"; + helpResult?.DocumentationLink += "#class-methods"; var modifier = "M"; modifier = functionMemberAst.IsHidden ? "A hidden m" : modifier; modifier = functionMemberAst.IsStatic ? "A static m" : modifier; diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_command.cs b/explainpowershell.analysisservice/AstVisitorExplainer_command.cs index 0d9bc0b..99967f8 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_command.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_command.cs @@ -28,7 +28,7 @@ public override AstVisitAction VisitCommand(CommandAst commandAst) { var helpResults = HelpTableQueryRange(resolvedCmd); helpResult = helpResults?.FirstOrDefault(); - if (helpResults.Count > 1) + if (helpResults?.Count > 1) { this.errorMessage = $"The command '{helpResult?.CommandName}' is present in more than one module: '{string.Join("', '", helpResults.Select(r => r.ModuleName))}'. Explicitly prepend the module name to the command to select one: '{helpResults.First().ModuleName}\\{helpResult?.CommandName}'"; } @@ -180,7 +180,7 @@ public override AstVisitAction VisitCommandParameter(CommandParameterAst command .Append("__AllParameterSets") .ToArray(); - var paramSetData = Helpers.GetParameterSetData(matchedParameter, availableParamSets); + var paramSetData = Helpers.GetParameterSetData(matchedParameter, availableParamSets ?? Array.Empty()); if (paramSetData.Count > 1) { @@ -191,7 +191,7 @@ public override AstVisitAction VisitCommandParameter(CommandParameterAst command var paramSetName = paramSetData.Select(p => p.ParameterSetName).FirstOrDefault(); if (paramSetName == "__AllParameterSets") { - if (availableParamSets.Length > 1) + if (availableParamSets?.Length > 1) { exp.Description += $"\nThis parameter is present in all parameter sets."; } diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs index 1d5295a..9317c22 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs @@ -12,7 +12,7 @@ public partial class AstVisitorExplainer : AstVisitor2 public override AstVisitAction VisitArrayExpression(ArrayExpressionAst arrayExpressionAst) { var helpResult = HelpTableQuery("about_arrays"); - helpResult.DocumentationLink += "#the-array-sub-expression-operator"; + helpResult?.DocumentationLink += "#the-array-sub-expression-operator"; explanations.Add( new Explanation() @@ -361,7 +361,7 @@ public override AstVisitAction VisitVariableExpression(VariableExpressionAst var suffix = ", with the 'using' scope modifier: a local variable used in a remote scope."; explanation.HelpResult = HelpTableQuery("about_Remote_Variables"); explanation.CommandName = "Scoped variable"; - explanation.HelpResult.RelatedLinks += HelpTableQuery("about_Scopes")?.DocumentationLink; + explanation.HelpResult?.RelatedLinks += HelpTableQuery("about_Scopes")?.DocumentationLink; } explanation.Description = $"A{prefix}variable {standard}{suffix}"; @@ -374,7 +374,7 @@ public override AstVisitAction VisitVariableExpression(VariableExpressionAst var public override AstVisitAction VisitTernaryExpression(TernaryExpressionAst ternaryExpressionAst) { var helpResult = HelpTableQuery("about_if"); - helpResult.DocumentationLink += "#using-the-ternary-operator-syntax"; + helpResult?.DocumentationLink += "#using-the-ternary-operator-syntax"; explanations.Add(new Explanation() { diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs index db2d074..2446d40 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs @@ -136,7 +136,7 @@ public override AstVisitAction VisitExitStatement(ExitStatementAst exitStatement $", with an exit code of '{exitStatementAst.Pipeline.Extent.Text}'."; var helpResult = HelpTableQuery("about_language_keywords"); - helpResult.DocumentationLink += "#exit"; + helpResult?.DocumentationLink += "#exit"; explanations.Add( new Explanation() diff --git a/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs b/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs index 84f5186..3f0c683 100644 --- a/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs +++ b/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs @@ -35,7 +35,10 @@ private static string GenerateId(this Token token) public static string TryFindParentExplanation(Ast ast, List explanations, int level = 0) { if (explanations.Count == 0 | ast.Parent == null) - return null; + return string.Empty; + + if (ast.Parent == null) + return string.Empty; var parentId = ast.Parent.GenerateId(); @@ -61,11 +64,13 @@ public static string TryFindParentExplanation(Token token, List exp } var closestNeigbour = explanationsBeforeToken.Max(e => GetEndOffSet(e)); - return explanationsBeforeToken.FirstOrDefault(t => GetEndOffSet(t) == closestNeigbour).Id; + return explanationsBeforeToken.FirstOrDefault(t => GetEndOffSet(t) == closestNeigbour)?.Id ?? string.Empty; } private static int GetEndOffSet(Explanation e) { + if (e.Id == null) + return -1; return int.Parse(e.Id.Split('.')[2]); } } diff --git a/explainpowershell.analysisservice/Helpers/GetParameterSetData.cs b/explainpowershell.analysisservice/Helpers/GetParameterSetData.cs index c057db2..0c4cb98 100644 --- a/explainpowershell.analysisservice/Helpers/GetParameterSetData.cs +++ b/explainpowershell.analysisservice/Helpers/GetParameterSetData.cs @@ -21,9 +21,9 @@ public static List GetParameterSetData(ParameterData paramData new ParameterSetData() { ParameterSetName = paramSet, - HelpMessage = foundParamSet.GetProperty("HelpMessage").GetString(), - HelpMessageBaseName = foundParamSet.GetProperty("HelpMessageBaseName").GetString(), - HelpMessageResourceId = foundParamSet.GetProperty("HelpMessageResourceId").GetString(), + HelpMessage = foundParamSet.GetProperty("HelpMessage").GetString() ?? string.Empty, + HelpMessageBaseName = foundParamSet.GetProperty("HelpMessageBaseName").GetString() ?? string.Empty, + HelpMessageResourceId = foundParamSet.GetProperty("HelpMessageResourceId").GetString() ?? string.Empty, IsMandatory = foundParamSet.GetProperty("IsMandatory").GetBoolean(), Position = foundParamSet.GetProperty("Position").GetInt32(), ValueFromPipeline = foundParamSet.GetProperty("ValueFromPipeline").GetBoolean(), diff --git a/explainpowershell.analysisservice/Helpers/MatchParam.cs b/explainpowershell.analysisservice/Helpers/MatchParam.cs index 951829a..d96d327 100644 --- a/explainpowershell.analysisservice/Helpers/MatchParam.cs +++ b/explainpowershell.analysisservice/Helpers/MatchParam.cs @@ -31,11 +31,11 @@ public static partial class Helpers if (!string.Equals(foundParameter, "none", StringComparison.OrdinalIgnoreCase)) { matchedParam = doc.Where( - p => p.Aliases.Split(", ") + p => (p.Aliases?.Split(", ") .All( q => q.StartsWith( foundParameter, - StringComparison.InvariantCultureIgnoreCase))).ToList(); + StringComparison.InvariantCultureIgnoreCase))) ?? false).ToList(); } if (matchedParam.Count == 0) @@ -43,14 +43,14 @@ public static partial class Helpers // If no aliases match, then try partial parameter names for static params (aliases and static params take precedence) matchedParam = doc.Where( p => ! (p.IsDynamic ?? false) && - p.Name.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase)).ToList(); + (p.Name?.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase) ?? false)).ToList(); } if (matchedParam.Count == 0) { // If no aliases or static params match, then try partial parameter names for dynamic params too. matchedParam = doc.Where( - p => p.Name.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase)).ToList(); + p => p.Name?.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase) ?? false).ToList(); } if (matchedParam.Count == 0) diff --git a/explainpowershell.analysisservice/MetaData.cs b/explainpowershell.analysisservice/MetaData.cs index 015220e..1759844 100644 --- a/explainpowershell.analysisservice/MetaData.cs +++ b/explainpowershell.analysisservice/MetaData.cs @@ -66,7 +66,7 @@ public static HelpMetaData CalculateMetaData(TableClient client, ILogger log) var entities = client.Query(filter: filter, select: select).ToList(); var numAbout = entities - .Count(r => r.CommandName.StartsWith("about_", StringComparison.OrdinalIgnoreCase)); + .Count(r => r.CommandName?.StartsWith("about_", StringComparison.OrdinalIgnoreCase) ?? false); var moduleNames = entities .Select(r => r.ModuleName) diff --git a/explainpowershell.analysisservice/Program.cs b/explainpowershell.analysisservice/Program.cs index d50d724..22174cd 100644 --- a/explainpowershell.analysisservice/Program.cs +++ b/explainpowershell.analysisservice/Program.cs @@ -26,7 +26,7 @@ services.Configure(context.Configuration.GetSection(AiExplanationOptions.SectionName)); // Register ChatClient factory - services.AddSingleton(sp => + services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; var logger = sp.GetRequiredService>(); @@ -39,7 +39,7 @@ if (!isConfigured) { logger.LogWarning("AI explanation ChatClient not configured. AI features will be disabled."); - return null; + return null!; } logger.LogInformation( diff --git a/explainpowershell.analysisservice/Services/AiExplanationService.cs b/explainpowershell.analysisservice/Services/AiExplanationService.cs index 24b1721..65205be 100644 --- a/explainpowershell.analysisservice/Services/AiExplanationService.cs +++ b/explainpowershell.analysisservice/Services/AiExplanationService.cs @@ -165,7 +165,7 @@ private static AnalysisResult ReducePayloadSize(AnalysisResult result, int targe ExpandedCode = result.ExpandedCode, ParseErrorMessage = result.ParseErrorMessage, DetectedModules = result.DetectedModules, - Explanations = result.Explanations? + Explanations = result.Explanations .Select(e => new Explanation { Id = e.Id, @@ -197,7 +197,7 @@ private static AnalysisResult ReducePayloadSize(AnalysisResult result, int targe } // Last resort: remove help results entirely, keep only basic explanations - reduced.Explanations = result.Explanations? + reduced.Explanations = result.Explanations .Select(e => new Explanation { Id = e.Id, diff --git a/explainpowershell.frontend/App.razor b/explainpowershell.frontend/App.razor index 3a2af53..6f67a6e 100644 --- a/explainpowershell.frontend/App.razor +++ b/explainpowershell.frontend/App.razor @@ -1,4 +1,4 @@ - + diff --git a/explainpowershell.frontend/Pages/Index.razor.cs b/explainpowershell.frontend/Pages/Index.razor.cs index 1bd2deb..6074cdd 100644 --- a/explainpowershell.frontend/Pages/Index.razor.cs +++ b/explainpowershell.frontend/Pages/Index.razor.cs @@ -191,7 +191,7 @@ private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResu private class AiExplanationResponse { public string AiExplanation { get; set; } = string.Empty; - public string? ModelName { get; set; } + public string ModelName { get; set; } } } } \ No newline at end of file diff --git a/explainpowershell.helpcollector/tools/DeCompress.cs b/explainpowershell.helpcollector/tools/DeCompress.cs index 8286ef0..39af806 100644 --- a/explainpowershell.helpcollector/tools/DeCompress.cs +++ b/explainpowershell.helpcollector/tools/DeCompress.cs @@ -40,7 +40,7 @@ public static string Decompress(string compressedText) memoryStream.Position = 0; using (var gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) { - gZipStream.Read(buffer, 0, buffer.Length); + gZipStream.ReadExactly(buffer); } return Encoding.UTF8.GetString(buffer); diff --git a/explainpowershell.models/AnalysisResult.cs b/explainpowershell.models/AnalysisResult.cs index 0f3407f..60667a6 100644 --- a/explainpowershell.models/AnalysisResult.cs +++ b/explainpowershell.models/AnalysisResult.cs @@ -4,10 +4,10 @@ namespace explainpowershell.models { public class AnalysisResult { - public string ExpandedCode { get; set; } + public string? ExpandedCode { get; set; } public List Explanations { get; set; } = new List(); public List DetectedModules { get; set; } = new List(); - public string ParseErrorMessage { get; set; } - public string AiExplanation { get; set; } + public string? ParseErrorMessage { get; set; } + public string? AiExplanation { get; set; } } } diff --git a/explainpowershell.models/Code.cs b/explainpowershell.models/Code.cs index f7d4b39..ed68c51 100644 --- a/explainpowershell.models/Code.cs +++ b/explainpowershell.models/Code.cs @@ -2,6 +2,6 @@ namespace explainpowershell.models { public class Code { - public string PowershellCode { get; set; } + public string? PowershellCode { get; set; } } } diff --git a/explainpowershell.models/Explanation.cs b/explainpowershell.models/Explanation.cs index 3b60741..c8d0cab 100644 --- a/explainpowershell.models/Explanation.cs +++ b/explainpowershell.models/Explanation.cs @@ -2,12 +2,12 @@ namespace explainpowershell.models { public class Explanation { - public string OriginalExtent { get; set; } - public string CommandName { get; set; } - public string Description { get; set; } - public HelpEntity HelpResult { get; set; } - public string Id { get; set; } - public string ParentId { get; set; } - public string TextToHighlight { get; set; } + public string? OriginalExtent { get; set; } + public string? CommandName { get; set; } + public string? Description { get; set; } + public HelpEntity? HelpResult { get; set; } + public string? Id { get; set; } + public string? ParentId { get; set; } + public string? TextToHighlight { get; set; } } } diff --git a/explainpowershell.models/HelpEntity.cs b/explainpowershell.models/HelpEntity.cs index 2b0663d..52c0c65 100644 --- a/explainpowershell.models/HelpEntity.cs +++ b/explainpowershell.models/HelpEntity.cs @@ -6,25 +6,24 @@ namespace explainpowershell.models { public class HelpEntity : ITableEntity { - public string Aliases { get; set; } - public string CommandName { get; set; } - public string DefaultParameterSet { get; set; } - public string Description { get; set; } - public string DocumentationLink { get; set; } - public string InputTypes { get; set; } - public string ModuleName { get; set; } - public string ModuleProjectUri { get; set; } - public string ModuleVersion { get; set; } - public string Parameters { get; set; } - public string ParameterSetNames { get; set; } - public string RelatedLinks { get; set; } - public string ReturnValues { get; set; } - public string Synopsis { get; set; } - public string Syntax { get; set; } - + public string? Aliases { get; set; } + public string? CommandName { get; set; } + public string? DefaultParameterSet { get; set; } + public string? Description { get; set; } + public string? DocumentationLink { get; set; } + public string? InputTypes { get; set; } + public string? ModuleName { get; set; } + public string? ModuleProjectUri { get; set; } + public string? ModuleVersion { get; set; } + public string? Parameters { get; set; } + public string? ParameterSetNames { get; set; } + public string? RelatedLinks { get; set; } + public string? ReturnValues { get; set; } + public string? Synopsis { get; set; } + public string? Syntax { get; set; } // ITableEntity - public string PartitionKey { get; set; } - public string RowKey { get; set; } + public string? PartitionKey { get; set; } + public string? RowKey { get; set; } public DateTimeOffset? Timestamp { get; set; } public ETag ETag { get; set; } } diff --git a/explainpowershell.models/HelpMetaData.cs b/explainpowershell.models/HelpMetaData.cs index 2147ed3..2a0e693 100644 --- a/explainpowershell.models/HelpMetaData.cs +++ b/explainpowershell.models/HelpMetaData.cs @@ -9,12 +9,12 @@ public class HelpMetaData : ITableEntity public int NumberOfCommands { get; set; } public int NumberOfAboutArticles { get; set; } public int NumberOfModules { get; set; } - public string ModuleNames { get; set; } - public string LastPublished {get; set;} + public string? ModuleNames { get; set; } + public string? LastPublished {get; set;} // ITableEntity - public string PartitionKey { get; set; } - public string RowKey { get; set; } + public required string PartitionKey { get; set; } + public required string RowKey { get; set; } public DateTimeOffset? Timestamp { get; set; } public ETag ETag { get; set; } } diff --git a/explainpowershell.models/Module.cs b/explainpowershell.models/Module.cs index f86ab8f..101b0d7 100644 --- a/explainpowershell.models/Module.cs +++ b/explainpowershell.models/Module.cs @@ -2,6 +2,6 @@ namespace explainpowershell.models { public class Module { - public string ModuleName { get; set; } + public string? ModuleName { get; set; } } } diff --git a/explainpowershell.models/ParameterData.cs b/explainpowershell.models/ParameterData.cs index 55b53f1..f75f46a 100644 --- a/explainpowershell.models/ParameterData.cs +++ b/explainpowershell.models/ParameterData.cs @@ -5,17 +5,17 @@ namespace explainpowershell.models { public class ParameterData { - public string Aliases { get; set; } - public string DefaultValue { get; set; } - public string Description { get; set; } - public string Globbing { get; set; } + public string? Aliases { get; set; } + public string? DefaultValue { get; set; } + public string? Description { get; set; } + public string? Globbing { get; set; } public bool? IsDynamic { get; set; } - public string Name { get; set; } - public string PipelineInput { get; set; } - public string Position { get; set; } - public string Required { get; set; } + public string? Name { get; set; } + public string? PipelineInput { get; set; } + public string? Position { get; set; } + public string? Required { get; set; } public bool? SwitchParameter { get; set; } - public string TypeName { get; set; } + public string? TypeName { get; set; } public JsonElement ParameterSets { get; set; } } } diff --git a/explainpowershell.models/ParameterSetData.cs b/explainpowershell.models/ParameterSetData.cs index 47c30dd..9f9e709 100644 --- a/explainpowershell.models/ParameterSetData.cs +++ b/explainpowershell.models/ParameterSetData.cs @@ -2,14 +2,14 @@ namespace explainpowershell.models { public class ParameterSetData { - public string ParameterSetName { get; set; } + public string? ParameterSetName { get; set; } public bool IsMandatory { get; set; } public int Position { get; set; } public bool ValueFromPipeline { get; set; } public bool ValueFromPipelineByPropertyName { get; set; } public bool ValueFromRemainingArguments { get; set; } - public string HelpMessage { get; set; } - public string HelpMessageBaseName { get; set; } - public string HelpMessageResourceId { get; set; } + public string? HelpMessage { get; set; } + public string? HelpMessageBaseName { get; set; } + public string? HelpMessageResourceId { get; set; } } } From 0e230a9ff729c59f9d6151b03c47f4e79fa36f34 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 09:09:45 +0100 Subject: [PATCH 10/37] Update VSCode extension recommendations for improved development experience --- .vscode/extensions.json | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 23abfa0..fc03f54 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,14 +1,11 @@ { "recommendations": [ + "azurite.azurite", "ms-azuretools.vscode-azurefunctions", + "ms-dotnettools.blazorwasm-companion", "ms-dotnettools.csharp", - "ms-vscode.powershell", "ms-dotnettools.vscode-dotnet-runtime", - "ms-dotnettools.blazorwasm-companion", - "vsls-contrib.codetour", - "derivitec-ltd.vscode-dotnet-adapter", - "ms-vscode.test-adapter-converter", - "hbenl.vscode-test-explorer", - "azurite.azurite" + "ms-vscode.powershell", + "vsls-contrib.codetour" ] } From a87b77978ddd110ac588ff79a385eb4ac2a84cea Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 09:50:38 +0100 Subject: [PATCH 11/37] Update code tours --- .../high-level-tour-of-the-application.tour | 60 +++++-------------- .tours/tour-of-the-azure-bootstrapper.tour | 2 +- .tours/tour-of-the-help-collector.tour | 15 +++-- 3 files changed, 26 insertions(+), 51 deletions(-) diff --git a/.tours/high-level-tour-of-the-application.tour b/.tours/high-level-tour-of-the-application.tour index 089e37c..1931daa 100644 --- a/.tours/high-level-tour-of-the-application.tour +++ b/.tours/high-level-tour-of-the-application.tour @@ -5,87 +5,57 @@ { "file": "explainpowershell.frontend/Pages/Index.razor", "description": "Welcome at the high-level tour of Explain PowerShell!\n\nWe will follow the journey of one user request trough the application and this is where that begins; the text input field where you can enter your PowerShell oneliner.\n\nIf the user presses Enter or clicks the `Explain` button, the oneliner is sent from this frontend to the backend api, the SyntaxAnalyzer endpoint.\n\nLet's see what happens there, and we will come back once we have an explanation to display here.", - "line": 12, - "selection": { - "start": { - "line": 16, - "character": 34 - }, - "end": { - "line": 22, - "character": 23 - } - } + "line": 15 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "This is where the PowerShell oneliner is sent to, the SyntaxAnalyzer endpoint, an Azure FunctionApp. \n\nWe use PowerShell's own parsing engine to parse the oneliner that was sent, the parser creates a so called Abstract Syntax Tree (AST), a logical representation of the oneliner in a convenient tree format that we can then 'walk' in an automated fashion. ", - "line": 24 + "line": 28 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "The AST is analyzed here, via the AstVisitorExplainer. It basically looks at all the logical elements of the oneliner and generates an explanation for each of them.\n\nWe will have a brief look there as well, to get the basic idea.", - "line": 53 + "line": 59 }, { - "file": "explainpowershell.analysisservice/AstVisitorExplainer.cs", + "file": "explainpowershell.analysisservice/AstVisitorExplainer_statements.cs", "description": "This is an example of how an 'if' statement explanation is generated. When the AST contains an 'if' statement, this method is called, and an explanation for it is added to the list of explanations. ", - "line": 577 + "line": 178 }, { - "file": "explainpowershell.analysisservice/AstVisitorExplainer.cs", + "file": "explainpowershell.analysisservice/AstVisitorExplainer_statements.cs", "description": "The `Explanation` type you see here, is defined in the `Models` project, which is used both by the Backend api as well as the Blazor Frontend. \n\nLet's have a quick look there.", - "line": 580 + "line": 181 }, { "file": "explainpowershell.models/Explanation.cs", "description": "This is how the `Explanation` type is defined. Even though we will send this type through the wire from the backend api to the frontend as json, because we use this same type on both ends, we can safely reserialize this data from json back to an `Explanation` object in the Frontend. \n\nThis is a great advantage of Blazor + C# api projects, you can have shared models. In JavaScript framework + c# backend api, you have to define the model twice. Which is errorprone. Ok back to our api.", - "line": 2 + "line": 3 }, { - "file": "explainpowershell.analysisservice/AstVisitorExplainer.cs", + "file": "explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs", "description": "Once we are done going through all the elements in the AST, this method gets called, and we return all explanations and a little metadata.", - "line": 24, - "selection": { - "start": { - "line": 397, - "character": 55 - }, - "end": { - "line": 397, - "character": 58 - } - } + "line": 28 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "if there were any parse errors, we get the message for that here.", - "line": 61 + "line": 65 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "We send our list of explanations, and any parse error messages back to the frontend.", - "line": 65 + "line": 69 }, { - "file": "explainpowershell.frontend/Pages/Index.razor", + "file": "explainpowershell.frontend/Pages/Index.razor.cs", "description": "This is where we re-create the AST tree a little bit, and generate our own tree, to display everything in a nice tree view, ordered logically. ", - "line": 250, - "selection": { - "start": { - "line": 29, - "character": 29 - }, - "end": { - "line": 29, - "character": 38 - } - } + "line": 137 }, { "file": "explainpowershell.frontend/Pages/Index.razor", "description": "Here is where we display all the tree items. This is basically a foreach, with an ItemTemplate that is filled in for each item in the tree.\n\nThis is how the end user gets to see the explanation that was generated for them.\n\nThis is the end of the high-level tour", - "line": 29 + "line": 52 } ] } \ No newline at end of file diff --git a/.tours/tour-of-the-azure-bootstrapper.tour b/.tours/tour-of-the-azure-bootstrapper.tour index f29a2cb..5218b70 100644 --- a/.tours/tour-of-the-azure-bootstrapper.tour +++ b/.tours/tour-of-the-azure-bootstrapper.tour @@ -5,7 +5,7 @@ { "file": "azuredeploymentbootstrapper.ps1", "description": "Welcome to the Azure bootstrapper tour.\n\nHere we will have a look at how you can get your own copy of explain powershell running in Azure, and this is basically how the actual www.explainpowershell.com site is set up in Azure too, excluding DNS, CDN and application insights.\n\nTo be able to use this, you need a GitHub account and an Azure subscription. A 30-day free subscription will do just fine.\nThe script assumes you have forked the explain powershell repo to your own github.\n\nYou will be asked to authenticate, so the script can set up everything.\n\nA few things are stored as GitHub Secrets, so they can be used from the GitHub Actions.\n\nAfter the resource group in Azure is created and the secrets are in place, you can run the `Deploy Azure Infra` GitHub Action. This action will deploy you copy of explain powershell to Azure.", - "line": 13 + "line": 1 }, { "file": ".github/workflows/deploy_azure_infra.yml", diff --git a/.tours/tour-of-the-help-collector.tour b/.tours/tour-of-the-help-collector.tour index d66d266..22d155a 100644 --- a/.tours/tour-of-the-help-collector.tour +++ b/.tours/tour-of-the-help-collector.tour @@ -9,13 +9,18 @@ }, { "file": ".github/workflows/add_module_help.yml", - "description": "A little bit above here, the requested module is installed on the GitHub action runner and here, the `helpcollector.ps1` script is run. This script will get all the relevant help information from the module. \n\nThe output is saved to json, and a check is done to see if there actually is any data in the file. ", - "line": 46 + "description": "If the module is not installed on the machine running this pipeline, try to install it.", + "line": 25 + }, + { + "file": ".github/workflows/add_module_help.yml", + "description": "After the module is installed, the `helpcollector.ps1` script is run. This script will get all the relevant help information from the module. \n\nThe output is saved to json, and a check is done to see if there actually is any data in the file. ", + "line": 47 }, { "file": ".github/workflows/add_module_help.yml", "description": "Here the `helpwriter.ps1` script is started, and is given the path to the cached json output from the previous step. This basically writes the information to an Azure Storage Table.\n\nThe steps below are just to notify the requester of the module if everything succeeded or not. We will have a look over at the scripts now, to see what they do.", - "line": 68 + "line": 74 }, { "file": "explainpowershell.helpcollector/helpcollector.ps1", @@ -24,8 +29,8 @@ }, { "file": "explainpowershell.helpcollector/helpwriter.ps1", - "description": "We store the help data in an Azure Storage Table. These are tables with two very important columns: `PartitionKey` and `RowKey`. Data from a Table like this, is retrieved based on these two columns. If a want a certain row, I ask for `(PartitionKey='x' RowKey='y')`, if my data is at x, y so to speak. So if we store data, the string that is in the PartitionKey and the RowKey, needs to be unique and easily searchable. That's why we convert the name of the command to lower case. When we search for a command later, we can convert that commandname to lower too, and be sure we always find the right command, even when the user MIsTyped-ThecOmmand. ", - "line": 48 + "description": "We store the help data in an Azure Storage Table. These are tables with two very important columns: `PartitionKey` and `RowKey`. Data from a Table like this, is retrieved based on these two columns. If we want a certain row, we ask for `(PartitionKey='x' RowKey='y')`, if our data is at x, y so to speak. So if we store data, the string that is in the PartitionKey and the RowKey, needs to be unique and easily searchable. That's why we convert the name of the command to lower case. When we search for a command later, we can convert that commandname to lower too, and be sure we always find the right command, even when the user MIsTyped-ThecOmmand. ", + "line": 117 }, { "file": "explainpowershell.helpcollector/BulkHelpCollector.ps1", From 8c627ad8a3e022016f4dc16a0bbc38cdefa90bb7 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 10:21:15 +0100 Subject: [PATCH 12/37] Add code tour for AI explanation feature --- .../tour-of-the-ai-explanation-feature.tour | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .tours/tour-of-the-ai-explanation-feature.tour diff --git a/.tours/tour-of-the-ai-explanation-feature.tour b/.tours/tour-of-the-ai-explanation-feature.tour new file mode 100644 index 0000000..0787f3f --- /dev/null +++ b/.tours/tour-of-the-ai-explanation-feature.tour @@ -0,0 +1,86 @@ +{ + "$schema": "https://aka.ms/codetour-schema", + "title": "Tour of the AI explanation feature", + "steps": [ + { + "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", + "description": "The AI explanation is intentionally *not* generated during the main AST analysis call.\n\nThis endpoint parses the PowerShell input into an AST and walks it with `AstVisitorExplainer` to produce the structured explanation nodes (the data that becomes the tree you see in the UI).\n\nThat AST-based result is returned quickly so the UI can render immediately.", + "line": 59 + }, + { + "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", + "description": "Key design choice: the AST endpoint always sets `AiExplanation` to an empty string.\n\nThe AI summary is fetched via a separate endpoint *after* the AST explanation tree is available. This keeps the main analysis deterministic and avoids coupling UI responsiveness to an external AI call.", + "line": 73 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "description": "Frontend starts by calling the AST analysis endpoint (`SyntaxAnalyzer`).\n\nThis request returns the expanded code plus a flat list of explanation nodes (each has an `Id` and optional `ParentId`).", + "line": 97 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "description": "Once the AST analysis result comes back, the frontend builds the explanation tree (based on `Id` / `ParentId`) for display in the `MudTreeView`.\n\nAt this point, the user already has a useful explanation from the AST visitor.", + "line": 137 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "description": "Immediately after the tree is available, the AI request is kicked off *in the background*.\n\nNotice the fire-and-forget pattern (`_ = ...`) so the AST UI is not blocked while the AI endpoint runs.", + "line": 142 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "description": "`LoadAiExplanationAsync` constructs a payload containing:\n- the original PowerShell code\n- the full AST analysis result\n\nThen it POSTs to the backend `AiExplanation` endpoint.\n\nIf the call fails, the UI silently continues without the AI summary (AI is treated as optional).", + "line": 145 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "description": "This is the actual HTTP call that requests the AI explanation.\n\nIt sends both the code and the AST result so the backend can build a prompt that is grounded in the already-produced explanation nodes and help metadata.", + "line": 158 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor", + "description": "The UI has a dedicated 'AI explanation' card.\n\nIt's only shown when either:\n- `AiExplanationLoading` is true (spinner), or\n- a non-empty `AiExplanation` has arrived.\n\nThis makes the feature feel additive: the AST explanation tree appears first, then the AI summary appears when ready.", + "line": 34 + }, + { + "file": "explainpowershell.analysisservice/AiExplanationFunction.cs", + "description": "Backend entrypoint for the AI feature: an Azure Function named `AiExplanation`.\n\nIt accepts an `AiExplanationRequest` that includes both the PowerShell code and the `AnalysisResult` produced earlier by the AST endpoint.", + "line": 23 + }, + { + "file": "explainpowershell.analysisservice/AiExplanationFunction.cs", + "description": "After validating/deserializing the request, this function delegates the real work to `IAiExplanationService.GenerateAsync(...)`.\n\nThis separation keeps the HTTP handler thin and makes the behavior easier to test.", + "line": 63 + }, + { + "file": "explainpowershell.analysisservice/Program.cs", + "description": "The AI feature is wired up through DI.\n\nA `ChatClient` is registered only when configuration is present (`Endpoint`, `ApiKey`, `DeploymentName`) and `Enabled` is true. If not configured, the factory returns `null` and AI remains disabled.", + "line": 29 + }, + { + "file": "explainpowershell.analysisservice/Services/AiExplanationOptions.cs", + "description": "AI behavior is controlled via `AiExplanationOptions`:\n- the system prompt (how the AI should behave)\n- an example prompt/response (few-shot guidance)\n- payload size guardrails (`MaxPayloadCharacters`)\n- request timeout (`RequestTimeoutSeconds`)\n\nThese settings are loaded from the `AiExplanation` config section.", + "line": 6 + }, + { + "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", + "description": "First guardrail: if DI did not create a `ChatClient`, the service returns `(null, null)` and logs that AI is unavailable.\n\nThis is what makes the feature optional without breaking the main AST explanation flow.", + "line": 30 + }, + { + "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", + "description": "Prompt grounding & size safety: the service builds a ‘slim’ version of the analysis result and serializes it to JSON for the prompt.\n\nIf the JSON is too large, it progressively reduces details (e.g., trims help fields, limits explanation count) so the request stays under `MaxPayloadCharacters`.", + "line": 49 + }, + { + "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", + "description": "Finally, the service builds a chat message list and calls `CompleteChatAsync`.\n\nThe response is reduced to a best-effort single sentence (per the prompt), and the model name is returned too so the UI can display what model produced the summary.", + "line": 82 + }, + { + "file": "explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1", + "description": "The AI endpoint has Pester integration tests.\n\nNotably, there’s coverage for:\n- accepting a valid `AnalysisResult` payload\n- handling very large payloads (50KB+) gracefully (exercise the payload reduction path)\n- an end-to-end workflow: call SyntaxAnalyzer first, then call AiExplanation with that output.", + "line": 33 + } + ] +} From ac2efdfdab331e7ffd3d442997c7998b66d96522 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 10:54:31 +0100 Subject: [PATCH 13/37] Add Copilot instructions for Explain PowerShell repository --- .github/copilot-instructions.md | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..2988896 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,60 @@ +# Copilot instructions for Explain PowerShell + +These instructions are for GitHub Copilot Chat/Edits when working in this repository. + +## Repo overview (what this project is) +- A PowerShell oneliner explainer. +- Backend: Azure Functions (.NET) in `explainpowershell.analysisservice/`. +- Frontend: Blazor in `explainpowershell.frontend/`. +- Shared models: `explainpowershell.models/`. +- Tests: Pester tests in `explainpowershell.analysisservice.tests/`. +- Infra: Bicep in `explainpowershell.azureinfra/`. + +## Architecture & flow +- The primary explanation is AST-based: + - `SyntaxAnalyzer` parses PowerShell into an AST and produces a list of `Explanation` nodes. +- The AI feature is additive and optional: + - The AST analysis returns immediately; the AI call is a separate endpoint invoked after the tree is available. + - Frontend triggers AI in the background so the UI remains responsive. + +## Editing guidelines (preferred behavior) +- Prefer small, surgical changes; avoid unrelated refactors. +- Preserve existing public APIs and JSON shapes unless explicitly requested. +- Keep AI functionality optional and non-blocking. + - If AI configuration is missing, the app should still work (AI can silently no-op). +- Use existing patterns in the codebase (logging, DI, options, error handling). +- Don’t add new external dependencies unless necessary and justified. + +## C# conventions +- Prefer async/await end-to-end. +- Handle nullability deliberately; avoid introducing new nullable warnings. +- Use `System.Text.Json` where the project already does; don’t mix serializers in the same flow unless required. + +## Unit tests +- Aim for high coverage on new features. +- When adding tests, follow existing patterns in `explainpowershell.analysisservice.tests/`. + +## Building +- On fresh clones, run all code generators before building: `Get-ChildItem -Path $PSScriptRoot/explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName }` + +## PowerShell / Pester conventions +- Keep tests deterministic and fast; avoid relying on external services unless explicitly an integration test. +- When adding tests, follow the existing Pester structure and naming. + +## Running locally +- For running Pester integration tests locally successfully it is necessary to run `.\bootstrap.ps1` from the repo root, it sets up the required data in Azurite, and calls code generators. +- For general debuging, running `.\bootstrap.ps1` once is also recommended. If Azurite is present and has helpldata, it is not necessary to run it again. +- You can load helper methods to test the functionapp locally by importing the following scripts in your PowerShell session: +```powershell +. C:\Users\JosKoelewijn\GitNoOneDrive\explainpowershell/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 +. C:\Users\JosKoelewijn\GitNoOneDrive\explainpowershell/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 +. C:\Users\JosKoelewijn\GitNoOneDrive\explainpowershell/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.ps1 +. C:\Users\JosKoelewijn\GitNoOneDrive\explainpowershell/explainpowershell.analysisservice.tests/Get-MetaData.ps1 +``` + +## How to validate changes +- Prefer the repo task: run the VS Code task named `run tests` (Pester). +- If you need a build check, use the VS Code `build` task. + +## Documentation +- When adding developer-facing features, also update or add a CodeTour in `.tours/` when it improves onboarding. From b136b9a3546f1b6d5776b45060d04f44ade1d17d Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 10:54:54 +0100 Subject: [PATCH 14/37] Add Invoke-AiExplanation function for AI analysis integration --- .../Invoke-AiExplanation.ps1 | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 diff --git a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 new file mode 100644 index 0000000..0659c8b --- /dev/null +++ b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 @@ -0,0 +1,52 @@ +function Invoke-AiExplanation { + param( + [Parameter(Mandatory)] + [string]$PowershellCode, + + [Parameter()] + [object]$AnalysisResult, + + [Parameter()] + [string]$BaseUri = 'http://localhost:7071/api', + + [Parameter()] + [switch]$AsObject, + + [Parameter()] + [switch]$AiExplanation + ) + + $ErrorActionPreference = 'stop' + + if (-not $AnalysisResult) { + if (Get-Command -Name Invoke-SyntaxAnalyzer -ErrorAction SilentlyContinue) { + $analysisResponse = Invoke-SyntaxAnalyzer -PowershellCode $PowershellCode + $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json + } + else { + $analysisBody = @{ PowershellCode = $PowershellCode } | ConvertTo-Json + $analysisResponse = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $analysisBody -ContentType 'application/json' + $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json + } + } + + $body = @{ + PowershellCode = $PowershellCode + AnalysisResult = $AnalysisResult + } | ConvertTo-Json -Depth 20 + + # Note: the function route is `AiExplanation`, but the Functions host is case-insensitive. + $response = Invoke-WebRequest -Uri "$BaseUri/aiexplanation" -Method Post -Body $body -ContentType 'application/json' + + if ($AsObject -or $AiExplanation) { + $result = $response.Content | ConvertFrom-Json + + if ($AiExplanation) { + return $result.AiExplanation + } + + return $result + } + + return $response +} From 94bf7342c4413e28eeae3878a83e4dd6d3b61033 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 10:54:56 +0100 Subject: [PATCH 15/37] Refactor AI Explanation integration tests to use Invoke-AiExplanation directly --- .../Invoke-AiExplanation.Tests.ps1 | 81 ++++++------------- 1 file changed, 23 insertions(+), 58 deletions(-) diff --git a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 index 6220808..18cd24e 100644 --- a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 @@ -4,6 +4,7 @@ Describe "AI Explanation Integration Tests" { BeforeAll { . $PSScriptRoot/Invoke-SyntaxAnalyzer.ps1 + . $PSScriptRoot/Invoke-AiExplanation.ps1 . $PSScriptRoot/Start-FunctionApp.ps1 . $PSScriptRoot/Test-IsAzuriteUp.ps1 @@ -70,9 +71,7 @@ Describe "AI Explanation Integration Tests" { It "Should accept valid analysis result" { # Arrange - $requestBody = @{ - PowershellCode = "Get-Process" - AnalysisResult = @{ + $analysisResult = @{ ExpandedCode = "Get-Process" ParseErrorMessage = "" Explanations = @( @@ -91,16 +90,10 @@ Describe "AI Explanation Integration Tests" { DetectedModules = @( @{ ModuleName = "Microsoft.PowerShell.Management" } ) - } - } | ConvertTo-Json -Depth 10 + } # Act & Assert - Should not throw - $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $requestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $response = Invoke-AiExplanation -PowershellCode "Get-Process" -AnalysisResult $analysisResult $response.StatusCode | Should -Be 200 $content = $response.Content | ConvertFrom-Json @@ -131,22 +124,14 @@ Describe "AI Explanation Integration Tests" { It "Should handle empty explanations list" { # Arrange - $requestBody = @{ - PowershellCode = "Get-Process" - AnalysisResult = @{ + $analysisResult = @{ ExpandedCode = "Get-Process" Explanations = @() DetectedModules = @() - } - } | ConvertTo-Json -Depth 10 + } # Act - $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $requestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $response = Invoke-AiExplanation -PowershellCode "Get-Process" -AnalysisResult $analysisResult # Assert $response.StatusCode | Should -Be 200 @@ -169,26 +154,24 @@ Describe "AI Explanation Integration Tests" { } } + $code = "Get-Process | Where-Object Name -Like 'chrome*'" + $analysisResult = @{ + ExpandedCode = $code + Explanations = $explanations + DetectedModules = @( + @{ ModuleName = "Microsoft.PowerShell.Management" } + ) + } + $requestBody = @{ - PowershellCode = "Get-Process | Where-Object Name -Like 'chrome*'" - AnalysisResult = @{ - ExpandedCode = "Get-Process | Where-Object Name -Like 'chrome*'" - Explanations = $explanations - DetectedModules = @( - @{ ModuleName = "Microsoft.PowerShell.Management" } - ) - } + PowershellCode = $code + AnalysisResult = $analysisResult } | ConvertTo-Json -Depth 10 Write-Host "Payload size: $($requestBody.Length) bytes" # Act - Should handle payload reduction gracefully - $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $requestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $response = Invoke-AiExplanation -PowershellCode $code -AnalysisResult $analysisResult # Assert $response.StatusCode | Should -Be 200 @@ -198,9 +181,7 @@ Describe "AI Explanation Integration Tests" { It "Should return model name in response" { # Arrange - $requestBody = @{ - PowershellCode = "gps" - AnalysisResult = @{ + $analysisResult = @{ ExpandedCode = "Get-Process" Explanations = @( @{ @@ -209,16 +190,10 @@ Describe "AI Explanation Integration Tests" { Description = "Gets processes" } ) - } - } | ConvertTo-Json -Depth 10 + } # Act - $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $requestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $response = Invoke-AiExplanation -PowershellCode "gps" -AnalysisResult $analysisResult # Assert $content = $response.Content | ConvertFrom-Json @@ -302,17 +277,7 @@ Describe "AI Explanation Integration Tests" { $analysisResult = $analysisResponse.Content | ConvertFrom-Json # Act - Then request AI explanation - $aiRequestBody = @{ - PowershellCode = $code - AnalysisResult = $analysisResult - } | ConvertTo-Json -Depth 10 - - $aiResponse = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $aiRequestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $aiResponse = Invoke-AiExplanation -PowershellCode $code -AnalysisResult $analysisResult # Assert $aiResponse.StatusCode | Should -Be 200 From 86d250ebe976acd698eb24c0957abed2745acddf Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 11:40:47 +0100 Subject: [PATCH 16/37] Fix links in README for explainshell.com and CodeTour extension --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 86ffaf2..1f40ceb 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # Explain PowerShell -PowerShell version of [explainshell.com](explainshell.com) +PowerShell version of [explainshell.com](https://explainshell.com) On ExplainShell.com, you can enter a Linux terminal oneliner, and the site will analyze it, and return snippets from the proper man-pages, in an effort to explain the oneliner. I have created a similar thing but for PowerShell here: https://www.explainpowershell.com -If you'd like a tour of this repo, open the repo in VSCode (from here with the '.' key), and install the [CodeTour](vsls-contrib.codetour) extension. In the Explorer View, you will now see CodeTour all the way at the bottom left. There currently are four code tours available: -- High level tour of the application -- Tour of development container -- Tour of the Azure bootstrapper -- Tour of the help collector +If you'd like a tour of this repo, open the repo in VS Code (from here with the '.' key), and install the [CodeTour](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.codetour) extension. In the Explorer View, you will now see CodeTour all the way at the bottom left. There currently are four code tours available: +- [High level tour of the application](.tours/high-level-tour-of-the-application.tour) +- [Tour of the AI explanation feature](.tours/tour-of-the-ai-explanation-feature.tour) +- [Tour of the Azure bootstrapper](.tours/tour-of-the-azure-bootstrapper.tour) +- [Tour of the help collector](.tours/tour-of-the-help-collector.tour) ## Goal From e105b03fee0181114eee9c52b58d24699b7e0fe4 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 12:04:18 +0100 Subject: [PATCH 17/37] Replace Newtonsoft.Json with System.Text.Json for request deserialization --- .../SyntaxAnalyzer.cs | 20 +++++++++++++++---- .../explainpowershell.csproj | 1 - 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/explainpowershell.analysisservice/SyntaxAnalyzer.cs b/explainpowershell.analysisservice/SyntaxAnalyzer.cs index b03f15c..b38d6d5 100644 --- a/explainpowershell.analysisservice/SyntaxAnalyzer.cs +++ b/explainpowershell.analysisservice/SyntaxAnalyzer.cs @@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; +using System.Text.Json; namespace ExplainPowershell.SyntaxAnalyzer { @@ -40,9 +40,21 @@ public async Task Run( return CreateResponse(req, HttpStatusCode.BadRequest, "Empty request. Pass powershell code in the request body for an AST analysis."); } - var code = JsonConvert - .DeserializeObject(requestBody) - ?.PowershellCode ?? string.Empty; + Code? request; + try + { + request = JsonSerializer.Deserialize(requestBody, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch (Exception e) + { + logger.LogError(e, "Failed to deserialize SyntaxAnalyzer request"); + return CreateResponse(req, HttpStatusCode.BadRequest, "Invalid request format. Pass powershell code in the request body for an AST analysis."); + } + + var code = request?.PowershellCode ?? string.Empty; logger.LogInformation("PowerShell code sent: {Code}", code); diff --git a/explainpowershell.analysisservice/explainpowershell.csproj b/explainpowershell.analysisservice/explainpowershell.csproj index 0944cb7..029f3cf 100644 --- a/explainpowershell.analysisservice/explainpowershell.csproj +++ b/explainpowershell.analysisservice/explainpowershell.csproj @@ -13,7 +13,6 @@ - From 2179e1d02031bf43db63060c2a721332b5084ab2 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 12:12:49 +0100 Subject: [PATCH 18/37] Log unhandled AST node types and their counts in GetAnalysisResult --- .../AstVisitorExplainer_helpers.cs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs index ff32623..5cc669c 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs @@ -24,6 +24,7 @@ public partial class AstVisitorExplainer : AstVisitor2 private readonly IHelpRepository helpRepository; private readonly ILogger log; private readonly Token[]? tokens; + private readonly Dictionary unhandledAstTypeCounts = new(StringComparer.OrdinalIgnoreCase); public AnalysisResult GetAnalysisResult() { @@ -31,6 +32,31 @@ public AnalysisResult GetAnalysisResult() ExplainSemiColons(); + if (unhandledAstTypeCounts.Count > 0) + { + var totalUnhandled = unhandledAstTypeCounts.Values.Sum(); + var ordered = unhandledAstTypeCounts + .OrderByDescending(kvp => kvp.Value) + .ThenBy(kvp => kvp.Key) + .ToList(); + + const int maxTypesToLog = 10; + var topTypes = string.Join(", ", + ordered + .Take(maxTypesToLog) + .Select(kvp => $"{kvp.Key}({kvp.Value})")); + + var extraTypes = ordered.Count > maxTypesToLog + ? $" (+{ordered.Count - maxTypesToLog} more types)" + : string.Empty; + + log.LogInformation( + "Unhandled AST nodes encountered: {UnhandledCount}. Types: {UnhandledTypes}{ExtraTypes}", + totalUnhandled, + topTypes, + extraTypes); + } + foreach (var exp in explanations) { if (exp.HelpResult == null) @@ -146,7 +172,8 @@ private void AstExplainer(Ast ast) CommandName = splitAstType }.AddDefaults(ast, explanations)); - log.LogWarning($"Unhandled ast: {splitAstType}"); + unhandledAstTypeCounts.TryGetValue(splitAstType, out var current); + unhandledAstTypeCounts[splitAstType] = current + 1; } public static List GetApprovedVerbs() From ce8b3afe9401c95d3134b2eb04ec22f0401b75ec Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 12:23:57 +0100 Subject: [PATCH 19/37] Implement cancellation and cleanup for AI explanation requests to prevent race condition. --- .../Pages/Index.razor.cs | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/explainpowershell.frontend/Pages/Index.razor.cs b/explainpowershell.frontend/Pages/Index.razor.cs index 6074cdd..f781065 100644 --- a/explainpowershell.frontend/Pages/Index.razor.cs +++ b/explainpowershell.frontend/Pages/Index.razor.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; +using System.Threading; using explainpowershell.models; using System.Linq; @@ -29,6 +30,10 @@ public partial class Index : ComponentBase { private List> TreeItems { get; set; } = new(); private bool ShouldShrinkTitle { get; set; } = false; private bool HasNoExplanations => TreeItems.Count == 0; + + private long _activeSearchId; + private CancellationTokenSource _aiExplanationCts; + private bool _disposed; private string InputValue { get { return _inputValue; @@ -56,6 +61,20 @@ protected override Task OnInitializedAsync() return DoSearch(); } + public void Dispose() + { + _disposed = true; + try + { + _aiExplanationCts?.Cancel(); + _aiExplanationCts?.Dispose(); + } + catch + { + // Best effort cleanup. + } + } + private void ToggleSyntaxPopoverIsOpen(string id) { SyntaxPopoverIsOpen[id] = !SyntaxPopoverIsOpen[id]; @@ -75,6 +94,14 @@ private void ShrinkTitle() private async Task DoSearch() { + // New search: cancel any in-flight AI request and advance request id. + Interlocked.Increment(ref _activeSearchId); + _aiExplanationCts?.Cancel(); + _aiExplanationCts?.Dispose(); + _aiExplanationCts = new CancellationTokenSource(); + var searchId = _activeSearchId; + var aiCancellationToken = _aiExplanationCts.Token; + HideExpandedCode = true; Waiting = false; RequestHasError = false; @@ -139,13 +166,19 @@ private async Task DoSearch() AiExplanation = null; // Will be loaded separately // Start fetching AI explanation in background - _ = LoadAiExplanationAsync(code, analysisResult); + _ = LoadAiExplanationAsync(code, analysisResult, searchId, aiCancellationToken); } - private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResult) + private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResult, long searchId, CancellationToken cancellationToken) { + // Ignore stale requests. + if (searchId != _activeSearchId || _disposed) + { + return; + } + AiExplanationLoading = true; - StateHasChanged(); + await InvokeAsync(StateHasChanged); try { @@ -155,11 +188,16 @@ private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResu AnalysisResult = analysisResult }; - var response = await Http.PostAsJsonAsync("AiExplanation", aiRequest); + var response = await Http.PostAsJsonAsync("AiExplanation", aiRequest, cancellationToken); + + if (searchId != _activeSearchId || _disposed) + { + return; + } if (response.IsSuccessStatusCode) { - var aiResult = await JsonSerializer.DeserializeAsync(response.Content.ReadAsStream()); + var aiResult = await JsonSerializer.DeserializeAsync(response.Content.ReadAsStream(), cancellationToken: cancellationToken); AiExplanation = aiResult?.AiExplanation ?? string.Empty; AiModelName = aiResult?.ModelName ?? string.Empty; } @@ -170,6 +208,10 @@ private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResu AiModelName = string.Empty; } } + catch (OperationCanceledException) + { + // Expected when a newer search cancels the in-flight AI request. + } catch (Exception ex) { // Silently fail (log only) - AI explanation is optional @@ -180,8 +222,11 @@ private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResu } finally { - AiExplanationLoading = false; - StateHasChanged(); + if (searchId == _activeSearchId && !_disposed) + { + AiExplanationLoading = false; + await InvokeAsync(StateHasChanged); + } } } From be8f39b024f33082e3b758cb9868da7ab74f2ecc Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 12:32:19 +0100 Subject: [PATCH 20/37] Add in-memory help repository seeding for unit tests --- .../helpers/TestHelpData.cs | 41 +++++++++++++++++++ .../tests/AstVisitorExplainer.tests.cs | 11 +++-- .../AstVisitorExplainer_command.tests.cs | 7 +--- 3 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 explainpowershell.analysisservice.tests/helpers/TestHelpData.cs diff --git a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs new file mode 100644 index 0000000..63a680a --- /dev/null +++ b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs @@ -0,0 +1,41 @@ +using explainpowershell.models; + +namespace ExplainPowershell.SyntaxAnalyzer.Tests +{ + internal static class TestHelpData + { + public static void SeedAboutTopics(InMemoryHelpRepository repository) + { + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Classes", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Classes" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Foreach", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_For", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Remote_Variables", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", + RelatedLinks = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Scopes", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Scopes" + }); + } + } +} diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs index 603482b..ea58fe0 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs @@ -6,8 +6,6 @@ using System.Management.Automation.Language; using System.Text.Json; using System.Threading.Tasks; - -using Azure.Data.Tables; using explainpowershell.models; using NUnit.Framework; @@ -21,10 +19,11 @@ public class GetAstVisitorExplainerTests public void Setup() { var mockILogger = new LoggerDouble(); - var tableClient = new TableClient( - "AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;DefaultEndpointsProtocol=http;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;", - "HelpData"); - var helpRepository = new ExplainPowershell.SyntaxAnalyzer.Repositories.TableStorageHelpRepository(tableClient); + + // Unit tests should not depend on Azurite/Table Storage. Seed only the help topics + // required by these assertions. + var helpRepository = new InMemoryHelpRepository(); + TestHelpData.SeedAboutTopics(helpRepository); explainer = new( extentText: string.Empty, diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs index fe024ed..9fd9ad2 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs @@ -6,8 +6,6 @@ using System.Management.Automation.Language; using System.Text.Json; using System.Threading.Tasks; - -using Azure.Data.Tables; using explainpowershell.models; using NUnit.Framework; @@ -21,10 +19,7 @@ public class GetAstVisitorExplainer_commandTests public void Setup() { var mockILogger = new LoggerDouble(); - var tableClient = new TableClient( - "AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;DefaultEndpointsProtocol=http;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;", - "HelpData"); - var helpRepository = new ExplainPowershell.SyntaxAnalyzer.Repositories.TableStorageHelpRepository(tableClient); + var helpRepository = new InMemoryHelpRepository(); explainer = new( extentText: string.Empty, From 8ee37a9eca791d4fd5bfaeba2f816cfb9c791c25 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 13:05:37 +0100 Subject: [PATCH 21/37] Enhance unit tests for return and throw statements, adding behavior verification and documentation links --- .github/copilot-instructions.md | 2 ++ .../Invoke-SyntaxAnalyzer.Tests.ps1 | 28 +++++++++++++++++ .../helpers/TestHelpData.cs | 18 +++++++++++ .../tests/AstVisitorExplainer.tests.cs | 30 ++++++++++++++++++- 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2988896..92c7bcb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -32,6 +32,7 @@ These instructions are for GitHub Copilot Chat/Edits when working in this reposi ## Unit tests - Aim for high coverage on new features. +- Focus on behavior verification over implementation details. - When adding tests, follow existing patterns in `explainpowershell.analysisservice.tests/`. ## Building @@ -40,6 +41,7 @@ These instructions are for GitHub Copilot Chat/Edits when working in this reposi ## PowerShell / Pester conventions - Keep tests deterministic and fast; avoid relying on external services unless explicitly an integration test. - When adding tests, follow the existing Pester structure and naming. +- Before adding Pester tests, consider if the behavior can be verified in C# unit tests first. ## Running locally - For running Pester integration tests locally successfully it is necessary to run `.\bootstrap.ps1` from the repo root, it sets up the required data in Azurite, and calls code generators. diff --git a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 index 9e97969..3b12e35 100644 --- a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 @@ -15,6 +15,34 @@ Describe "Invoke-SyntaxAnalyzer" { $content.Explanations[0].HelpResult.DocumentationLink | Should -Match "about_Classes" } + It "Explains return statement" { + $explanations = Invoke-SyntaxAnalyzer -PowershellCode "return 42" -Explanations + $returnExplanation = $explanations | Where-Object { $_.TextToHighlight -eq 'return' } | Select-Object -First 1 + + $returnExplanation | Should -Not -BeNullOrEmpty + $returnExplanation.CommandName | Should -Be 'return statement' + $returnLink = $returnExplanation.HelpResult.DocumentationLink + $returnLink | Should -Not -BeNullOrEmpty + $returnLink | Should -Match 'about_Return|#return' + if ($returnLink -match 'about_Return') { + $returnExplanation.HelpResult.RelatedLinks | Should -Match '#return' + } + } + + It "Explains throw statement" { + $explanations = Invoke-SyntaxAnalyzer -PowershellCode "throw 'boom'" -Explanations + $throwExplanation = $explanations | Where-Object { $_.TextToHighlight -eq 'throw' } | Select-Object -First 1 + + $throwExplanation | Should -Not -BeNullOrEmpty + $throwExplanation.CommandName | Should -Be 'throw statement' + $throwLink = $throwExplanation.HelpResult.DocumentationLink + $throwLink | Should -Not -BeNullOrEmpty + $throwLink | Should -Match 'about_Throw|#throw' + if ($throwLink -match 'about_Throw') { + $throwExplanation.HelpResult.RelatedLinks | Should -Match '#throw' + } + } + It "Should display correct help for assigment operators" { $code = '$D=[Datetime]::Now' [BasicHtmlWebResponseObject]$result = Invoke-SyntaxAnalyzer -PowerShellCode $code diff --git a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs index 63a680a..f11752c 100644 --- a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs +++ b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs @@ -36,6 +36,24 @@ public static void SeedAboutTopics(InMemoryHelpRepository repository) CommandName = "about_Scopes", DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Scopes" }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Return", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Throw", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_language_keywords", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords" + }); } } } diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs index ea58fe0..7f4e42f 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs @@ -23,7 +23,7 @@ public void Setup() // Unit tests should not depend on Azurite/Table Storage. Seed only the help topics // required by these assertions. var helpRepository = new InMemoryHelpRepository(); - TestHelpData.SeedAboutTopics(helpRepository); + TestHelpData.SeedAboutTopics(helpRepository); explainer = new( extentText: string.Empty, @@ -101,5 +101,33 @@ public void ShoudGenerateHelpForForStatements() "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For", res.Explanations[0].HelpResult?.DocumentationLink); } + + [Test] + public void ShouldGenerateHelpForReturnStatement() + { + ScriptBlock.Create("return 42").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + var explanation = res.Explanations.SingleOrDefault(e => e.TextToHighlight == "return"); + + Assert.That(explanation, Is.Not.Null); + Assert.That(explanation.CommandName, Is.EqualTo("return statement")); + Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Return")); + Assert.That(explanation.HelpResult?.RelatedLinks, Does.Contain("about_language_keywords").And.Contain("#return")); + } + + [Test] + public void ShouldGenerateHelpForThrowStatement() + { + ScriptBlock.Create("throw 'boom'").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + var explanation = res.Explanations.SingleOrDefault(e => e.TextToHighlight == "throw"); + + Assert.That(explanation, Is.Not.Null); + Assert.That(explanation.CommandName, Is.EqualTo("throw statement")); + Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Throw")); + Assert.That(explanation.HelpResult?.RelatedLinks, Does.Contain("about_language_keywords").And.Contain("#throw")); + } } } \ No newline at end of file From 2bd23523cf1a75132418b2b5d6a2e94939a8a5c6 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 13:05:55 +0100 Subject: [PATCH 22/37] Add explanations for return and throw statements with documentation links --- .../AstVisitorExplainer_statements.cs | 76 ++++++++++++++++++- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs index 2446d40..40474ea 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs @@ -191,8 +191,42 @@ public override AstVisitAction VisitIfStatement(IfStatementAst ifStmtAst) public override AstVisitAction VisitReturnStatement(ReturnStatementAst returnStatementAst) { - // TODO: add return statement explanation - AstExplainer(returnStatementAst); + var returnedValue = string.IsNullOrEmpty(returnStatementAst.Pipeline?.Extent?.Text) + ? string.Empty + : $" returning '{returnStatementAst.Pipeline.Extent.Text}'."; + + var helpResult = HelpTableQuery("about_Return") + ?? new HelpEntity + { + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" + }; + + if (string.IsNullOrEmpty(helpResult.DocumentationLink)) + { + helpResult.DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return"; + } + + var languageKeywordsLink = (HelpTableQuery("about_language_keywords")?.DocumentationLink + ?? "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#return"; + + if (string.IsNullOrEmpty(helpResult.RelatedLinks)) + { + helpResult.RelatedLinks = languageKeywordsLink; + } + else if (!helpResult.RelatedLinks.Contains(languageKeywordsLink, StringComparison.OrdinalIgnoreCase)) + { + helpResult.RelatedLinks += ", " + languageKeywordsLink; + } + + explanations.Add( + new Explanation() + { + CommandName = "return statement", + HelpResult = helpResult, + Description = $"Returns from the current scope{returnedValue}", + TextToHighlight = "return" + }.AddDefaults(returnStatementAst, explanations)); + return base.VisitReturnStatement(returnStatementAst); } @@ -205,8 +239,42 @@ public override AstVisitAction VisitSwitchStatement(SwitchStatementAst switchSta public override AstVisitAction VisitThrowStatement(ThrowStatementAst throwStatementAst) { - // TODO: add throw statement explanation - AstExplainer(throwStatementAst); + var thrownValue = string.IsNullOrEmpty(throwStatementAst.Pipeline?.Extent?.Text) + ? string.Empty + : $" with value '{throwStatementAst.Pipeline.Extent.Text}'."; + + var helpResult = HelpTableQuery("about_Throw") + ?? new HelpEntity + { + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" + }; + + if (string.IsNullOrEmpty(helpResult.DocumentationLink)) + { + helpResult.DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw"; + } + + var languageKeywordsLink = (HelpTableQuery("about_language_keywords")?.DocumentationLink + ?? "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#throw"; + + if (string.IsNullOrEmpty(helpResult.RelatedLinks)) + { + helpResult.RelatedLinks = languageKeywordsLink; + } + else if (!helpResult.RelatedLinks.Contains(languageKeywordsLink, StringComparison.OrdinalIgnoreCase)) + { + helpResult.RelatedLinks += ", " + languageKeywordsLink; + } + + explanations.Add( + new Explanation() + { + CommandName = "throw statement", + HelpResult = helpResult, + Description = $"Throws a terminating error (exception){thrownValue}", + TextToHighlight = "throw" + }.AddDefaults(throwStatementAst, explanations)); + return base.VisitThrowStatement(throwStatementAst); } From 0c3d2c2f358233e160bc56754eb15ad2647a5596 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 14:01:01 +0100 Subject: [PATCH 23/37] Add HTML synopsis scraper and related functions for documentation extraction --- .github/copilot-instructions.md | 3 + .../HelpCollectorHtmlScraper.Tests.ps1 | 54 ++++++ .../HelpCollector.Functions.ps1 | 169 ++++++++++++++++++ .../helpcollector.ps1 | 76 +------- 4 files changed, 227 insertions(+), 75 deletions(-) create mode 100644 explainpowershell.analysisservice.tests/HelpCollectorHtmlScraper.Tests.ps1 create mode 100644 explainpowershell.helpcollector/HelpCollector.Functions.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 92c7bcb..739bb9c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -18,6 +18,9 @@ These instructions are for GitHub Copilot Chat/Edits when working in this reposi - Frontend triggers AI in the background so the UI remains responsive. ## Editing guidelines (preferred behavior) +- Keep in mind this project is open source and intended to be cross platform. +- Follow existing code style and patterns. +- Favor readability and maintainability. - Prefer small, surgical changes; avoid unrelated refactors. - Preserve existing public APIs and JSON shapes unless explicitly requested. - Keep AI functionality optional and non-blocking. diff --git a/explainpowershell.analysisservice.tests/HelpCollectorHtmlScraper.Tests.ps1 b/explainpowershell.analysisservice.tests/HelpCollectorHtmlScraper.Tests.ps1 new file mode 100644 index 0000000..807ace5 --- /dev/null +++ b/explainpowershell.analysisservice.tests/HelpCollectorHtmlScraper.Tests.ps1 @@ -0,0 +1,54 @@ +Describe "HelpCollector HTML synopsis scraper" { + BeforeAll { + . "$PSScriptRoot/../explainpowershell.helpcollector/HelpCollector.Functions.ps1" + } + + It "Normalizes docs.microsoft.com to learn.microsoft.com and forces https" { + ConvertTo-LearnDocumentationUri -Uri 'http://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_return' | + Should -Be 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_return' + } + + It "Extracts synopsis from summary paragraph" { + $html = @' + + +t + +

about_Return

+

Returns from the current scope.

+ + +'@ + + Get-SynopsisFromHtml -Html $html -Cmd 'return' | Should -Be 'Returns from the current scope.' + } + + It "Extracts synopsis from meta description" { + $html = @' + + + + + +

about_Throw

+ + +'@ + + Get-SynopsisFromHtml -Html $html -Cmd 'throw' | Should -Be 'Throws a terminating error.' + } + + It "Extracts keyword synopsis from about_language_keywords section" { + $html = @' + + +

about_Language_Keywords

+

throw

+

Throws an exception.

+ + +'@ + + Get-SynopsisFromHtml -Html $html -Cmd 'throw' | Should -Be 'Throws an exception.' + } +} diff --git a/explainpowershell.helpcollector/HelpCollector.Functions.ps1 b/explainpowershell.helpcollector/HelpCollector.Functions.ps1 new file mode 100644 index 0000000..723a62c --- /dev/null +++ b/explainpowershell.helpcollector/HelpCollector.Functions.ps1 @@ -0,0 +1,169 @@ +function ConvertTo-LearnDocumentationUri { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Uri + ) + + $normalized = $Uri.Trim() + if ([string]::IsNullOrWhiteSpace($normalized)) { + return $Uri + } + + # Prefer HTTPS + $normalized = $normalized -replace '^http://', 'https://' + + # docs.microsoft.com redirects to learn.microsoft.com; normalize for consistency. + $normalized = $normalized -replace '^https://docs\.microsoft\.com', 'https://learn.microsoft.com' + + return $normalized +} + +function Get-TitleFromHtml { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Html + ) + + $m = [regex]::Match($Html, ']*>(.*?)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline) + if ($m.Success) { + return ([System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) -replace '<[^>]+>', '').Trim() + } + + return $null +} + +function Get-SynopsisFromHtml { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Html, + + [Parameter()] + [string]$Cmd + ) + + if ([string]::IsNullOrWhiteSpace($Html)) { + return $null + } + + $regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline + + # 1) Learn pages usually expose a meta description. + $meta = [regex]::Match($Html, '', $regexOptions) + if ($meta.Success) { + return ([System.Net.WebUtility]::HtmlDecode($meta.Groups[1].Value)).Trim() + } + + # 2) Learn pages often have a summary paragraph. + $summary = [regex]::Match($Html, ']*class=["\'']summary["\''][^>]*>(.*?)

', $regexOptions) + if ($summary.Success) { + $text = $summary.Groups[1].Value + $text = [System.Net.WebUtility]::HtmlDecode($text) + $text = ($text -replace '<[^>]+>', '').Trim() + if (-not [string]::IsNullOrWhiteSpace($text)) { + return $text + } + } + + # 3) About_language_keywords sometimes has a per-keyword section. + if (-not [string]::IsNullOrWhiteSpace($Cmd)) { + $escapedCmd = [regex]::Escape($Cmd) + $pattern = ']*id=[''"]' + $escapedCmd + '[''"][^>]*>.*?\s*]*>(.*?)

' + $section = [regex]::Match($Html, $pattern, $regexOptions) + if ($section.Success) { + $text = $section.Groups[1].Value + $text = [System.Net.WebUtility]::HtmlDecode($text) + $text = ($text -replace '<[^>]+>', '').Trim() + if (-not [string]::IsNullOrWhiteSpace($text)) { + return $text + } + } + } + + return $null +} + +function Get-SynopsisFromUri { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Uri, + + [Parameter()] + [string]$Cmd + ) + + $normalizedUri = ConvertTo-LearnDocumentationUri -Uri $Uri + + try { + # Use Invoke-WebRequest to reliably get HTML content; IRM sometimes tries to parse. + $response = Invoke-WebRequest -Uri $normalizedUri -ErrorAction Stop + $html = $response.Content + + $synopsis = Get-SynopsisFromHtml -Html $html -Cmd $Cmd + if (-not [string]::IsNullOrWhiteSpace($synopsis)) { + return @($true, $synopsis) + } + + $title = Get-TitleFromHtml -Html $html + return @($false, $title) + } + catch { + return @($false, $null) + } +} + +function Get-Synopsis { + param( + $Help, + $Cmd, + $DocumentationLink, + $description + ) + + if ($null -eq $help) { + return @($null, $null) + } + + $synopsis = $help.Synopsis.Trim() + + if ($synopsis -like '') { + Write-Verbose "$($cmd.name) - Empty synopsis, trying to get synopsis from description." + $description = $help.description.Text + if ([string]::IsNullOrEmpty($description)) { + Write-Verbose "$($cmd.name) - Empty description." + } + else { + $synopsis = $description.Trim().Split('.')[0].Trim() + } + } + + if ($synopsis -match "^$($cmd.Name) .*[-\[\]<>]" -or $synopsis -like '') { + # If synopsis starts with the name of the verb, it's not a synopsis. + $synopsis = $null + + if ([string]::IsNullOrEmpty($DocumentationLink) -or $DocumentationLink -in $script:badUrls) { + } + else { + Write-Verbose "$($cmd.name) - Trying to get missing synopsis from Uri" + $success, $synopsis = Get-SynopsisFromUri -Uri $DocumentationLink -Cmd $cmd.Name -verbose:$false + + if ($null -eq $synopsis -or -not $success) { + if ($synopsis -notmatch "^$($cmd.Name) .*[-\[\]<>]") { + Write-Warning "!!$($cmd.name) - Bad online help uri, '$DocumentationLink' is about '$synopsis'" + $script:badUrls += $DocumentationLink + $DocumentationLink = $null + $synopsis = $null + } + } + } + } + + if ($null -ne $synopsis -and $synopsis -match "^$($cmd.Name) .*[-\[\]<>]") { + $synopsis = $null + } + + return @($synopsis, $DocumentationLink) +} diff --git a/explainpowershell.helpcollector/helpcollector.ps1 b/explainpowershell.helpcollector/helpcollector.ps1 index 2ab7710..4594a55 100644 --- a/explainpowershell.helpcollector/helpcollector.ps1 +++ b/explainpowershell.helpcollector/helpcollector.ps1 @@ -4,81 +4,7 @@ param( $ModulesToProcess ) -#region functions -function Get-SynopsisFromUri { - [CmdletBinding()] - param( - $uri, - $cmd - ) - - try { - $html = (Invoke-RestMethod $uri).Trim().Split("`n").split('(.*)' -Context 1 - if ($null -eq $temp) { - $temp = $html | Select-String "h2 id=`"$cmd" -Context 1 - } - return @($true, $temp.Context.PostContext.Trim() -replace '

|

', '') - } - catch { - return @($false, $title) - } -} - -function Get-Synopsis { - param( - $Help, - $Cmd, - $DocumentationLink, - $description - ) - - if ($null -eq $help) { - return @($null, $null) - } - - $synopsis = $help.Synopsis.Trim() - - if ($synopsis -like '') { - Write-Verbose "$($cmd.name) - Empty synopsis, trying to get synopsis from description." - $description = $help.description.Text - if ([string]::IsNullOrEmpty($description)) { - Write-Verbose "$($cmd.name) - Empty description." - } - else { - $synopsis = $description.Trim().Split('.')[0].Trim() - } - } - - if ($synopsis -match "^$($cmd.Name) .*[-\[\]<>]" -or $synopsis -like '') { - # If synopsis starts with the name of the verb, it's not a synopsis. - $synopsis = $null - - if ([string]::IsNullOrEmpty($DocumentationLink) -or $DocumentationLink -in $script:badUrls) { - } - else { - Write-Verbose "$($cmd.name) - Trying to get missing synopsis from Uri" - $succes, $synopsis = Get-SynopsisFromUri $DocumentationLink -cmd $cmd.Name -verbose:$false - - if ($null -eq $synopsis -or -not $success) { - if ($synopsis -notmatch "^$($cmd.Name) .*[-\[\]<>]") { - Write-Warning "!!$($cmd.name) - Bad online help uri, '$DocumentationLink' is about '$synopsis'" - $script:badUrls += $DocumentationLink - $DocumentationLink = $null - $synopsis = $null - } - } - } - } - - if ($null -ne $synopsis -and $synopsis -match "^$($cmd.Name) .*[-\[\]<>]") { - $synopsis = $null - } - - return @($synopsis, $DocumentationLink) -} -#endregion functions +. $PSScriptRoot/HelpCollector.Functions.ps1 $ModulesToProcess = $ModulesToProcess | Sort-Object -Unique -Property Name From df46893d7c7b63d31493a3e8cf39673dda32c703 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 14:16:24 +0100 Subject: [PATCH 24/37] Refactor logical operators for consistency in AstVisitorExplainer --- .../AstVisitorExplainer_expressions.cs | 4 ++-- .../AstVisitorExplainer_general.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs index 9317c22..967d7a6 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs @@ -302,7 +302,7 @@ public override AstVisitAction VisitVariableExpression(VariableExpressionAst var } } - if (varName == "_" | string.Equals(varName, "PSItem", StringComparison.OrdinalIgnoreCase)) + if (varName == "_" || string.Equals(varName, "PSItem", StringComparison.OrdinalIgnoreCase)) { suffix = ", a built-in variable that holds the current element from the objects being passed in from the pipeline."; explanation.CommandName = "Pipeline iterator variable"; @@ -330,7 +330,7 @@ public override AstVisitAction VisitVariableExpression(VariableExpressionAst var varName = split.LastOrDefault(); standard = $"named '{varName}'"; - if (variableExpressionAst.VariablePath.IsGlobal | variableExpressionAst.VariablePath.IsScript) + if (variableExpressionAst.VariablePath.IsGlobal || variableExpressionAst.VariablePath.IsScript) { suffix = $" in '{identifier}' scope "; explanation.CommandName = "Scoped variable"; diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_general.cs b/explainpowershell.analysisservice/AstVisitorExplainer_general.cs index c571fe0..243b48d 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_general.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_general.cs @@ -147,7 +147,7 @@ public override AstVisitAction VisitScriptBlock(ScriptBlockAst scriptBlockAst) public override AstVisitAction VisitStatementBlock(StatementBlockAst statementBlockAst) { - if (statementBlockAst.Parent is TryStatementAst & + if (statementBlockAst.Parent is TryStatementAst && // Ugly hack. Finally block is undistinguisable from the Try block, except for textual position. statementBlockAst.Extent.StartColumnNumber > statementBlockAst.Parent.Extent.StartColumnNumber + 5) { From 4d9f7e6e7a767b811498c5989b5c5f99e4b9764e Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 14:28:44 +0100 Subject: [PATCH 25/37] Add trap statement explanation and related tests --- .../Invoke-SyntaxAnalyzer.Tests.ps1 | 9 +++++++ .../helpers/TestHelpData.cs | 6 +++++ .../tests/AstVisitorExplainer.tests.cs | 14 +++++++++++ .../AstVisitorExplainer_statements.cs | 25 +++++++++++++++++-- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 index 3b12e35..a3607f2 100644 --- a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 @@ -43,6 +43,15 @@ Describe "Invoke-SyntaxAnalyzer" { } } + It "Explains trap statement" { + $explanations = Invoke-SyntaxAnalyzer -PowershellCode "trap { continue }" -Explanations + $trapExplanation = $explanations | Where-Object { $_.TextToHighlight -eq 'trap' } | Select-Object -First 1 + + $trapExplanation | Should -Not -BeNullOrEmpty + $trapExplanation.CommandName | Should -Be 'trap statement' + $trapExplanation.HelpResult.DocumentationLink | Should -Match 'about_Trap' + } + It "Should display correct help for assigment operators" { $code = '$D=[Datetime]::Now' [BasicHtmlWebResponseObject]$result = Invoke-SyntaxAnalyzer -PowerShellCode $code diff --git a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs index f11752c..cc0bd75 100644 --- a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs +++ b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs @@ -54,6 +54,12 @@ public static void SeedAboutTopics(InMemoryHelpRepository repository) CommandName = "about_language_keywords", DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords" }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_trap", + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" + }); } } } diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs index 7f4e42f..9ebe58a 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs @@ -129,5 +129,19 @@ public void ShouldGenerateHelpForThrowStatement() Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Throw")); Assert.That(explanation.HelpResult?.RelatedLinks, Does.Contain("about_language_keywords").And.Contain("#throw")); } + + [Test] + public void ShouldGenerateHelpForTrapStatement() + { + ScriptBlock.Create("trap { continue }").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + var explanation = res.Explanations.SingleOrDefault(e => e.TextToHighlight == "trap"); + + Assert.That(explanation, Is.Not.Null); + Assert.That(explanation.CommandName, Is.EqualTo("trap statement")); + Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Trap")); + Assert.That(explanation.Description, Does.Contain("trap handler")); + } } } \ No newline at end of file diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs index 40474ea..bc747b0 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs @@ -280,8 +280,29 @@ public override AstVisitAction VisitThrowStatement(ThrowStatementAst throwStatem public override AstVisitAction VisitTrap(TrapStatementAst trapStatementAst) { - // TODO: add trap explanation - AstExplainer(trapStatementAst); + var trapTypeText = trapStatementAst.TrapType == null + ? "any error" + : $"errors of type '{trapStatementAst.TrapType.TypeName.Name}'"; + + var helpResult = HelpTableQuery("about_trap") + ?? new HelpEntity + { + DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" + }; + + if (string.IsNullOrEmpty(helpResult.DocumentationLink)) + { + helpResult.DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap"; + } + + explanations.Add(new Explanation() + { + CommandName = "trap statement", + HelpResult = helpResult, + Description = $"Defines a trap handler that runs when {trapTypeText} occurs in the current scope.", + TextToHighlight = "trap" + }.AddDefaults(trapStatementAst, explanations)); + return base.VisitTrap(trapStatementAst); } From 117da9f22809e26da2c9eb7ca39a41a03470e631 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 14:40:11 +0100 Subject: [PATCH 26/37] Update documentation links to use the new Microsoft Learn URLs --- .../Get-HelpDatabaseData.Tests.ps1 | 2 +- .../helpers/TestHelpData.cs | 22 ++++++++++--------- .../testfiles/test_get_help.json | 6 ++--- .../tests/AstVisitorExplainer.tests.cs | 6 ++--- .../AstVisitorExplainer_statements.cs | 16 +++++++------- .../Constants.cs | 2 +- .../New-SasToken.ps1 | 2 +- .../aboutcollector.ps1 | 2 +- .../defaultModules.json | 14 ++++++------ research/Ast.txt | 2 +- 10 files changed, 38 insertions(+), 36 deletions(-) diff --git a/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 b/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 index 299d857..ceae3e2 100644 --- a/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 @@ -8,7 +8,7 @@ Describe 'Get-HelpDatabaseData' { $data = Get-HelpDatabaseData -RowKey 'about_pwsh' $data.Properties.CommandName | Should -BeExactly 'about_Pwsh' - $data.Properties.DocumentationLink | Should -Match 'https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Pwsh' + $data.Properties.DocumentationLink | Should -Match 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Pwsh' $data.Properties.ModuleName | Should -BeNullOrEmpty $data.Properties.Synopsis | Should -BeExactly 'Explains how to use the pwsh command-line interface. Displays the command-line parameters and describes the syntax.' } diff --git a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs index cc0bd75..1b1da2f 100644 --- a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs +++ b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs @@ -9,56 +9,58 @@ public static void SeedAboutTopics(InMemoryHelpRepository repository) repository.AddHelpEntity(new HelpEntity { CommandName = "about_Classes", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Classes" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Classes" }); repository.AddHelpEntity(new HelpEntity { CommandName = "about_Foreach", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach" }); repository.AddHelpEntity(new HelpEntity { CommandName = "about_For", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For" }); repository.AddHelpEntity(new HelpEntity { CommandName = "about_Remote_Variables", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", - RelatedLinks = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", + RelatedLinks = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables" }); repository.AddHelpEntity(new HelpEntity { CommandName = "about_Scopes", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Scopes" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Scopes" }); repository.AddHelpEntity(new HelpEntity { CommandName = "about_Return", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" }); repository.AddHelpEntity(new HelpEntity { CommandName = "about_Throw", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" }); repository.AddHelpEntity(new HelpEntity { CommandName = "about_language_keywords", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords" }); repository.AddHelpEntity(new HelpEntity { CommandName = "about_trap", - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" + }); + }); } } diff --git a/explainpowershell.analysisservice.tests/testfiles/test_get_help.json b/explainpowershell.analysisservice.tests/testfiles/test_get_help.json index 67f8501..39516d9 100644 --- a/explainpowershell.analysisservice.tests/testfiles/test_get_help.json +++ b/explainpowershell.analysisservice.tests/testfiles/test_get_help.json @@ -3,14 +3,14 @@ "CommandName": "Get-Help", "DefaultParameterSet": "AllUsersView", "Description": "The `Get-Help` cmdlet displays information about PowerShell concepts and commands, including cmdlets, functions, Common Information Model (CIM) commands, workflows, providers, aliases, and scripts.\nTo get help for a PowerShell cmdlet, type `Get-Help` followed by the cmdlet name, such as: `Get-Help Get-Process`.\nConceptual help articles in PowerShell begin with about_ , such as about_Comparison_Operators . To see all about_ articles, type `Get-Help about_*`. To see a particular article, type `Get-Help about_`, such as `Get-Help about_Comparison_Operators`.\nTo get help for a PowerShell provider, type `Get-Help` followed by the provider name. For example, to get help for the Certificate provider, type `Get-Help Certificate`.\nYou can also type `help` or `man`, which displays one screen of text at a time. Or, ` -?`, that is identical to `Get-Help`, but only works for cmdlets.\n`Get-Help` gets the help content that it displays from help files on your computer. Without the help files, `Get-Help` displays only basic information about cmdlets. Some PowerShell modules include help files. Beginning in PowerShell 3.0, the modules that come with the Windows operating system don't include help files. To download or update the help files for a module in PowerShell 3.0, use the `Update-Help` cmdlet.\nYou can also view the PowerShell help documents online in the Microsoft Docs. To get the online version of a help file, use the Online parameter, such as: `Get-Help Get-Process -Online`. To read all the PowerShell documentation, see the Microsoft Docs PowerShell Documentation (/powershell).\nIf you type `Get-Help` followed by the exact name of a help article, or by a word unique to a help article, `Get-Help` displays the article's content. If you specify the exact name of a command alias, `Get-Help` displays the help for the original command. If you enter a word or word pattern that appears in several help article titles, `Get-Help` displays a list of the matching titles. If you enter any text that doesn't appear in any help article titles, `Get-Help` displays a list of articles that include that text in their contents.\n`Get-Help` can get help articles for all supported languages and locales. `Get-Help` first looks for help files in the locale set for Windows, then in the parent locale, such as pt for pt-BR , and then in a fallback locale. Beginning in PowerShell 3.0, if `Get-Help` doesn't find help in the fallback locale, it looks for help articles in English, en-US , before it returns an error message or displaying auto-generated help.\nFor information about the symbols that `Get-Help` displays in the command syntax diagram, see about_Command_Syntax (./About/about_Command_Syntax.md). For information about parameter attributes, such as Required and Position , see about_Parameters (./About/about_Parameters.md).\n>[!NOTE] > In PowerShell 3.0 and PowerShell 4.0, `Get-Help` can't find About articles in modules unless > the module is imported into the current session. This is a known issue. To get About articles > in a module, import the module, either by using the `Import-Module` cmdlet or by running a cmdlet > that's included in the module.", - "DocumentationLink": "https://docs.microsoft.com/powershell/module/microsoft.powershell.core/get-help?view=powershell-7.2&WT.mc_id=ps-gethelp", + "DocumentationLink": "https://learn.microsoft.com/powershell/module/microsoft.powershell.core/get-help?view=powershell-7.2&WT.mc_id=ps-gethelp", "InputTypes": "None", "ModuleName": "Microsoft.PowerShell.Core", "ModuleVersion": "7.2.3.500", - "ModuleProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core", + "ModuleProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core", "Parameters": "[{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays help only for items in the specified category and their aliases. Conceptual articles are in the HelpFile category.\\nThe acceptable values for this parameter are as follows:\\n- Alias\\n- Cmdlet\\n- Provider\\n- General\\n- FAQ\\n- Glossary\\n- HelpFile\\n- ScriptCommand\\n- Function\\n- Filter\\n- ExternalScript\\n- All\\n- DefaultHelp\\n- Workflow\\n- DscResource\\n- Class\\n- Configuration\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Category\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays commands with the specified component value, such as Exchange . Enter a component name. Wildcard characters are permitted. This parameter has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Component\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Adds parameter descriptions and examples to the basic help display. This parameter is effective only when the help files are installed on the computer. It has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Detailed\",\"ParameterSets\":{\"DetailedView\":{\"IsMandatory\":true,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":true,\"TypeName\":\"System.Management.Automation.SwitchParameter\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Displays only the name, synopsis, and examples. To display only the examples, type `(Get-Help ).Examples`.\\nThis parameter is effective only when the help files are installed on the computer. It has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Examples\",\"ParameterSets\":{\"Examples\":{\"IsMandatory\":true,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":true,\"TypeName\":\"System.Management.Automation.SwitchParameter\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Displays the entire help article for a cmdlet. Full includes parameter descriptions and attributes, examples, input and output object types, and additional notes.\\nThis parameter is effective only when the help files are installed on the computer. It has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Full\",\"ParameterSets\":{\"AllUsersView\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":true,\"TypeName\":\"System.Management.Automation.SwitchParameter\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays help for items with the specified functionality. Enter the functionality. Wildcard characters are permitted. This parameter has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Functionality\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Gets help about the specified command or concept. Enter the name of a cmdlet, function, provider, script, or workflow, such as `Get-Member`, a conceptual article name, such as `about_Objects`, or an alias, such as `ls`. Wildcard characters are permitted in cmdlet and provider names, but you can't use wildcard characters to find the names of function help and script help articles.\\nTo get help for a script that isn't located in a path that's listed in the `$env:Path` environment variable, type the script's path and file name.\\nIf you enter the exact name of a help article, `Get-Help` displays the article contents.\\nIf you enter a word or word pattern that appears in several help article titles, `Get-Help` displays a list of the matching titles.\\nIf you enter any text that doesn't match any help article titles, `Get-Help` displays a list of articles that include that text in their contents.\\nThe names of conceptual articles, such as `about_Objects`, must be entered in English, even in non-English versions of PowerShell.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Name\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":0,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":true,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"True (ByPropertyName)\",\"Position\":\"0\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Displays the online version of a help article in the default browser. This parameter is valid only for cmdlet, function, workflow, and script help articles. You can't use the Online parameter with `Get-Help` in a remote session.\\nFor information about supporting this feature in help articles that you write, see about_Comment_Based_Help (./About/about_Comment_Based_Help.md), and Supporting Online Help (/powershell/scripting/developer/module/supporting-online-help), and Writing Help for PowerShell Cmdlets (/powershell/scripting/developer/help/writing-help-for-windows-powershell-cmdlets).\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Online\",\"ParameterSets\":{\"Online\":{\"IsMandatory\":true,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":true,\"TypeName\":\"System.Management.Automation.SwitchParameter\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays only the detailed descriptions of the specified parameters. Wildcards are permitted. This parameter has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Parameter\",\"ParameterSets\":{\"Parameters\":{\"IsMandatory\":true,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Gets help that explains how the cmdlet works in the specified provider path. Enter a PowerShell provider path.\\nThis parameter gets a customized version of a cmdlet help article that explains how the cmdlet works in the specified PowerShell provider path. This parameter is effective only for help about a provider cmdlet and only when the provider includes a custom version of the provider cmdlet help article in its help file. To use this parameter, install the help file for the module that includes the provider.\\nTo see the custom cmdlet help for a provider path, go to the provider path location and enter a `Get-Help` command or, from any path location, use the Path parameter of `Get-Help` to specify the provider path. You can also find custom cmdlet help online in the provider help section of the help articles.\\nFor more information about PowerShell providers, see about_Providers (./About/about_Providers.md).\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Path\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays help customized for the specified user role. Enter a role. Wildcard characters are permitted.\\nEnter the role that the user plays in an organization. Some cmdlets display different text in their help files based on the value of this parameter. This parameter has no effect on help for the core cmdlets.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Role\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Displays the help topic in a window for easier reading. The window includes a Find search feature and a Settings box that lets you set options for the display, including options to display only selected sections of a help topic.\\nThe ShowWindow parameter supports help topics for commands (cmdlets, functions, CIM commands, scripts) and conceptual About articles. It does not support provider help.\\nThis parameter was reintroduced in PowerShell 7.0.\",\"Globbing\":\"false\",\"IsDynamic\":null,\"Name\":\"ShowWindow\",\"ParameterSets\":null,\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":null,\"TypeName\":\"System.Management.Automation.SwitchParameter\"}]", "ParameterSetNames": "AllUsersView, DetailedView, Examples, Online, Parameters", - "RelatedLinks": "https://docs.microsoft.com/powershell/module/microsoft.powershell.core/get-help?view=powershell-7.2&WT.mc_id=ps-gethelp", + "RelatedLinks": "https://learn.microsoft.com/powershell/module/microsoft.powershell.core/get-help?view=powershell-7.2&WT.mc_id=ps-gethelp", "ReturnValues": "ExtendedCmdletHelpInfo, System.String, MamlCommandHelpInfo", "Synopsis": "Displays information about PowerShell commands and concepts.", "Syntax": "Get-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] -Detailed [-Functionality ] [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] -Examples [-Functionality ] [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] [-Full] [-Functionality ] [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] [-Functionality ] -Online [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] [-Functionality ] -Parameter [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] [-Functionality ] [-Path ] [-Role ] -ShowWindow []" diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs index 9ebe58a..21ba207 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs @@ -62,7 +62,7 @@ public void ShouldGenerateHelpForUsingExpressions() "A variable named 'var', with the 'using' scope modifier: a local variable used in a remote scope.", res.Explanations[1].Description); Assert.AreEqual( - "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", + "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", res.Explanations[1].HelpResult?.DocumentationLink); Assert.AreEqual( "Scoped variable", @@ -83,7 +83,7 @@ public void ShoudGenerateHelpForForeachStatements() res.Explanations[0].Description); Assert.AreEqual( - "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach", + "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach", res.Explanations[0].HelpResult?.DocumentationLink); } @@ -98,7 +98,7 @@ public void ShoudGenerateHelpForForStatements() res.Explanations[0].Description); Assert.AreEqual( - "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For", + "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For", res.Explanations[0].HelpResult?.DocumentationLink); } diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs index bc747b0..d25d5d5 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs @@ -198,16 +198,16 @@ public override AstVisitAction VisitReturnStatement(ReturnStatementAst returnSta var helpResult = HelpTableQuery("about_Return") ?? new HelpEntity { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" }; if (string.IsNullOrEmpty(helpResult.DocumentationLink)) { - helpResult.DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return"; + helpResult.DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return"; } var languageKeywordsLink = (HelpTableQuery("about_language_keywords")?.DocumentationLink - ?? "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#return"; + ?? "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#return"; if (string.IsNullOrEmpty(helpResult.RelatedLinks)) { @@ -246,16 +246,16 @@ public override AstVisitAction VisitThrowStatement(ThrowStatementAst throwStatem var helpResult = HelpTableQuery("about_Throw") ?? new HelpEntity { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" }; if (string.IsNullOrEmpty(helpResult.DocumentationLink)) { - helpResult.DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw"; + helpResult.DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw"; } var languageKeywordsLink = (HelpTableQuery("about_language_keywords")?.DocumentationLink - ?? "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#throw"; + ?? "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#throw"; if (string.IsNullOrEmpty(helpResult.RelatedLinks)) { @@ -287,12 +287,12 @@ public override AstVisitAction VisitTrap(TrapStatementAst trapStatementAst) var helpResult = HelpTableQuery("about_trap") ?? new HelpEntity { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" }; if (string.IsNullOrEmpty(helpResult.DocumentationLink)) { - helpResult.DocumentationLink = "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap"; + helpResult.DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap"; } explanations.Add(new Explanation() diff --git a/explainpowershell.analysisservice/Constants.cs b/explainpowershell.analysisservice/Constants.cs index e00659d..d695a79 100644 --- a/explainpowershell.analysisservice/Constants.cs +++ b/explainpowershell.analysisservice/Constants.cs @@ -36,7 +36,7 @@ public static class TableStorage /// public static class Documentation { - public const string MicrosoftDocsBase = "https://docs.microsoft.com/en-us/powershell/scripting/lang-spec"; + public const string MicrosoftDocsBase = "https://learn.microsoft.com/en-us/powershell/scripting/lang-spec"; public const string Chapter04TypeSystem = MicrosoftDocsBase + "/chapter-04"; public const string Chapter04GenericTypes = Chapter04TypeSystem + "#44-generic-types"; public const string Chapter08PipelineStatements = MicrosoftDocsBase + "/chapter-08#82-pipeline-statements"; diff --git a/explainpowershell.helpcollector/New-SasToken.ps1 b/explainpowershell.helpcollector/New-SasToken.ps1 index 106661f..b02ef0d 100644 --- a/explainpowershell.helpcollector/New-SasToken.ps1 +++ b/explainpowershell.helpcollector/New-SasToken.ps1 @@ -9,7 +9,7 @@ function New-SasToken { $sasSplat = @{ Service = 'Table' ResourceType = 'Service', 'Container', 'Object' - Permission = 'racwdlup' # https://docs.microsoft.com/en-us/powershell/module/az.storage/new-azstorageaccountsastoken + Permission = 'racwdlup' # https://learn.microsoft.com/en-us/powershell/module/az.storage/new-azstorageaccountsastoken#-permission StartTime = (Get-Date) ExpiryTime = (Get-Date).AddMinutes(30) Context = $context diff --git a/explainpowershell.helpcollector/aboutcollector.ps1 b/explainpowershell.helpcollector/aboutcollector.ps1 index 7f86fe4..ba32374 100644 --- a/explainpowershell.helpcollector/aboutcollector.ps1 +++ b/explainpowershell.helpcollector/aboutcollector.ps1 @@ -7,7 +7,7 @@ $aboutArticles = Get-Help About_* $abouts = $aboutArticles | Where-Object {-not $_.synopsis} foreach ($about in $abouts) { - $baseUrl = 'https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/' + $baseUrl = 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/' [BasicHtmlWebResponseObject]$result = $null try { $result = Invoke-WebRequest -Uri ($baseUrl + $about.name) -ErrorAction SilentlyContinue diff --git a/explainpowershell.metadata/defaultModules.json b/explainpowershell.metadata/defaultModules.json index 2f15f30..30756c6 100644 --- a/explainpowershell.metadata/defaultModules.json +++ b/explainpowershell.metadata/defaultModules.json @@ -1,31 +1,31 @@ [ { "Name": "Microsoft.PowerShell.Archive", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.archive" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.archive" }, { "Name": "Microsoft.PowerShell.Host", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.host" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.host" }, { "Name": "Microsoft.PowerShell.Management", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.management" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management" }, { "Name": "Microsoft.PowerShell.Security", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.security" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security" }, { "Name": "Microsoft.PowerShell.Utility", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility" }, { "Name": "Microsoft.PowerShell.Core", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core" }, { "Name": "PSReadLine", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/psreadline" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/psreadline" }, { "Name": "myTestModule", diff --git a/research/Ast.txt b/research/Ast.txt index b50afe4..e4fd4d1 100644 --- a/research/Ast.txt +++ b/research/Ast.txt @@ -211,4 +211,4 @@ Ast StringConstantType [BareWord, DoubleQuoted, DoubleQuotedHereString, SingleQuoted, SingleQuotedHereString] -TokenKind (https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.language.tokenkind) \ No newline at end of file +TokenKind (https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.tokenkind) \ No newline at end of file From 81fe417adb144078ee02ffdd6091a8288ee6d5a9 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 15:02:21 +0100 Subject: [PATCH 27/37] Add tests for tree generation and update syntax analyzer methods to return nullable types --- .../SyntaxAnalyzerExtensions.cs | 19 +++--- explainpowershell.frontend.tests/TreeTests.cs | 34 ++++++++++ .../explainpowershell.frontend.tests.csproj | 27 ++++++++ .../InternalsVisibleTo.cs | 3 + explainpowershell.frontend/Tree.cs | 23 ++++++- explainpowershell.sln | 66 +++++++++++++++++-- 6 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 explainpowershell.frontend.tests/TreeTests.cs create mode 100644 explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj create mode 100644 explainpowershell.frontend/InternalsVisibleTo.cs diff --git a/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs b/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs index 3f0c683..6c1e5c4 100644 --- a/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs +++ b/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs @@ -32,39 +32,36 @@ private static string GenerateId(this Token token) return $"{token.Extent.StartLineNumber}.{token.Extent.StartOffset}.{token.Extent.EndOffset}.{token.Kind}"; } - public static string TryFindParentExplanation(Ast ast, List explanations, int level = 0) + public static string? TryFindParentExplanation(Ast ast, List explanations, int level = 0) { - if (explanations.Count == 0 | ast.Parent == null) - return string.Empty; - - if (ast.Parent == null) - return string.Empty; + if (explanations.Count == 0 || ast.Parent == null) + return null; var parentId = ast.Parent.GenerateId(); - if ((!explanations.Any(e => e.Id == parentId)) & level < 100) + if ((!explanations.Any(e => e.Id == parentId)) && level < 100) { return TryFindParentExplanation(ast.Parent, explanations, ++level); } if (level >= 99) - return string.Empty; + return null; return parentId; } - public static string TryFindParentExplanation(Token token, List explanations) + public static string? TryFindParentExplanation(Token token, List explanations) { var start = token.Extent.StartOffset; var explanationsBeforeToken = explanations.Where(e => GetEndOffSet(e) <= start); if (!explanationsBeforeToken.Any()) { - return string.Empty; + return null; } var closestNeigbour = explanationsBeforeToken.Max(e => GetEndOffSet(e)); - return explanationsBeforeToken.FirstOrDefault(t => GetEndOffSet(t) == closestNeigbour)?.Id ?? string.Empty; + return explanationsBeforeToken.FirstOrDefault(t => GetEndOffSet(t) == closestNeigbour)?.Id; } private static int GetEndOffSet(Explanation e) diff --git a/explainpowershell.frontend.tests/TreeTests.cs b/explainpowershell.frontend.tests/TreeTests.cs new file mode 100644 index 0000000..3e17c10 --- /dev/null +++ b/explainpowershell.frontend.tests/TreeTests.cs @@ -0,0 +1,34 @@ +using explainpowershell.models; + +namespace explainpowershell.frontend.tests; + +public class TreeTests +{ + [Test] + public void GenerateTree_TreatsNullAndEmptyParentIdAsRoot() + { + var explanations = new List + { + new() { Id = "1", ParentId = "", CommandName = "root-empty" }, + new() { Id = "2", ParentId = null, CommandName = "root-null" }, + new() { Id = "1.1", ParentId = "1", CommandName = "child" }, + }; + + var tree = explanations.GenerateTree(e => e.Id, e => e.ParentId); + + Assert.That(tree, Has.Count.EqualTo(2)); + + var rootEmpty = tree.Single(t => t.Value is not null && t.Value.Id == "1"); + Assert.That(rootEmpty.Value, Is.Not.Null); + Assert.That(rootEmpty.Children, Is.Not.Null); + var rootEmptyChildren = rootEmpty.Children!; + Assert.That(rootEmptyChildren, Has.Count.EqualTo(1)); + var onlyChild = rootEmptyChildren.Single(); + Assert.That(onlyChild.Value, Is.Not.Null); + Assert.That(onlyChild.Value!.Id, Is.EqualTo("1.1")); + + var rootNull = tree.Single(t => t.Value is not null && t.Value.Id == "2"); + Assert.That(rootNull.Value, Is.Not.Null); + Assert.That(rootNull.Children, Is.Null); + } +} diff --git a/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj b/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj new file mode 100644 index 0000000..d506d49 --- /dev/null +++ b/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/explainpowershell.frontend/InternalsVisibleTo.cs b/explainpowershell.frontend/InternalsVisibleTo.cs new file mode 100644 index 0000000..d01b8e8 --- /dev/null +++ b/explainpowershell.frontend/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("explainpowershell.frontend.tests")] diff --git a/explainpowershell.frontend/Tree.cs b/explainpowershell.frontend/Tree.cs index 8502e85..249864e 100644 --- a/explainpowershell.frontend/Tree.cs +++ b/explainpowershell.frontend/Tree.cs @@ -18,7 +18,28 @@ public static List> GenerateTree( { var nodes = new List>(); - foreach (var item in collection.Where(c => EqualityComparer.Default.Equals(parentIdSelector(c), rootId))) + bool IsRoot(T item) + { + var parentId = parentIdSelector(item); + + // Special-case string keys: treat both null and empty as root. + if (typeof(K) == typeof(string)) + { + var parentString = parentId as string; + var rootString = rootId as string; + + if (string.IsNullOrEmpty(rootString)) + { + return string.IsNullOrEmpty(parentString); + } + + return string.Equals(parentString, rootString, StringComparison.Ordinal); + } + + return EqualityComparer.Default.Equals(parentId, rootId); + } + + foreach (var item in collection.Where(IsRoot)) { var children = collection.GenerateTree(idSelector, parentIdSelector, idSelector(item)); diff --git a/explainpowershell.sln b/explainpowershell.sln index 557cd95..38f2a9a 100644 --- a/explainpowershell.sln +++ b/explainpowershell.sln @@ -11,39 +11,97 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "explainpowershell.models", EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "explainpowershell.helpcollector", "explainpowershell.helpcollector", "{56F173B4-132E-4ADA-A154-7914BC2ECF6C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools", "explainpowershell.helpcollector\tools\explainpowershell.helpcollector.tools.csproj", "{6854D7F5-13D2-4363-A699-88F3802FC917}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "explainpowershell.helpcollector.tools", "explainpowershell.helpcollector\tools\explainpowershell.helpcollector.tools.csproj", "{6854D7F5-13D2-4363-A699-88F3802FC917}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "explainpowershell.analysisservice.tests", "explainpowershell.analysisservice.tests\explainpowershell.analysisservice.tests.csproj", "{37C8F882-8BFA-483B-80B6-CBA18387AEE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "explainpowershell.frontend.tests", "explainpowershell.frontend.tests\explainpowershell.frontend.tests.csproj", "{FB7EE901-0650-4A54-BF4A-70407BE1234C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|x64.ActiveCfg = Debug|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|x64.Build.0 = Debug|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|x86.ActiveCfg = Debug|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|x86.Build.0 = Debug|Any CPU {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|Any CPU.ActiveCfg = Release|Any CPU {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|Any CPU.Build.0 = Release|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|x64.ActiveCfg = Release|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|x64.Build.0 = Release|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|x86.ActiveCfg = Release|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|x86.Build.0 = Release|Any CPU {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|x64.ActiveCfg = Debug|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|x64.Build.0 = Debug|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|x86.ActiveCfg = Debug|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|x86.Build.0 = Debug|Any CPU {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|Any CPU.ActiveCfg = Release|Any CPU {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|Any CPU.Build.0 = Release|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|x64.ActiveCfg = Release|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|x64.Build.0 = Release|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|x86.ActiveCfg = Release|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|x86.Build.0 = Release|Any CPU {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|x64.Build.0 = Debug|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|x86.Build.0 = Debug|Any CPU {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|Any CPU.Build.0 = Release|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|x64.ActiveCfg = Release|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|x64.Build.0 = Release|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|x86.ActiveCfg = Release|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|x86.Build.0 = Release|Any CPU {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|x64.ActiveCfg = Debug|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|x64.Build.0 = Debug|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|x86.ActiveCfg = Debug|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|x86.Build.0 = Debug|Any CPU {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|Any CPU.ActiveCfg = Release|Any CPU {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|Any CPU.Build.0 = Release|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|x64.ActiveCfg = Release|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|x64.Build.0 = Release|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|x86.ActiveCfg = Release|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|x86.Build.0 = Release|Any CPU {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|x64.ActiveCfg = Debug|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|x64.Build.0 = Debug|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|x86.Build.0 = Debug|Any CPU {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|Any CPU.Build.0 = Release|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|x64.ActiveCfg = Release|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|x64.Build.0 = Release|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|x86.ActiveCfg = Release|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|x86.Build.0 = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|x64.Build.0 = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|x86.Build.0 = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|Any CPU.Build.0 = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|x64.ActiveCfg = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|x64.Build.0 = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|x86.ActiveCfg = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {6854D7F5-13D2-4363-A699-88F3802FC917} = {56F173B4-132E-4ADA-A154-7914BC2ECF6C} From 231259e03c9161db9be3f07e4735eca3265813d1 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 15:02:38 +0100 Subject: [PATCH 28/37] Implement switch statement explanation and related tests --- .../helpers/TestHelpData.cs | 4 ++ .../tests/AstVisitorExplainer.tests.cs | 24 ++++++++++ .../AstVisitorExplainer_statements.cs | 44 ++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs index 1b1da2f..f17bb1e 100644 --- a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs +++ b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs @@ -61,6 +61,10 @@ public static void SeedAboutTopics(InMemoryHelpRepository repository) DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" }); + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Switch", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Switch" }); } } diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs index 21ba207..fb9432c 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs @@ -143,5 +143,29 @@ public void ShouldGenerateHelpForTrapStatement() Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Trap")); Assert.That(explanation.Description, Does.Contain("trap handler")); } + + [Test] + public void ShouldGenerateHelpForSwitchStatement() + { + ScriptBlock.Create("switch ($x) { 1 { 'one' } default { 'other' } }").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + var explanation = res.Explanations.SingleOrDefault(e => e.TextToHighlight == "switch"); + + Assert.That(explanation, Is.Not.Null); + Assert.That(explanation.CommandName, Is.EqualTo("switch statement")); + Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Switch")); + Assert.That(explanation.HelpResult?.RelatedLinks, Does.Contain("about_language_keywords").And.Contain("#switch")); + } + + [Test] + public void AnalysisResult_HasRootExplanation_WithNullParentId() + { + ScriptBlock.Create("Get-Process").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + Assert.That(res.Explanations, Is.Not.Empty); + Assert.That(res.Explanations.Any(e => e.ParentId == null), Is.True); + } } } \ No newline at end of file diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs index d25d5d5..75a2bcb 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs @@ -232,8 +232,48 @@ public override AstVisitAction VisitReturnStatement(ReturnStatementAst returnSta public override AstVisitAction VisitSwitchStatement(SwitchStatementAst switchStatementAst) { - // TODO: add switch statement explanation - AstExplainer(switchStatementAst); + var flags = switchStatementAst.Flags; + + var flagText = flags == SwitchFlags.None + ? string.Empty + : $" using flags: {flags}."; + + var inputText = string.IsNullOrEmpty(switchStatementAst.Condition?.Extent?.Text) + ? "" + : $" over '{switchStatementAst.Condition.Extent.Text}'"; + + var helpResult = HelpTableQuery("about_Switch") + ?? new HelpEntity + { + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Switch" + }; + + if (string.IsNullOrEmpty(helpResult.DocumentationLink)) + { + helpResult.DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Switch"; + } + + var languageKeywordsLink = (HelpTableQuery("about_language_keywords")?.DocumentationLink + ?? "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#switch"; + + if (string.IsNullOrEmpty(helpResult.RelatedLinks)) + { + helpResult.RelatedLinks = languageKeywordsLink; + } + else if (!helpResult.RelatedLinks.Contains(languageKeywordsLink, StringComparison.OrdinalIgnoreCase)) + { + helpResult.RelatedLinks += ", " + languageKeywordsLink; + } + + explanations.Add( + new Explanation() + { + CommandName = "switch statement", + HelpResult = helpResult, + Description = $"Evaluates input{inputText} and runs the first matching clause.{flagText}", + TextToHighlight = "switch" + }.AddDefaults(switchStatementAst, explanations)); + return base.VisitSwitchStatement(switchStatementAst); } From ccbb566b21ffcd1c1096b6a41e9704bdbc3805d2 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 16:47:30 +0100 Subject: [PATCH 29/37] fix failing tests --- .../Get-HelpDatabaseData.Tests.ps1 | 2 +- .../explainpowershell.frontend.tests.csproj | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 b/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 index ceae3e2..4d7849c 100644 --- a/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 @@ -8,7 +8,7 @@ Describe 'Get-HelpDatabaseData' { $data = Get-HelpDatabaseData -RowKey 'about_pwsh' $data.Properties.CommandName | Should -BeExactly 'about_Pwsh' - $data.Properties.DocumentationLink | Should -Match 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Pwsh' + $data.Properties.DocumentationLink | Should -Match 'https://(learn|docs).microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Pwsh' $data.Properties.ModuleName | Should -BeNullOrEmpty $data.Properties.Synopsis | Should -BeExactly 'Explains how to use the pwsh command-line interface. Displays the command-line parameters and describes the syntax.' } diff --git a/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj b/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj index d506d49..98db677 100644 --- a/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj +++ b/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj @@ -1,5 +1,4 @@ - - + net10.0 latest @@ -7,21 +6,17 @@ enable false - - - - - + + + + - - - - + \ No newline at end of file From ed1d480f323248d6d8550099e5ee6632deb6f58b Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 16:47:58 +0100 Subject: [PATCH 30/37] Vastly speed up test suite --- .vscode/launch.json | 7 +- .vscode/tasks.json | 2 +- .../Get-MetaData.ps1 | 4 +- .../Invoke-AiExplanation.Tests.ps1 | 74 +++++++------------ .../Invoke-AiExplanation.ps1 | 20 ++++- .../Invoke-SyntaxAnalyzer.ps1 | 23 +++++- .../Start-AllBackendTests.ps1 | 31 ++++++-- .../Start-FunctionApp.ps1 | 3 +- 8 files changed, 96 insertions(+), 68 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 1aa9d64..8ca6031 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,15 +16,18 @@ "type": "PowerShell", "request": "launch", "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1", + "args": ["-Output Detailed"], "cwd": "${workspaceFolder}", - "internalConsoleOptions": "openOnSessionStart" + "internalConsoleOptions": "openOnSessionStart", + "preLaunchTask": "startstorageemulator" }, { "name": "debug functionApp", "type": "coreclr", "request": "attach", "processId": "${command:azureFunctions.pickProcess}", - "internalConsoleOptions": "neverOpen" + "internalConsoleOptions": "neverOpen", + "preLaunchTask": "startstorageemulator" }, { "name": "debug wasm", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9a05dfc..1eec41a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -8,7 +8,7 @@ "isDefault": true }, "type": "shell", - "command": "Invoke-Pester -Configuration (New-PesterConfiguration -Hashtable @{ Run = @{ Path = './explainpowershell.analysisservice.tests' }; Output = @{ Verbosity = 'Detailed' } })", + "command": "$env:AiExplanation__Enabled='false'; $env:AiExplanation__Endpoint=''; $env:AiExplanation__ApiKey=''; $env:AiExplanation__DeploymentName=''; Invoke-Pester -Configuration (New-PesterConfiguration -Hashtable @{ Run = @{ Path = './explainpowershell.analysisservice.tests' }; Output = @{ Verbosity = 'Detailed' } })", "presentation": { "echo": true, "reveal": "always", diff --git a/explainpowershell.analysisservice.tests/Get-MetaData.ps1 b/explainpowershell.analysisservice.tests/Get-MetaData.ps1 index d840c0b..1ddec13 100644 --- a/explainpowershell.analysisservice.tests/Get-MetaData.ps1 +++ b/explainpowershell.analysisservice.tests/Get-MetaData.ps1 @@ -3,11 +3,11 @@ function Get-MetaData { [switch] $Refresh ) - $uri = 'http://localhost:7071/api/MetaData' + $uri = 'http://127.0.0.1:7071/api/MetaData' if ( $Refresh ) { $uri += '?refresh=true' } - + Invoke-RestMethod -Uri $uri } diff --git a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 index 18cd24e..3415338 100644 --- a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 @@ -3,16 +3,25 @@ using namespace Microsoft.PowerShell.Commands Describe "AI Explanation Integration Tests" { BeforeAll { - . $PSScriptRoot/Invoke-SyntaxAnalyzer.ps1 - . $PSScriptRoot/Invoke-AiExplanation.ps1 - . $PSScriptRoot/Start-FunctionApp.ps1 - . $PSScriptRoot/Test-IsAzuriteUp.ps1 - # Save original AI config to restore later $script:originalAiEnabled = $env:AiExplanation__Enabled $script:originalAiEndpoint = $env:AiExplanation__Endpoint $script:originalAiApiKey = $env:AiExplanation__ApiKey $script:originalAiDeploymentName = $env:AiExplanation__DeploymentName + + # IMPORTANT: AI enabled/configured state is read at Functions host startup. + # Keep tests deterministic and fast by disabling outbound AI calls. + $env:AiExplanation__Enabled = 'false' + $env:AiExplanation__Endpoint = '' + $env:AiExplanation__ApiKey = '' + $env:AiExplanation__DeploymentName = '' + + . $PSScriptRoot/Invoke-SyntaxAnalyzer.ps1 + . $PSScriptRoot/Invoke-AiExplanation.ps1 + . $PSScriptRoot/Start-FunctionApp.ps1 + . $PSScriptRoot/Test-IsAzuriteUp.ps1 + + $script:baseUri = 'http://127.0.0.1:7071/api' } AfterAll { @@ -33,14 +42,12 @@ Describe "AI Explanation Integration Tests" { Context "AiExplanation Function Endpoint" { - It "Should return 200 OK when AI is disabled" -Skip { - # Note: Skipped because AI enabled state is determined at function app startup. + It "Should return 200 OK when AI is disabled" -Skip:($env:AiExplanation__Enabled -ne 'false') { + # Note: you should skip this test if AI is not disabled, because AI enabled state is determined at function app startup. # Changing environment variables at runtime doesn't reload the DI container. # To test AI disabled behavior, restart function app with AiExplanation__Enabled=false # Arrange - $env:AiExplanation__Enabled = "false" - Start-Sleep -Milliseconds 500 # Give function app time to reload config $requestBody = @{ PowershellCode = "Get-Process" @@ -57,7 +64,7 @@ Describe "AI Explanation Integration Tests" { # Act $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` + -Uri "$script:baseUri/aiexplanation" ` -Method Post ` -Body $requestBody ` -ContentType "application/json" ` @@ -93,7 +100,7 @@ Describe "AI Explanation Integration Tests" { } # Act & Assert - Should not throw - $response = Invoke-AiExplanation -PowershellCode "Get-Process" -AnalysisResult $analysisResult + $response = Invoke-AiExplanation -PowershellCode "Get-Process" -AnalysisResult $analysisResult -BaseUri $script:baseUri $response.StatusCode | Should -Be 200 $content = $response.Content | ConvertFrom-Json @@ -114,7 +121,7 @@ Describe "AI Explanation Integration Tests" { # Act & Assert - Should return 400 BadRequest for validation error { Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` + -Uri "$script:baseUri/aiexplanation" ` -Method Post ` -Body $requestBody ` -ContentType "application/json" ` @@ -131,7 +138,7 @@ Describe "AI Explanation Integration Tests" { } # Act - $response = Invoke-AiExplanation -PowershellCode "Get-Process" -AnalysisResult $analysisResult + $response = Invoke-AiExplanation -PowershellCode "Get-Process" -AnalysisResult $analysisResult -BaseUri $script:baseUri # Assert $response.StatusCode | Should -Be 200 @@ -171,7 +178,7 @@ Describe "AI Explanation Integration Tests" { Write-Host "Payload size: $($requestBody.Length) bytes" # Act - Should handle payload reduction gracefully - $response = Invoke-AiExplanation -PowershellCode $code -AnalysisResult $analysisResult + $response = Invoke-AiExplanation -PowershellCode $code -AnalysisResult $analysisResult -BaseUri $script:baseUri # Assert $response.StatusCode | Should -Be 200 @@ -193,7 +200,7 @@ Describe "AI Explanation Integration Tests" { } # Act - $response = Invoke-AiExplanation -PowershellCode "gps" -AnalysisResult $analysisResult + $response = Invoke-AiExplanation -PowershellCode "gps" -AnalysisResult $analysisResult -BaseUri $script:baseUri # Assert $content = $response.Content | ConvertFrom-Json @@ -203,35 +210,6 @@ Describe "AI Explanation Integration Tests" { } } - Context "Configuration Validation" { - - It "Should respect MaxPayloadCharacters configuration" { - # This is implicitly tested by the large payload test - # The service should reduce payload size when it exceeds configured limit - $true | Should -Be $true - } - - It "Should use configured system prompt" { - # Arrange - Set custom system prompt - $customPrompt = "You are a test assistant for PowerShell." - $env:AiExplanation__SystemPrompt = $customPrompt - Start-Sleep -Milliseconds 500 - - # Note: We can't directly verify the prompt is used without AI credentials - # This test validates the configuration is accepted - $env:AiExplanation__SystemPrompt | Should -Be $customPrompt - } - - It "Should use configured timeout" { - # Arrange - $env:AiExplanation__RequestTimeoutSeconds = "5" - Start-Sleep -Milliseconds 500 - - # Verify configuration is set - $env:AiExplanation__RequestTimeoutSeconds | Should -Be "5" - } - } - Context "Error Handling" { It "Should handle malformed JSON gracefully" { @@ -241,7 +219,7 @@ Describe "AI Explanation Integration Tests" { # Act & Assert { Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` + -Uri "$script:baseUri/aiexplanation" ` -Method Post ` -Body $badJson ` -ContentType "application/json" ` @@ -259,7 +237,7 @@ Describe "AI Explanation Integration Tests" { # Act & Assert - Should return 400 BadRequest for validation error { Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` + -Uri "$script:baseUri/aiexplanation" ` -Method Post ` -Body $requestBody ` -ContentType "application/json" ` @@ -273,11 +251,11 @@ Describe "AI Explanation Integration Tests" { It "Should work in complete analysis workflow" { # Arrange - First get regular analysis $code = 'Get-Process | Where-Object CPU -gt 100' - [BasicHtmlWebResponseObject]$analysisResponse = Invoke-SyntaxAnalyzer -PowerShellCode $code + [BasicHtmlWebResponseObject]$analysisResponse = Invoke-SyntaxAnalyzer -PowerShellCode $code -BaseUri $script:baseUri $analysisResult = $analysisResponse.Content | ConvertFrom-Json # Act - Then request AI explanation - $aiResponse = Invoke-AiExplanation -PowershellCode $code -AnalysisResult $analysisResult + $aiResponse = Invoke-AiExplanation -PowershellCode $code -AnalysisResult $analysisResult -BaseUri $script:baseUri # Assert $aiResponse.StatusCode | Should -Be 200 diff --git a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 index 0659c8b..0efe126 100644 --- a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 @@ -1,3 +1,10 @@ +# Ensure we use the repo-local (optimized) helper implementation. +# This avoids accidentally calling a stale `Invoke-SyntaxAnalyzer` already loaded in the caller's session. +$invokeSyntaxAnalyzerPath = Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-SyntaxAnalyzer.ps1' +if (Test-Path -LiteralPath $invokeSyntaxAnalyzerPath) { + . $invokeSyntaxAnalyzerPath +} + function Invoke-AiExplanation { param( [Parameter(Mandatory)] @@ -7,7 +14,10 @@ function Invoke-AiExplanation { [object]$AnalysisResult, [Parameter()] - [string]$BaseUri = 'http://localhost:7071/api', + [string]$BaseUri = 'http://127.0.0.1:7071/api', + + [Parameter()] + [int]$TimeoutSec = 30, [Parameter()] [switch]$AsObject, @@ -20,12 +30,13 @@ function Invoke-AiExplanation { if (-not $AnalysisResult) { if (Get-Command -Name Invoke-SyntaxAnalyzer -ErrorAction SilentlyContinue) { - $analysisResponse = Invoke-SyntaxAnalyzer -PowershellCode $PowershellCode + $analysisResponse = Invoke-SyntaxAnalyzer -PowershellCode $PowershellCode -BaseUri $BaseUri -TimeoutSec $TimeoutSec $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json } else { $analysisBody = @{ PowershellCode = $PowershellCode } | ConvertTo-Json - $analysisResponse = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $analysisBody -ContentType 'application/json' + + $analysisResponse = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $analysisBody -ContentType 'application/json' -TimeoutSec $TimeoutSec $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json } } @@ -36,8 +47,9 @@ function Invoke-AiExplanation { } | ConvertTo-Json -Depth 20 # Note: the function route is `AiExplanation`, but the Functions host is case-insensitive. - $response = Invoke-WebRequest -Uri "$BaseUri/aiexplanation" -Method Post -Body $body -ContentType 'application/json' + $response = Invoke-WebRequest -Uri "$BaseUri/aiexplanation" -Method Post -Body $body -ContentType 'application/json' -TimeoutSec $TimeoutSec + if ($AsObject -or $AiExplanation) { $result = $response.Content | ConvertFrom-Json diff --git a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 index 83f5d2b..959be71 100644 --- a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 @@ -1,16 +1,31 @@ function Invoke-SyntaxAnalyzer { param( + [Parameter(Mandatory)] [string]$PowershellCode, + + [Parameter()] + [string]$BaseUri = 'http://127.0.0.1:7071/api', + + [Parameter()] + [int]$TimeoutSec = 30, + [switch]$Explanations ) $ErrorActionPreference = 'stop' - $body = @{ - PowershellCode=$PowershellCode - } | ConvertTo-Json + $body = @{ PowershellCode = $PowershellCode } | ConvertTo-Json - $response = Invoke-WebRequest -Uri "http://localhost:7071/api/SyntaxAnalyzer" -Method Post -Body $body + # Invoke-WebRequest can emit expensive per-request progress UI, which adds significant overhead + # in tight test loops on Windows. Suppress progress only for this request to keep tests fast. + $originalProgressPreference = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + try { + $response = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $body -ContentType 'application/json' -TimeoutSec $TimeoutSec + } + finally { + $ProgressPreference = $originalProgressPreference + } if ($Explanations) { return $response.Content | ConvertFrom-Json | Select-Object -Expandproperty Explanations diff --git a/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 b/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 index cb62541..155d920 100644 --- a/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 +++ b/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 @@ -4,7 +4,11 @@ param( [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] [string]$Output = 'Normal', [switch]$SkipIntegrationTests, - [switch]$SkipUnitTests + [switch]$SkipUnitTests, + + # By default, keep test runs deterministic and fast by disabling outbound AI calls. + # Opt-in for real AI calls when needed (e.g., manual/local integration). + [switch]$EnableAiCalls ) $c = New-PesterConfiguration -Hashtable @{ @@ -15,12 +19,23 @@ $c = New-PesterConfiguration -Hashtable @{ $PSScriptRoot +# Save/restore environment so running tests doesn't permanently affect the user's session. +$script:originalAiEnabled = $env:AiExplanation__Enabled +$script:originalAiEndpoint = $env:AiExplanation__Endpoint +$script:originalAiApiKey = $env:AiExplanation__ApiKey +$script:originalAiDeploymentName = $env:AiExplanation__DeploymentName + +try { + if (-not $EnableAiCalls) { + $env:AiExplanation__Enabled = 'false' + $env:AiExplanation__Endpoint = '' + $env:AiExplanation__ApiKey = '' + $env:AiExplanation__DeploymentName = '' + } + # Run all code generators Get-ChildItem -Path $PSScriptRoot/../explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName } -$opp = $ProgressPreference -$ProgressPreference = 'SilentlyContinue' - Push-Location -Path $PSScriptRoot/ if (-not $SkipIntegrationTests) { # Integration Tests @@ -44,4 +59,10 @@ Push-Location -Path $PSScriptRoot/ } Pop-Location -$ProgressPreference = $opp \ No newline at end of file +} +finally { + if ($null -ne $script:originalAiEnabled) { $env:AiExplanation__Enabled = $script:originalAiEnabled } else { Remove-Item Env:AiExplanation__Enabled -ErrorAction SilentlyContinue } + if ($null -ne $script:originalAiEndpoint) { $env:AiExplanation__Endpoint = $script:originalAiEndpoint } else { Remove-Item Env:AiExplanation__Endpoint -ErrorAction SilentlyContinue } + if ($null -ne $script:originalAiApiKey) { $env:AiExplanation__ApiKey = $script:originalAiApiKey } else { Remove-Item Env:AiExplanation__ApiKey -ErrorAction SilentlyContinue } + if ($null -ne $script:originalAiDeploymentName) { $env:AiExplanation__DeploymentName = $script:originalAiDeploymentName } else { Remove-Item Env:AiExplanation__DeploymentName -ErrorAction SilentlyContinue } +} \ No newline at end of file diff --git a/explainpowershell.analysisservice.tests/Start-FunctionApp.ps1 b/explainpowershell.analysisservice.tests/Start-FunctionApp.ps1 index ee5a7f2..2637fc7 100644 --- a/explainpowershell.analysisservice.tests/Start-FunctionApp.ps1 +++ b/explainpowershell.analysisservice.tests/Start-FunctionApp.ps1 @@ -32,8 +32,7 @@ if (-not (Test-IsPrerequisitesRunning -ports 7071)) { } until ((IsTimedOut -Start $start -TimeOut $timeOut) -or (Test-IsPrerequisitesRunning -ports 7071)) } catch { - throw $_ - Write-Warning "Error: $($_.Message)" + throw } } From 85806b54b3959efc2aa192ec89dcc4f060c93633 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 17:11:28 +0100 Subject: [PATCH 31/37] Refactor test execution scripts and update launch configurations --- .vscode/launch.json | 2 +- .vscode/settings.json | 4 ---- .vscode/tasks.json | 3 +-- bootstrap.ps1 | 2 +- .../AI-TESTS-README.md | 6 +++--- .../{Start-AllBackendTests.ps1 => Start-AllTests.ps1} | 11 ++++++----- 6 files changed, 12 insertions(+), 16 deletions(-) rename explainpowershell.analysisservice.tests/{Start-AllBackendTests.ps1 => Start-AllTests.ps1} (89%) diff --git a/.vscode/launch.json b/.vscode/launch.json index 8ca6031..6632584 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,7 @@ "name": "Test functionApp", "type": "PowerShell", "request": "launch", - "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1", + "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllTests.ps1", "args": ["-Output Detailed"], "cwd": "${workspaceFolder}", "internalConsoleOptions": "openOnSessionStart", diff --git a/.vscode/settings.json b/.vscode/settings.json index 2cb1987..0dd642f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,10 +4,6 @@ "azureFunctions.projectRuntime": "~4", "debug.internalConsoleOptions": "neverOpen", "azureFunctions.preDeployTask": "publish", - "razor.disableBlazorDebugPrompt": true, - "testExplorer.useNativeTesting": true, - "dotnetCoreExplorer.logpanel": true, - "dotnetCoreExplorer.searchpatterns": "explainpowershell.analysisservice.tests/**/bin/**/explain*tests.dll", "dotnetAcquisitionExtension.existingDotnetPath": [ "/usr/bin/dotnet" ], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1eec41a..60651cb 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -100,8 +100,7 @@ { "label": "startstorageemulator", "command": "${command:azurite.start}", - "isBackground": true, - "problemMatcher": [] + "isBackground": true } ] } diff --git a/bootstrap.ps1 b/bootstrap.ps1 index a067166..91f59c7 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -154,6 +154,6 @@ foreach ($module in $modulesToProcess) { } Write-Host -ForegroundColor Green 'Running tests to see if everything works' -& $PSScriptRoot/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 -Output Detailed +& $PSScriptRoot/explainpowershell.analysisservice.tests/Start-AllTests.ps1 -Output Detailed Write-Host -ForegroundColor Green "Done. You now have the functions 'Get-HelpDatabaseData', 'Invoke-SyntaxAnalyzer' and 'Get-MetaData' available for ease of testing." \ No newline at end of file diff --git a/explainpowershell.analysisservice.tests/AI-TESTS-README.md b/explainpowershell.analysisservice.tests/AI-TESTS-README.md index 56a7813..5da6ccf 100644 --- a/explainpowershell.analysisservice.tests/AI-TESTS-README.md +++ b/explainpowershell.analysisservice.tests/AI-TESTS-README.md @@ -60,13 +60,13 @@ End-to-end integration tests covering: ### All Tests ```powershell cd explainpowershell.analysisservice.tests -.\Start-AllBackendTests.ps1 -Output Detailed +.\Start-AllTests.ps1 -Output Detailed ``` ### Unit Tests Only (C#) ```powershell cd explainpowershell.analysisservice.tests -.\Start-AllBackendTests.ps1 -SkipIntegrationTests -Output Detailed +.\Start-AllTests.ps1 -SkipIntegrationTests -Output Detailed ``` Or using dotnet CLI: @@ -77,7 +77,7 @@ dotnet test --verbosity normal ### Integration Tests Only (Pester) ```powershell cd explainpowershell.analysisservice.tests -.\Start-AllBackendTests.ps1 -SkipUnitTests -Output Detailed +.\Start-AllTests.ps1 -SkipUnitTests -Output Detailed ``` Or using Pester directly: diff --git a/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 b/explainpowershell.analysisservice.tests/Start-AllTests.ps1 similarity index 89% rename from explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 rename to explainpowershell.analysisservice.tests/Start-AllTests.ps1 index 155d920..c8ce7c6 100644 --- a/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 +++ b/explainpowershell.analysisservice.tests/Start-AllTests.ps1 @@ -27,16 +27,16 @@ $script:originalAiDeploymentName = $env:AiExplanation__DeploymentName try { if (-not $EnableAiCalls) { - $env:AiExplanation__Enabled = 'false' + $env:AiExplanation__Enabled = $false $env:AiExplanation__Endpoint = '' $env:AiExplanation__ApiKey = '' $env:AiExplanation__DeploymentName = '' } -# Run all code generators -Get-ChildItem -Path $PSScriptRoot/../explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName } + # Run all code generators + Get-ChildItem -Path $PSScriptRoot/../explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName } -Push-Location -Path $PSScriptRoot/ + Push-Location -Path $PSScriptRoot/ if (-not $SkipIntegrationTests) { # Integration Tests Write-Host -ForegroundColor Cyan "`n####`n#### Starting Integration tests`n" @@ -51,13 +51,14 @@ Push-Location -Path $PSScriptRoot/ # Unit Tests Write-Host -ForegroundColor Cyan "`n####`n#### Starting Unit tests`n" Write-Host -ForegroundColor Green "Building tests.." + Set-Location $PSScriptRoot/.. # we want the verbosity for the build step to be quiet dotnet build --verbosity quiet --nologo Write-Host -ForegroundColor Green "Running tests.." # for the test step we want to be able to adjust the verbosity dotnet test --no-build --nologo --verbosity $Output } -Pop-Location + Pop-Location } finally { From d9d67ca8da917e9c7b7b21659bc2026f8da149d3 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 17:11:55 +0100 Subject: [PATCH 32/37] Add unit tests for SyntaxAnalyzerFunction and refactor dependencies --- .../tests/SyntaxAnalyzerFunction.tests.cs | 169 ++++++++++++++++++ explainpowershell.analysisservice/Program.cs | 7 + .../SyntaxAnalyzer.cs | 11 +- 3 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs diff --git a/explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs b/explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs new file mode 100644 index 0000000..4d5b657 --- /dev/null +++ b/explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs @@ -0,0 +1,169 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using explainpowershell.models; +using ExplainPowershell.SyntaxAnalyzer; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace ExplainPowershell.SyntaxAnalyzer.Tests +{ + [TestFixture] + public class SyntaxAnalyzerFunctionTests + { + [Test] + public async Task Run_DoesNotRequireAzureWebJobsStorage_WhenHelpRepositoryInjected() + { + Environment.SetEnvironmentVariable("AzureWebJobsStorage", null); + + var loggerFactory = LoggerFactory.Create(_ => { }); + var logger = loggerFactory.CreateLogger(); + + var helpRepository = new InMemoryHelpRepository(); + var function = new SyntaxAnalyzerFunction(logger, helpRepository); + + var request = new Code { PowershellCode = "Get-Process" }; + var bodyJson = JsonSerializer.Serialize(request); + + var req = new TestHttpRequestData(bodyJson); + var resp = await function.Run(req); + + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + resp.Body.Position = 0; + using var reader = new StreamReader(resp.Body, Encoding.UTF8); + var responseJson = await reader.ReadToEndAsync(); + + var analysisResult = JsonSerializer.Deserialize(responseJson, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + Assert.That(analysisResult, Is.Not.Null); + Assert.That(analysisResult!.ExpandedCode, Is.Not.Empty); + } + + private sealed class TestHttpRequestData : HttpRequestData + { + private readonly MemoryStream body; + private readonly HttpHeadersCollection headers = new(); + private readonly Uri url; + + public TestHttpRequestData(string bodyJson) + : base(new TestFunctionContext()) + { + body = new MemoryStream(Encoding.UTF8.GetBytes(bodyJson)); + url = new Uri("http://127.0.0.1/api/SyntaxAnalyzer"); + } + + public override Stream Body => body; + + public override HttpHeadersCollection Headers => headers; + + public override IReadOnlyCollection Cookies => Array.Empty(); + + public override Uri Url => url; + + public override IEnumerable Identities => Array.Empty(); + + public override string Method => "POST"; + + public override HttpResponseData CreateResponse() + { + return new TestHttpResponseData(FunctionContext); + } + } + + private sealed class TestHttpResponseData : HttpResponseData + { + public TestHttpResponseData(FunctionContext functionContext) + : base(functionContext) + { + Headers = new HttpHeadersCollection(); + Body = new MemoryStream(); + Cookies = new TestHttpCookies(); + } + + public override HttpStatusCode StatusCode { get; set; } + + public override HttpHeadersCollection Headers { get; set; } + + public override Stream Body { get; set; } + + public override HttpCookies Cookies { get; } + } + + private sealed class TestHttpCookies : HttpCookies + { + private readonly Dictionary cookies = new(StringComparer.OrdinalIgnoreCase); + + public override void Append(IHttpCookie cookie) + { + cookies[cookie.Name] = cookie; + } + + public override void Append(string name, string value) + { + cookies[name] = new TestHttpCookie { Name = name, Value = value }; + } + + public override IHttpCookie CreateNew() + { + return new TestHttpCookie(); + } + + private sealed class TestHttpCookie : IHttpCookie + { + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string? Domain { get; set; } + public string? Path { get; set; } + public DateTimeOffset? Expires { get; set; } + public bool? Secure { get; set; } + public bool? HttpOnly { get; set; } + public SameSite SameSite { get; set; } + public double? MaxAge { get; set; } + } + } + + private sealed class TestFunctionContext : FunctionContext + { + private IDictionary items = new Dictionary(); + + public override string InvocationId { get; } = Guid.NewGuid().ToString("n"); + + public override string FunctionId { get; } = "TestFunction"; + + public override TraceContext TraceContext => throw new NotImplementedException(); + + public override BindingContext BindingContext => throw new NotImplementedException(); + + public override RetryContext RetryContext => null!; + + public override IServiceProvider InstanceServices + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override FunctionDefinition FunctionDefinition => throw new NotImplementedException(); + + public override IDictionary Items + { + get => items; + set => items = value; + } + + public override IInvocationFeatures Features => throw new NotImplementedException(); + } + } +} diff --git a/explainpowershell.analysisservice/Program.cs b/explainpowershell.analysisservice/Program.cs index 22174cd..de16068 100644 --- a/explainpowershell.analysisservice/Program.cs +++ b/explainpowershell.analysisservice/Program.cs @@ -1,4 +1,8 @@ +using Azure.Data.Tables; +using explainpowershell.analysisservice; using explainpowershell.analysisservice.Services; +using ExplainPowershell.SyntaxAnalyzer; +using ExplainPowershell.SyntaxAnalyzer.Repositories; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -24,6 +28,9 @@ { services.AddLogging(); services.Configure(context.Configuration.GetSection(AiExplanationOptions.SectionName)); + + services.AddSingleton(sp => TableClientFactory.Create(Constants.TableStorage.HelpDataTableName)); + services.AddSingleton(sp => new TableStorageHelpRepository(sp.GetRequiredService())); // Register ChatClient factory services.AddSingleton(sp => diff --git a/explainpowershell.analysisservice/SyntaxAnalyzer.cs b/explainpowershell.analysisservice/SyntaxAnalyzer.cs index b38d6d5..56b2def 100644 --- a/explainpowershell.analysisservice/SyntaxAnalyzer.cs +++ b/explainpowershell.analysisservice/SyntaxAnalyzer.cs @@ -2,7 +2,6 @@ using System.Net; using System.Text; using explainpowershell.analysisservice; -using explainpowershell.analysisservice.Services; using explainpowershell.models; using ExplainPowershell.SyntaxAnalyzer.Repositories; using Microsoft.Azure.Functions.Worker; @@ -15,20 +14,18 @@ namespace ExplainPowershell.SyntaxAnalyzer public sealed class SyntaxAnalyzerFunction { private readonly ILogger logger; - private readonly IAiExplanationService aiExplanationService; + private readonly IHelpRepository helpRepository; - public SyntaxAnalyzerFunction(ILogger logger, IAiExplanationService aiExplanationService) + public SyntaxAnalyzerFunction(ILogger logger, IHelpRepository helpRepository) { this.logger = logger; - this.aiExplanationService = aiExplanationService; + this.helpRepository = helpRepository; } [Function("SyntaxAnalyzer")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) { - var tableClient = TableClientFactory.Create(Constants.TableStorage.HelpDataTableName); - var helpRepository = new TableStorageHelpRepository(tableClient); string requestBody; using (var reader = new StreamReader(req.Body)) { @@ -68,7 +65,7 @@ public async Task Run( AnalysisResult analysisResult; try { - var visitor = new AstVisitorExplainer(ast.Extent.Text, helpRepository, logger, tokens); + var visitor = new AstVisitorExplainer(ast.Extent.Text, helpRepository: helpRepository, logger, tokens); ast.Visit(visitor); analysisResult = visitor.GetAnalysisResult(); } From de3c8f7a40693de20d49ce31b07abb853a5f5e71 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 20:18:33 +0100 Subject: [PATCH 33/37] Add UpdateProfile switch to bootstrap script for conditional profile updates --- bootstrap.ps1 | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 91f59c7..99bc131 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -1,6 +1,7 @@ [CmdletBinding()] param( - [Switch]$Force + [Switch]$Force, + [Switch]$UpdateProfile ) $minPwsh = [Version]'7.4' @@ -84,17 +85,41 @@ $commandsToAddToProfile = @( ". $PSScriptRoot/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1" ". $PSScriptRoot/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.ps1" ". $PSScriptRoot/explainpowershell.analysisservice.tests/Get-MetaData.ps1" + ". $PSScriptRoot/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1" ) -if ( !(Test-Path -Path $profile.CurrentUserAllHosts) ) { - New-Item -Path $profile.CurrentUserAllHosts -Force -ItemType file | Out-Null +$isInteractive = $null -ne $Host.UI -and $null -ne $Host.UI.RawUI -and -not $env:CI -and -not $env:GITHUB_ACTIONS + +$profileNeedsUpdate = $false +if (Test-Path -Path $profile.CurrentUserAllHosts) { + $profileContents = Get-Content -Path $profile.CurrentUserAllHosts + if ($null -eq $profileContents -or $profileContents.split("`n") -notcontains $commandsToAddToProfile[0]) { + $profileNeedsUpdate = $true + } +} +else { + $profileNeedsUpdate = $true +} + +$shouldUpdateProfile = $UpdateProfile +if (-not $shouldUpdateProfile -and $profileNeedsUpdate) { + if ($isInteractive) { + $answer = Read-Host "Update PowerShell profile '$($profile.CurrentUserAllHosts)' with helper imports? (y/N)" + $shouldUpdateProfile = $answer -match '^(y|yes)$' + } + else { + Write-Host "Skipping profile update (non-interactive). Re-run with -UpdateProfile to enable." + } } -$profileContents = Get-Content -Path $profile.CurrentUserAllHosts -if ($null -eq $profileContents -or - $profileContents.split("`n") -notcontains $commandsToAddToProfile[0]) { +if ($shouldUpdateProfile -and $profileNeedsUpdate) { + if ( !(Test-Path -Path $profile.CurrentUserAllHosts) ) { + New-Item -Path $profile.CurrentUserAllHosts -Force -ItemType file | Out-Null + } + Write-Host -ForegroundColor Green 'Add settings to PowerShell profile' Add-Content -Path $profile.CurrentUserAllHosts -Value $commandsToAddToProfile + # Copy profile contents to VSCode profile too: Microsoft.VSCode_profile.ps1 Get-Content -Path $profile.CurrentUserAllHosts | Set-Content -Path ($profile.CurrentUserAllHosts From b9b95f48fcaa0f0791cdd9eb3ea9b63ea5ef53e6 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 20:22:41 +0100 Subject: [PATCH 34/37] Refactor GenerateTree method for improved clarity and performance --- explainpowershell.frontend/Tree.cs | 124 ++++++++++++++++++++++++----- 1 file changed, 104 insertions(+), 20 deletions(-) diff --git a/explainpowershell.frontend/Tree.cs b/explainpowershell.frontend/Tree.cs index 249864e..c4bda3e 100644 --- a/explainpowershell.frontend/Tree.cs +++ b/explainpowershell.frontend/Tree.cs @@ -7,6 +7,8 @@ namespace explainpowershell.frontend { internal static class GenericTree { + private const int MaxDepth = 256; + /// /// Generates a MudBlazor-compatible tree from a flat collection. /// @@ -16,40 +18,122 @@ public static List> GenerateTree( Func parentIdSelector, K rootId = default) { - var nodes = new List>(); - - bool IsRoot(T item) + static K CanonicalizeKey(K key) { - var parentId = parentIdSelector(item); - - // Special-case string keys: treat both null and empty as root. if (typeof(K) == typeof(string)) { - var parentString = parentId as string; - var rootString = rootId as string; + var s = key as string; + return (K)(object)(s ?? string.Empty); + } + + return key; + } + + var comparer = EqualityComparer.Default; + var items = collection as IList ?? collection.ToList(); + + // Group children by parent id in one pass (O(n)). + var childrenByParent = new Dictionary>(comparer); + foreach (var item in items) + { + var parentKey = CanonicalizeKey(parentIdSelector(item)); + if (!childrenByParent.TryGetValue(parentKey, out var list)) + { + list = new List(); + childrenByParent[parentKey] = list; + } - if (string.IsNullOrEmpty(rootString)) + list.Add(item); + } + + var rootKey = CanonicalizeKey(rootId); + if (!childrenByParent.TryGetValue(rootKey, out var rootItems) || rootItems.Count == 0) + { + // Special-case: when rootId is null/empty string, treat both null and empty as root. + if (typeof(K) == typeof(string) && string.IsNullOrEmpty(rootKey as string)) + { + if (childrenByParent.TryGetValue((K)(object)string.Empty, out var emptyRootItems)) { - return string.IsNullOrEmpty(parentString); + rootItems = emptyRootItems; } + } - return string.Equals(parentString, rootString, StringComparison.Ordinal); + if (rootItems is null || rootItems.Count == 0) + { + return new List>(); } + } + + var nodes = new List>(rootItems.Count); + + var stack = new Stack<(TreeItemData Node, K Id, int Depth, HashSet Path)>(); + + foreach (var item in rootItems) + { + var nodeId = CanonicalizeKey(idSelector(item)); + var node = new TreeItemData + { + Value = item, + Expanded = true + }; - return EqualityComparer.Default.Equals(parentId, rootId); + nodes.Add(node); + stack.Push((node, nodeId, 1, new HashSet(comparer) { nodeId })); } - foreach (var item in collection.Where(IsRoot)) + while (stack.Count > 0) { - var children = collection.GenerateTree(idSelector, parentIdSelector, idSelector(item)); + var (node, id, depth, path) = stack.Pop(); - nodes.Add(new TreeItemData + if (depth >= MaxDepth) { - Value = item, - Expanded = true, - Expandable = children.Count > 0, - Children = children.Count == 0 ? null : children - }); + node.Children = null; + node.Expandable = false; + continue; + } + + if (!childrenByParent.TryGetValue(id, out var children) || children.Count == 0) + { + node.Children = null; + node.Expandable = false; + continue; + } + + var childNodes = new List>(children.Count); + + foreach (var child in children) + { + var childId = CanonicalizeKey(idSelector(child)); + if (path.Contains(childId)) + { + continue; + } + + childNodes.Add(new TreeItemData + { + Value = child, + Expanded = true + }); + } + + if (childNodes.Count == 0) + { + node.Children = null; + node.Expandable = false; + continue; + } + + node.Children = childNodes; + node.Expandable = true; + + // Push in reverse so the first child is processed first. + for (var i = childNodes.Count - 1; i >= 0; i--) + { + var childNode = childNodes[i]; + var childId = CanonicalizeKey(idSelector(childNode.Value)); + var childPath = new HashSet(path, comparer) { childId }; + stack.Push((childNode, childId, depth + 1, childPath)); + } } return nodes; From ed6ba300f22656eea5843057f8e91a4c46dbbb97 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 20:24:56 +0100 Subject: [PATCH 35/37] Implement SyntaxAnalyzerClient and related classes for improved code analysis and AI explanation handling --- .../Clients/AiExplanationResponse.cs | 8 ++ .../Clients/ApiCallResult.cs | 36 +++++++ .../Clients/ISyntaxAnalyzerClient.cs | 15 +++ .../Clients/SyntaxAnalyzerClient.cs | 94 +++++++++++++++++++ .../Pages/Index.razor.cs | 46 ++------- explainpowershell.frontend/Program.cs | 3 + 6 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 explainpowershell.frontend/Clients/AiExplanationResponse.cs create mode 100644 explainpowershell.frontend/Clients/ApiCallResult.cs create mode 100644 explainpowershell.frontend/Clients/ISyntaxAnalyzerClient.cs create mode 100644 explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs diff --git a/explainpowershell.frontend/Clients/AiExplanationResponse.cs b/explainpowershell.frontend/Clients/AiExplanationResponse.cs new file mode 100644 index 0000000..9e9ac8b --- /dev/null +++ b/explainpowershell.frontend/Clients/AiExplanationResponse.cs @@ -0,0 +1,8 @@ +namespace explainpowershell.frontend.Clients; + +public sealed class AiExplanationResponse +{ + public string AiExplanation { get; set; } = string.Empty; + + public string ModelName { get; set; } = string.Empty; +} diff --git a/explainpowershell.frontend/Clients/ApiCallResult.cs b/explainpowershell.frontend/Clients/ApiCallResult.cs new file mode 100644 index 0000000..ab5489a --- /dev/null +++ b/explainpowershell.frontend/Clients/ApiCallResult.cs @@ -0,0 +1,36 @@ +#nullable enable + +using System.Net; + +namespace explainpowershell.frontend.Clients; + +public sealed class ApiCallResult +{ + private ApiCallResult() { } + + public bool IsSuccess { get; private init; } + + public HttpStatusCode StatusCode { get; private init; } + + public T? Value { get; private init; } + + public string? ErrorMessage { get; private init; } + + public static ApiCallResult Success(T value, HttpStatusCode statusCode = HttpStatusCode.OK) + => new() + { + IsSuccess = true, + StatusCode = statusCode, + Value = value, + ErrorMessage = null + }; + + public static ApiCallResult Failure(string? errorMessage, HttpStatusCode statusCode) + => new() + { + IsSuccess = false, + StatusCode = statusCode, + Value = default, + ErrorMessage = errorMessage + }; +} diff --git a/explainpowershell.frontend/Clients/ISyntaxAnalyzerClient.cs b/explainpowershell.frontend/Clients/ISyntaxAnalyzerClient.cs new file mode 100644 index 0000000..fe98453 --- /dev/null +++ b/explainpowershell.frontend/Clients/ISyntaxAnalyzerClient.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; +using explainpowershell.models; + +namespace explainpowershell.frontend.Clients; + +public interface ISyntaxAnalyzerClient +{ + Task> AnalyzeAsync(Code code, CancellationToken cancellationToken = default); + + Task> GetAiExplanationAsync( + Code code, + AnalysisResult analysisResult, + CancellationToken cancellationToken = default); +} diff --git a/explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs b/explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs new file mode 100644 index 0000000..711f4df --- /dev/null +++ b/explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs @@ -0,0 +1,94 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using explainpowershell.models; + +namespace explainpowershell.frontend.Clients; + +public sealed class SyntaxAnalyzerClient : ISyntaxAnalyzerClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient http; + + public SyntaxAnalyzerClient(HttpClient http) + { + this.http = http; + } + + public async Task> AnalyzeAsync(Code code, CancellationToken cancellationToken = default) + { + try + { + using var response = await http.PostAsJsonAsync("SyntaxAnalyzer", code, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var reason = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return ApiCallResult.Failure(reason, response.StatusCode); + } + + var analysisResult = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); + if (analysisResult is null) + { + return ApiCallResult.Failure("Empty response from SyntaxAnalyzer.", response.StatusCode); + } + + return ApiCallResult.Success(analysisResult, response.StatusCode); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return ApiCallResult.Failure($"Request failed: {ex.Message}", HttpStatusCode.ServiceUnavailable); + } + } + + public async Task> GetAiExplanationAsync( + Code code, + AnalysisResult analysisResult, + CancellationToken cancellationToken = default) + { + try + { + var aiRequest = new + { + PowershellCode = code.PowershellCode, + AnalysisResult = analysisResult + }; + + using var response = await http.PostAsJsonAsync("AiExplanation", aiRequest, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return ApiCallResult.Failure(null, response.StatusCode); + } + + var aiResponse = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); + if (aiResponse is null) + { + return ApiCallResult.Success(new AiExplanationResponse(), response.StatusCode); + } + + return ApiCallResult.Success(aiResponse, response.StatusCode); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + // AI is optional; treat failures as empty response. + return ApiCallResult.Success(new AiExplanationResponse(), HttpStatusCode.OK); + } + } +} diff --git a/explainpowershell.frontend/Pages/Index.razor.cs b/explainpowershell.frontend/Pages/Index.razor.cs index f781065..8181b93 100644 --- a/explainpowershell.frontend/Pages/Index.razor.cs +++ b/explainpowershell.frontend/Pages/Index.razor.cs @@ -1,22 +1,20 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using System.Collections.Generic; -using System.Net.Http; -using System.Text.Json; using System.Threading.Tasks; using System.Threading; using explainpowershell.models; using System.Linq; -using System.Net.Http.Json; using MudBlazor; using System; +using explainpowershell.frontend.Clients; namespace explainpowershell.frontend.Pages { public partial class Index : ComponentBase { [Inject] - private HttpClient Http { get; set; } + private ISyntaxAnalyzerClient SyntaxAnalyzerClient { get; set; } private string TitleMargin { get; set; }= "mt-16"; private Dictionary SyntaxPopoverIsOpen { get; set; }= new(); private Dictionary CommandDetailsPopoverIsOpen { get; set; } = new(); @@ -119,26 +117,16 @@ private async Task DoSearch() Waiting = true; var code = new Code() { PowershellCode = InputValue }; - HttpResponseMessage temp; - try { - temp = await Http.PostAsJsonAsync("SyntaxAnalyzer", code); - } - catch { - RequestHasError = true; - Waiting = false; - ReasonPhrase = "oops!"; - return; - } - - if (!temp.IsSuccessStatusCode) + var analyzeResult = await SyntaxAnalyzerClient.AnalyzeAsync(code); + if (!analyzeResult.IsSuccess || analyzeResult.Value is null) { RequestHasError = true; Waiting = false; - ReasonPhrase = await temp.Content.ReadAsStringAsync(); + ReasonPhrase = string.IsNullOrWhiteSpace(analyzeResult.ErrorMessage) ? "oops!" : analyzeResult.ErrorMessage; return; } - var analysisResult = await JsonSerializer.DeserializeAsync(temp.Content.ReadAsStream()); + var analysisResult = analyzeResult.Value; if (!string.IsNullOrEmpty(analysisResult.ParseErrorMessage)) { @@ -182,28 +170,20 @@ private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResu try { - var aiRequest = new - { - PowershellCode = code.PowershellCode, - AnalysisResult = analysisResult - }; - - var response = await Http.PostAsJsonAsync("AiExplanation", aiRequest, cancellationToken); + var aiResult = await SyntaxAnalyzerClient.GetAiExplanationAsync(code, analysisResult, cancellationToken); if (searchId != _activeSearchId || _disposed) { return; } - if (response.IsSuccessStatusCode) + if (aiResult.IsSuccess && aiResult.Value is not null) { - var aiResult = await JsonSerializer.DeserializeAsync(response.Content.ReadAsStream(), cancellationToken: cancellationToken); - AiExplanation = aiResult?.AiExplanation ?? string.Empty; - AiModelName = aiResult?.ModelName ?? string.Empty; + AiExplanation = aiResult.Value.AiExplanation ?? string.Empty; + AiModelName = aiResult.Value.ModelName ?? string.Empty; } else { - // Silently fail - AI explanation is optional AiExplanation = string.Empty; AiModelName = string.Empty; } @@ -232,11 +212,5 @@ private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResu private string _inputValue; private string AiModelName { get; set; } - - private class AiExplanationResponse - { - public string AiExplanation { get; set; } = string.Empty; - public string ModelName { get; set; } - } } } \ No newline at end of file diff --git a/explainpowershell.frontend/Program.cs b/explainpowershell.frontend/Program.cs index 8fa58d2..453067b 100644 --- a/explainpowershell.frontend/Program.cs +++ b/explainpowershell.frontend/Program.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using explainpowershell.frontend.Clients; using System; using System.Collections.Generic; using System.Net.Http; @@ -22,6 +23,8 @@ public static async Task Main(string[] args) BaseAddress = new Uri( builder.Configuration.GetValue("BaseAddress"))}); + builder.Services.AddScoped(); + builder.Services.AddMudServices(); await builder.Build().RunAsync(); From 8a65db6dee38621718722f2b58b1e6d5d865ab00 Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 20:34:39 +0100 Subject: [PATCH 36/37] Update tour descriptions and line references for improved clarity in AI explanation and help collector features --- .../high-level-tour-of-the-application.tour | 12 +++++----- .../tour-of-the-ai-explanation-feature.tour | 24 +++++++++---------- .tours/tour-of-the-help-collector.tour | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.tours/high-level-tour-of-the-application.tour b/.tours/high-level-tour-of-the-application.tour index 1931daa..217e20e 100644 --- a/.tours/high-level-tour-of-the-application.tour +++ b/.tours/high-level-tour-of-the-application.tour @@ -10,12 +10,12 @@ { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "This is where the PowerShell oneliner is sent to, the SyntaxAnalyzer endpoint, an Azure FunctionApp. \n\nWe use PowerShell's own parsing engine to parse the oneliner that was sent, the parser creates a so called Abstract Syntax Tree (AST), a logical representation of the oneliner in a convenient tree format that we can then 'walk' in an automated fashion. ", - "line": 28 + "line": 25 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "The AST is analyzed here, via the AstVisitorExplainer. It basically looks at all the logical elements of the oneliner and generates an explanation for each of them.\n\nWe will have a brief look there as well, to get the basic idea.", - "line": 59 + "line": 68 }, { "file": "explainpowershell.analysisservice/AstVisitorExplainer_statements.cs", @@ -35,22 +35,22 @@ { "file": "explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs", "description": "Once we are done going through all the elements in the AST, this method gets called, and we return all explanations and a little metadata.", - "line": 28 + "line": 29 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "if there were any parse errors, we get the message for that here.", - "line": 65 + "line": 79 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "We send our list of explanations, and any parse error messages back to the frontend.", - "line": 69 + "line": 88 }, { "file": "explainpowershell.frontend/Pages/Index.razor.cs", "description": "This is where we re-create the AST tree a little bit, and generate our own tree, to display everything in a nice tree view, ordered logically. ", - "line": 137 + "line": 152 }, { "file": "explainpowershell.frontend/Pages/Index.razor", diff --git a/.tours/tour-of-the-ai-explanation-feature.tour b/.tours/tour-of-the-ai-explanation-feature.tour index 0787f3f..dddbf3f 100644 --- a/.tours/tour-of-the-ai-explanation-feature.tour +++ b/.tours/tour-of-the-ai-explanation-feature.tour @@ -5,37 +5,37 @@ { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "The AI explanation is intentionally *not* generated during the main AST analysis call.\n\nThis endpoint parses the PowerShell input into an AST and walks it with `AstVisitorExplainer` to produce the structured explanation nodes (the data that becomes the tree you see in the UI).\n\nThat AST-based result is returned quickly so the UI can render immediately.", - "line": 59 + "line": 25 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "Key design choice: the AST endpoint always sets `AiExplanation` to an empty string.\n\nThe AI summary is fetched via a separate endpoint *after* the AST explanation tree is available. This keeps the main analysis deterministic and avoids coupling UI responsiveness to an external AI call.", - "line": 73 + "line": 82 }, { "file": "explainpowershell.frontend/Pages/Index.razor.cs", "description": "Frontend starts by calling the AST analysis endpoint (`SyntaxAnalyzer`).\n\nThis request returns the expanded code plus a flat list of explanation nodes (each has an `Id` and optional `ParentId`).", - "line": 97 + "line": 120 }, { "file": "explainpowershell.frontend/Pages/Index.razor.cs", "description": "Once the AST analysis result comes back, the frontend builds the explanation tree (based on `Id` / `ParentId`) for display in the `MudTreeView`.\n\nAt this point, the user already has a useful explanation from the AST visitor.", - "line": 137 + "line": 152 }, { "file": "explainpowershell.frontend/Pages/Index.razor.cs", "description": "Immediately after the tree is available, the AI request is kicked off *in the background*.\n\nNotice the fire-and-forget pattern (`_ = ...`) so the AST UI is not blocked while the AI endpoint runs.", - "line": 142 + "line": 157 }, { "file": "explainpowershell.frontend/Pages/Index.razor.cs", "description": "`LoadAiExplanationAsync` constructs a payload containing:\n- the original PowerShell code\n- the full AST analysis result\n\nThen it POSTs to the backend `AiExplanation` endpoint.\n\nIf the call fails, the UI silently continues without the AI summary (AI is treated as optional).", - "line": 145 + "line": 160 }, { - "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "file": "explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs", "description": "This is the actual HTTP call that requests the AI explanation.\n\nIt sends both the code and the AST result so the backend can build a prompt that is grounded in the already-produced explanation nodes and help metadata.", - "line": 158 + "line": 69 }, { "file": "explainpowershell.frontend/Pages/Index.razor", @@ -55,7 +55,7 @@ { "file": "explainpowershell.analysisservice/Program.cs", "description": "The AI feature is wired up through DI.\n\nA `ChatClient` is registered only when configuration is present (`Endpoint`, `ApiKey`, `DeploymentName`) and `Enabled` is true. If not configured, the factory returns `null` and AI remains disabled.", - "line": 29 + "line": 36 }, { "file": "explainpowershell.analysisservice/Services/AiExplanationOptions.cs", @@ -70,17 +70,17 @@ { "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", "description": "Prompt grounding & size safety: the service builds a ‘slim’ version of the analysis result and serializes it to JSON for the prompt.\n\nIf the JSON is too large, it progressively reduces details (e.g., trims help fields, limits explanation count) so the request stays under `MaxPayloadCharacters`.", - "line": 49 + "line": 57 }, { "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", "description": "Finally, the service builds a chat message list and calls `CompleteChatAsync`.\n\nThe response is reduced to a best-effort single sentence (per the prompt), and the model name is returned too so the UI can display what model produced the summary.", - "line": 82 + "line": 92 }, { "file": "explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1", "description": "The AI endpoint has Pester integration tests.\n\nNotably, there’s coverage for:\n- accepting a valid `AnalysisResult` payload\n- handling very large payloads (50KB+) gracefully (exercise the payload reduction path)\n- an end-to-end workflow: call SyntaxAnalyzer first, then call AiExplanation with that output.", - "line": 33 + "line": 3 } ] } diff --git a/.tours/tour-of-the-help-collector.tour b/.tours/tour-of-the-help-collector.tour index 22d155a..db3dbae 100644 --- a/.tours/tour-of-the-help-collector.tour +++ b/.tours/tour-of-the-help-collector.tour @@ -15,7 +15,7 @@ { "file": ".github/workflows/add_module_help.yml", "description": "After the module is installed, the `helpcollector.ps1` script is run. This script will get all the relevant help information from the module. \n\nThe output is saved to json, and a check is done to see if there actually is any data in the file. ", - "line": 47 + "line": 39 }, { "file": ".github/workflows/add_module_help.yml", From 707dac22ca7c269b3eb47c8d100efbd9f27f3f5c Mon Sep 17 00:00:00 2001 From: Jos Koelewijn Date: Sat, 13 Dec 2025 20:59:19 +0100 Subject: [PATCH 37/37] Refactor launch.json and tasks.json for consistency; update launchSettings.json to disable browser launch for DefaultBlazor.Wasm --- .vscode/launch.json | 30 +++++++++---------- .vscode/tasks.json | 1 + .../Properties/launchSettings.json | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 6632584..69509c4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,8 +2,8 @@ "version": "0.2.0", "compounds": [ { - "name": "debug solution", - "configurations": ["debug functionApp", "debug wasm"], + "name": "Debug solution", + "configurations": ["Debug functionApp", "Debug wasm"], "outFiles": [ "./explainpowershell.analysisservice/SyntaxAnalyzer.cs", "./explainpowershell.frontend/Pages/Index.razor" @@ -12,25 +12,15 @@ ], "configurations": [ { - "name": "Test functionApp", - "type": "PowerShell", - "request": "launch", - "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllTests.ps1", - "args": ["-Output Detailed"], - "cwd": "${workspaceFolder}", - "internalConsoleOptions": "openOnSessionStart", - "preLaunchTask": "startstorageemulator" - }, - { - "name": "debug functionApp", + "name": "Debug functionApp", "type": "coreclr", "request": "attach", "processId": "${command:azureFunctions.pickProcess}", "internalConsoleOptions": "neverOpen", - "preLaunchTask": "startstorageemulator" + "preLaunchTask": "func: host start" }, { - "name": "debug wasm", + "name": "Debug wasm", "type": "blazorwasm", "request": "launch", "cwd": "${workspaceFolder}/explainpowershell.frontend/", @@ -43,5 +33,15 @@ "script": "${file}", "cwd": "${file}" }, + { + "name": "Test solution", + "type": "PowerShell", + "request": "launch", + "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllTests.ps1", + "args": ["-Output Detailed"], + "cwd": "${workspaceFolder}", + "internalConsoleOptions": "openOnSessionStart", + "preLaunchTask": "startstorageemulator" + } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 60651cb..86d55bb 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -58,6 +58,7 @@ "problemMatcher": "$msCompile" }, { + "label": "func: host start", "type": "func", "options": { "cwd": "${workspaceFolder}/explainpowershell.analysisservice/" diff --git a/explainpowershell.frontend/Properties/launchSettings.json b/explainpowershell.frontend/Properties/launchSettings.json index 7c0e339..8a1b6f6 100644 --- a/explainpowershell.frontend/Properties/launchSettings.json +++ b/explainpowershell.frontend/Properties/launchSettings.json @@ -18,7 +18,7 @@ }, "DefaultBlazor.Wasm": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" },