diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..739bb9c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,65 @@ +# 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) +- 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. + - 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. +- Focus on behavior verification over implementation details. +- 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. +- 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. +- 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. diff --git a/.tours/high-level-tour-of-the-application.tour b/.tours/high-level-tour-of-the-application.tour index 089e37c..217e20e 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": 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": 53 + "line": 68 }, { - "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": 29 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "if there were any parse errors, we get the message for that here.", - "line": 61 + "line": 79 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "We send our list of explanations, and any parse error messages back to the frontend.", - "line": 65 + "line": 88 }, { - "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": 152 }, { "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-ai-explanation-feature.tour b/.tours/tour-of-the-ai-explanation-feature.tour new file mode 100644 index 0000000..dddbf3f --- /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": 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": 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": 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": 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": 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": 160 + }, + { + "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": 69 + }, + { + "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": 36 + }, + { + "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": 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": 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": 3 + } + ] +} 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..db3dbae 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": 39 }, { "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", 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" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 1aa9d64..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,22 +12,15 @@ ], "configurations": [ { - "name": "Test functionApp", - "type": "PowerShell", - "request": "launch", - "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1", - "cwd": "${workspaceFolder}", - "internalConsoleOptions": "openOnSessionStart" - }, - { - "name": "debug functionApp", + "name": "Debug functionApp", "type": "coreclr", "request": "attach", "processId": "${command:azureFunctions.pickProcess}", - "internalConsoleOptions": "neverOpen" + "internalConsoleOptions": "neverOpen", + "preLaunchTask": "func: host start" }, { - "name": "debug wasm", + "name": "Debug wasm", "type": "blazorwasm", "request": "launch", "cwd": "${workspaceFolder}/explainpowershell.frontend/", @@ -40,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/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 9a05dfc..86d55bb 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", @@ -58,6 +58,7 @@ "problemMatcher": "$msCompile" }, { + "label": "func: host start", "type": "func", "options": { "cwd": "${workspaceFolder}/explainpowershell.analysisservice/" @@ -100,8 +101,7 @@ { "label": "startstorageemulator", "command": "${command:azurite.start}", - "isBackground": true, - "problemMatcher": [] + "isBackground": true } ] } 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 diff --git a/bootstrap.ps1 b/bootstrap.ps1 index a067166..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 @@ -154,6 +179,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/Get-HelpDatabaseData.Tests.ps1 b/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 index 299d857..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://docs.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.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/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 = @' + + +
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 = @' + + + + + +Throws an exception.
+ + +'@ + + Get-SynopsisFromHtml -Html $html -Cmd 'throw' | Should -Be 'Throws an exception.' + } +} diff --git a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 index 6220808..3415338 100644 --- a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 @@ -3,15 +3,25 @@ using namespace Microsoft.PowerShell.Commands Describe "AI Explanation Integration Tests" { BeforeAll { - . $PSScriptRoot/Invoke-SyntaxAnalyzer.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 { @@ -32,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" @@ -56,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" ` @@ -70,9 +78,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 +97,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 -BaseUri $script:baseUri $response.StatusCode | Should -Be 200 $content = $response.Content | ConvertFrom-Json @@ -121,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,22 +131,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 -BaseUri $script:baseUri # Assert $response.StatusCode | Should -Be 200 @@ -169,26 +161,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 -BaseUri $script:baseUri # Assert $response.StatusCode | Should -Be 200 @@ -198,9 +188,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 +197,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 -BaseUri $script:baseUri # Assert $content = $response.Content | ConvertFrom-Json @@ -228,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" { @@ -266,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" ` @@ -284,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" ` @@ -298,21 +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 - $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 -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 new file mode 100644 index 0000000..0efe126 --- /dev/null +++ b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 @@ -0,0 +1,64 @@ +# 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)] + [string]$PowershellCode, + + [Parameter()] + [object]$AnalysisResult, + + [Parameter()] + [string]$BaseUri = 'http://127.0.0.1:7071/api', + + [Parameter()] + [int]$TimeoutSec = 30, + + [Parameter()] + [switch]$AsObject, + + [Parameter()] + [switch]$AiExplanation + ) + + $ErrorActionPreference = 'stop' + + if (-not $AnalysisResult) { + if (Get-Command -Name Invoke-SyntaxAnalyzer -ErrorAction SilentlyContinue) { + $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' -TimeoutSec $TimeoutSec + $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' -TimeoutSec $TimeoutSec + + if ($AsObject -or $AiExplanation) { + $result = $response.Content | ConvertFrom-Json + + if ($AiExplanation) { + return $result.AiExplanation + } + + return $result + } + + return $response +} diff --git a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 index 9e97969..a3607f2 100644 --- a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 @@ -15,6 +15,43 @@ 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 "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/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 deleted file mode 100644 index cb62541..0000000 --- a/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -[CmdletBinding()] -param( - [Parameter()] - [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] - [string]$Output = 'Normal', - [switch]$SkipIntegrationTests, - [switch]$SkipUnitTests -) - -$c = New-PesterConfiguration -Hashtable @{ - Output = @{ - Verbosity = $Output - } -} - -$PSScriptRoot - -# 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 - Write-Host -ForegroundColor Cyan "`n####`n#### Starting Integration tests`n" - . ./Test-IsPrerequisitesRunning.ps1 - $werePrerequisitesAlreadyRunning = Test-IsPrerequisitesRunning -ports 7071 - Invoke-Pester -Configuration $c - if (-not $werePrerequisitesAlreadyRunning) { - Get-Job | Stop-Job -PassThru | Remove-Job -Force - } - } - if (-not $SkipUnitTests) { - # Unit Tests - Write-Host -ForegroundColor Cyan "`n####`n#### Starting Unit tests`n" - Write-Host -ForegroundColor Green "Building tests.." - # 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 - -$ProgressPreference = $opp \ No newline at end of file diff --git a/explainpowershell.analysisservice.tests/Start-AllTests.ps1 b/explainpowershell.analysisservice.tests/Start-AllTests.ps1 new file mode 100644 index 0000000..c8ce7c6 --- /dev/null +++ b/explainpowershell.analysisservice.tests/Start-AllTests.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +param( + [Parameter()] + [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] + [string]$Output = 'Normal', + [switch]$SkipIntegrationTests, + [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 @{ + Output = @{ + Verbosity = $Output + } +} + +$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 } + + Push-Location -Path $PSScriptRoot/ + if (-not $SkipIntegrationTests) { + # Integration Tests + Write-Host -ForegroundColor Cyan "`n####`n#### Starting Integration tests`n" + . ./Test-IsPrerequisitesRunning.ps1 + $werePrerequisitesAlreadyRunning = Test-IsPrerequisitesRunning -ports 7071 + Invoke-Pester -Configuration $c + if (-not $werePrerequisitesAlreadyRunning) { + Get-Job | Stop-Job -PassThru | Remove-Job -Force + } + } + if (-not $SkipUnitTests) { + # 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 + +} +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 } } 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 @@("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))
{
@@ -139,37 +154,44 @@ 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
{
- var aiRequest = new
- {
- PowershellCode = code.PowershellCode,
- AnalysisResult = analysisResult
- };
+ var aiResult = await SyntaxAnalyzerClient.GetAiExplanationAsync(code, analysisResult, cancellationToken);
- var response = await Http.PostAsJsonAsync("AiExplanation", aiRequest);
+ if (searchId != _activeSearchId || _disposed)
+ {
+ return;
+ }
- if (response.IsSuccessStatusCode)
+ if (aiResult.IsSuccess && aiResult.Value is not null)
{
- var aiResult = await JsonSerializer.DeserializeAsync(response.Content.ReadAsStream());
- 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;
}
}
+ 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,18 +202,15 @@ private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResu
}
finally
{
- AiExplanationLoading = false;
- StateHasChanged();
+ if (searchId == _activeSearchId && !_disposed)
+ {
+ AiExplanationLoading = false;
+ await InvokeAsync(StateHasChanged);
+ }
}
}
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();
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"
},
diff --git a/explainpowershell.frontend/Tree.cs b/explainpowershell.frontend/Tree.cs
index 8502e85..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,19 +18,122 @@ public static List> GenerateTree(
Func parentIdSelector,
K rootId = default)
{
- var nodes = new List>();
+ static K CanonicalizeKey(K key)
+ {
+ if (typeof(K) == typeof(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;
+ }
- foreach (var item in collection.Where(c => EqualityComparer.Default.Equals(parentIdSelector(c), rootId)))
+ list.Add(item);
+ }
+
+ var rootKey = CanonicalizeKey(rootId);
+ if (!childrenByParent.TryGetValue(rootKey, out var rootItems) || rootItems.Count == 0)
{
- var children = collection.GenerateTree(idSelector, parentIdSelector, idSelector(item));
+ // 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))
+ {
+ rootItems = emptyRootItems;
+ }
+ }
- nodes.Add(new TreeItemData
+ 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,
- Expandable = children.Count > 0,
- Children = children.Count == 0 ? null : children
- });
+ Expanded = true
+ };
+
+ nodes.Add(node);
+ stack.Push((node, nodeId, 1, new HashSet(comparer) { nodeId }));
+ }
+
+ while (stack.Count > 0)
+ {
+ var (node, id, depth, path) = stack.Pop();
+
+ if (depth >= MaxDepth)
+ {
+ 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;
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/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/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.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
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.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/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; }
}
}
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}
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
diff --git a/tools/bump_versions.ps1 b/tools/bump_versions.ps1
index bd6e6ba..43e44f3 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,39 +61,18 @@ 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
- 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"
## 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 +83,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 +93,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 +103,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