diff --git a/CHANGELOG.md b/CHANGELOG.md index 28eaaa95..be67e852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `copilot-instructions.md` documenting build/test/quality commands, the mandatory `./build.ps1` entry point, high-level architecture, key conventions (changelog discipline, `-ErrorAction 'Ignore'` preference, cross-platform/cross-version PS5.1+PS7 support), guidance for running long builds without hanging the agent shell (always tee output to a log file), and how to extract test failures from the Pester NUnit XML and HQRM CliXml result files. - Per-area `instructions/*.instructions.md` files for public functions, private functions, Plaster templates, build tasks, and Pester tests. - `agents/sampler-maintainer.md` agent definition and the `skills/validate-changes` skill that picks the smallest useful test scope and surfaces XML-based failure diagnostics. +- Tab-completion for the `-Tasks` parameter of the scaffolded `Build.ps1`. + Candidates include workflow aliases declared under `BuildWorkflow:` in + `build.yaml`, tasks defined under `./.build/**/*.ps1`, tasks imported from + modules under `./output/RequiredModules/**/*.build.ps1`, and the Invoke-Build + `?` help token. Candidates are emitted in YAML / file declaration order + (workflow aliases first, then individual tasks). The YAML parser extracts + the `BuildWorkflow:` block up to the next root-level key and tracks brace + depth so it correctly handles in-yaml scriptblock values (single- or + multi-line, including nested braces) without picking up identifiers from + inside the scriptblock body. The task-discovery regex anchors the captured + name to a real word boundary, so identifiers that only happen to appear + after the literal text `task ` inside a comment block (for example a + `task parameter $foo` description in comment-based help) are no longer + surfaced as completion candidates. The completer is self-contained and + works in a fresh clone before `Resolve-Dependency` has ever run. ### Changed @@ -993,7 +1008,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed #192 where the `Build-Module` command from module builder returns - a rooted path (sometimes). + a rooted path (sometimes). ## [0.107.0] - 2020-09-07 diff --git a/GitVersion.yml b/GitVersion.yml index 1ef14602..2f277fa5 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,5 +1,5 @@ mode: ContinuousDelivery -next-version: 0.90 +next-version: 0.90.0 major-version-bump-message: '\s?(breaking|major|breaking\schange)' minor-version-bump-message: '(adds?|features?|minor)\b' patch-version-bump-message: '\s?(fix|patch)' diff --git a/Sampler/Templates/Build/build.ps1 b/Sampler/Templates/Build/build.ps1 index f005a4e0..ba979162 100644 --- a/Sampler/Templates/Build/build.ps1 +++ b/Sampler/Templates/Build/build.ps1 @@ -75,6 +75,170 @@ param ( [Parameter(Position = 0)] + [ArgumentCompleter({ + param + ( + [Parameter()] + $commandName, + + [Parameter()] + $parameterName, + + [Parameter()] + $wordToComplete, + + [Parameter()] + $commandAst, + + [Parameter()] + $fakeBoundParameters + ) + + # $PSScriptRoot is not reliable inside an ArgumentCompleter script block; + # derive the script root defensively, falling back to the current location. + $scriptRoot = if ($PSCommandPath) + { + Split-Path -Path $PSCommandPath -Parent + } + else + { + $PWD.Path + } + + # Use an ordered hashtable so YAML / file declaration order is preserved + # (case-insensitive deduplication via Contains lookup). + $tasks = [ordered] @{ } + + # 1. Workflow aliases declared under BuildWorkflow in build.yaml. + # These are the high-level entry points users typically tab-complete + # to (build, docs, pack, ...), so they appear first. + $buildYaml = Join-Path -Path $scriptRoot -ChildPath 'build.yaml' + if (Test-Path -Path $buildYaml) + { + $yamlText = Get-Content -LiteralPath $buildYaml -Raw -ErrorAction Ignore + + if ($yamlText) + { + # Extract everything from "^BuildWorkflow:" up to (but not + # including) the next root-level YAML key (a non-indented + # line that starts with a word character) or end-of-file. + $blockMatch = [regex]::Match( + $yamlText, + '(?ms)^BuildWorkflow\s*:\s*\r?\n(?.*?)(?=^\w|\Z)' + ) + + if ($blockMatch.Success) + { + $bodyLines = $blockMatch.Groups['body'].Value -split '\r?\n' + + # Discover the workflow's child indent level from the + # first non-empty, non-comment indented line. + $childIndent = $null + foreach ($line in $bodyLines) + { + if ($line -match '^(?\s+)[^\s#]') + { + $childIndent = $Matches['i'].Length + break + } + } + + if ($null -ne $childIndent) + { + $childKeyRegex = "^\s{$childIndent}['""]?(?[A-Za-z_\.][^\s:#'""]*)['""]?\s*:" + $braceDepth = 0 + + foreach ($line in $bodyLines) + { + # Strip trailing comments to keep the brace count + # honest (a '#' starts a YAML comment). + $codeLine = $line -replace '(? ... + # The lookahead anchors the end of the captured name to a real + # word-boundary so we don't truncate longer identifiers (e.g. + # 'parameter' inside a comment) into spurious matches like + # 'paramete'. What may follow a real task name: + # * optional whitespace then a syntax char ({ ( , @ - # ' " ) + # * mandatory whitespace then a word char (next Job string) + # * end-of-line (rare but legal) + $taskRegex = '^\s*task\s+[''"]?(?[A-Za-z_][\w\.\-]*)[''"]?(?=\s*[{(,@\-#''"]|\s+\w|\s*$)' + + # 2. Tasks defined in the local '.build' directory. + $localBuildDir = Join-Path -Path $scriptRoot -ChildPath '.build' + if (Test-Path -Path $localBuildDir) + { + Get-ChildItem -Path $localBuildDir -Filter '*.ps1' -Recurse -ErrorAction Ignore | + Select-String -Pattern $taskRegex -ErrorAction Ignore | + ForEach-Object { + $name = $_.Matches[0].Groups['name'].Value + + if ($name -and -not $tasks.Contains($name)) + { + $tasks[$name] = $null + } + } + } + + # 3. Tasks imported from Sampler and related modules (*.build.ps1). + $modulesDir = Join-Path -Path $scriptRoot -ChildPath 'output/RequiredModules' + if (Test-Path -Path $modulesDir) + { + Get-ChildItem -Path $modulesDir -Filter '*.build.ps1' -Recurse -ErrorAction Ignore | + Select-String -Pattern $taskRegex -ErrorAction Ignore | + ForEach-Object { + $name = $_.Matches[0].Groups['name'].Value + + if ($name -and -not $tasks.Contains($name)) + { + $tasks[$name] = $null + } + } + } + + # 4. Built-in Invoke-Build help token (last so it doesn't push the + # workflow aliases off the top of the candidate list). + if (-not $tasks.Contains('?')) + { + $tasks['?'] = $null + } + + $tasks.Keys | + Where-Object { $_ -like "$wordToComplete*" } | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + })] [System.String[]] $Tasks = '.', diff --git a/tests/Unit/Templates/Build.ps1.Tests.ps1 b/tests/Unit/Templates/Build.ps1.Tests.ps1 new file mode 100644 index 00000000..e81b56b2 --- /dev/null +++ b/tests/Unit/Templates/Build.ps1.Tests.ps1 @@ -0,0 +1,203 @@ +Describe 'Sampler Build.ps1 template' { + BeforeAll { + $templatePath = Join-Path -Path $PSScriptRoot -ChildPath '../../../Sampler/Templates/Build/build.ps1' + $templatePath = (Resolve-Path -Path $templatePath).Path + + $tokens = $null + $parseErrors = $null + $script:ast = [System.Management.Automation.Language.Parser]::ParseFile( + $templatePath, [ref] $tokens, [ref] $parseErrors + ) + $script:parseErrors = $parseErrors + + $script:tasksParam = $script:ast.FindAll({ + param ($node) + $node -is [System.Management.Automation.Language.ParameterAst] -and + $node.Name.VariablePath.UserPath -eq 'Tasks' + }, $true) | Select-Object -First 1 + + $script:completerAttr = $null + if ($script:tasksParam) + { + $script:completerAttr = $script:tasksParam.Attributes | + Where-Object { $_.TypeName.FullName -eq 'ArgumentCompleter' } | + Select-Object -First 1 + } + } + + It 'Parses without errors' { + $script:parseErrors.Count | Should -Be 0 + } + + It 'Defines a $Tasks parameter' { + $script:tasksParam | Should -Not -BeNullOrEmpty + } + + It 'Has an ArgumentCompleter attribute on $Tasks' { + $script:completerAttr | Should -Not -BeNullOrEmpty + } + + Context 'When invoking the ArgumentCompleter against a fake workspace' { + BeforeAll { + $script:fakeRoot = Join-Path -Path $TestDrive -ChildPath 'FakeProject' + $null = New-Item -ItemType Directory -Path $script:fakeRoot + $null = New-Item -ItemType Directory -Path (Join-Path -Path $script:fakeRoot -ChildPath '.build') + + Set-Content -Path (Join-Path -Path $script:fakeRoot -ChildPath '.build/Foo.ps1') -Value 'task Foo {}' + + # Tasks file with both a real task and a comment line that begins + # with the literal text "task something" but is not a real task + # declaration. The completer must NOT pick up "comment" as a task. + $tricky = @( + 'task Bar { }' + '<#' + ' task comment $foo is described in the help block' + '#>' + ) -join [System.Environment]::NewLine + Set-Content -Path (Join-Path -Path $script:fakeRoot -ChildPath '.build/Bar.ps1') -Value $tricky + + # The fixture exercises three things: + # 1. Plain workflow aliases (CompWorkflow, OtherWf, TrailingWf). + # 2. An in-yaml scriptblock value with nested braces (Inline). Its + # key must be picked up, but identifiers that appear *inside* + # the scriptblock body must not be treated as workflow aliases. + # 3. A non-workflow root key (NextKey) that terminates the block. + $yamlLines = @( + 'BuildWorkflow:' + '' + ' CompWorkflow:' + ' - build' + ' - test' + '' + ' OtherWf:' + ' - noop' + '' + ' # An in-yaml scriptblock value with nested braces.' + ' Inline: {' + ' if ($true) {' + ' InnerKey: not_a_workflow' + ' }' + ' }' + '' + ' TrailingWf:' + ' - x' + 'NextKey: value' + ) + Set-Content -Path (Join-Path -Path $script:fakeRoot -ChildPath 'build.yaml') -Value ($yamlLines -join [System.Environment]::NewLine) + + $fakeScriptPath = Join-Path -Path $script:fakeRoot -ChildPath 'Build.ps1' + Set-Content -Path $fakeScriptPath -Value '# stub' + + $script:completerSource = $script:completerAttr.PositionalArguments[0].ScriptBlock.GetScriptBlock().ToString() + + # Run the completer in a fresh runspace with the fake workspace as + # the current location. Because no script is being invoked, + # $PSCommandPath is empty inside the runspace and the completer + # falls back to $PWD.Path for its script root. + function Invoke-Completer + { + param + ( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [System.String] + $WordToComplete + ) + + $ps = [powershell]::Create() + try + { + $null = $ps.AddScript("Set-Location -LiteralPath '$($script:fakeRoot.Replace("'", "''"))'") + $null = $ps.Invoke() + $ps.Commands.Clear() + + $null = $ps.AddScript("`$completer = [scriptblock]::Create(@'`n$($script:completerSource)`n'@); & `$completer 'x' 'Tasks' '$WordToComplete' `$null `$null") + return $ps.Invoke() + } + finally + { + $ps.Dispose() + } + } + } + + It 'Returns the workflow aliases, the local task and the help token' { + $results = Invoke-Completer -WordToComplete '' + $values = @($results | ForEach-Object { $_.CompletionText }) + + $values | Should -Not -BeNullOrEmpty + $values | Should -Contain '?' + $values | Should -Contain 'Foo' + $values | Should -Contain 'Bar' + $values | Should -Contain 'CompWorkflow' + $values | Should -Contain 'OtherWf' + $values | Should -Contain 'Inline' + $values | Should -Contain 'TrailingWf' + } + + It 'Does not pick up identifiers from comment text that mentions "task "' { + $results = Invoke-Completer -WordToComplete '' + $values = @($results | ForEach-Object { $_.CompletionText }) + + # 'comment' appears inside a <# ... #> help block on a line that + # reads 'task comment $foo is described ...'. The regex must reject + # it because it is followed by a $ variable reference, which is + # not a valid Invoke-Build task signature. + $values | Should -Not -Contain 'comment' + } + + It 'Does not pick up identifiers inside an in-yaml scriptblock value' { + $results = Invoke-Completer -WordToComplete '' + $values = @($results | ForEach-Object { $_.CompletionText }) + + # 'InnerKey' lives inside the Inline scriptblock body and must not + # be treated as a top-level workflow alias. + $values | Should -Not -Contain 'InnerKey' + } + + It 'Preserves YAML / file declaration order (no alphabetical sort)' { + $results = Invoke-Completer -WordToComplete '' + $values = @($results | ForEach-Object { $_.CompletionText }) + + # Workflow aliases appear in their declaration order from the YAML, + # followed by the local '.build/' tasks (in Get-ChildItem order, + # which is alphabetical by file name on Windows: Bar.ps1, Foo.ps1), + # followed by the Invoke-Build help token. We assert *relative* + # order rather than equality, to stay robust to other workspace + # artefacts that the completer may legitimately discover. + $expectedOrder = @('CompWorkflow', 'OtherWf', 'Inline', 'TrailingWf', 'Bar', 'Foo', '?') + + $actualPositions = foreach ($name in $expectedOrder) + { + [System.Array]::IndexOf([System.Object[]] $values, $name) + } + + $actualPositions | Should -Not -Contain -1 + + $sortedPositions = $actualPositions | Sort-Object + ($actualPositions -join '|') | Should -Be ($sortedPositions -join '|') + } + + It 'Deduplicates entries (case-insensitive)' { + $results = Invoke-Completer -WordToComplete '' + $values = @($results | ForEach-Object { $_.CompletionText }) + + ($values | Group-Object | Where-Object Count -gt 1) | Should -BeNullOrEmpty + } + + It 'Filters results based on $wordToComplete' { + $results = Invoke-Completer -WordToComplete 'Comp' + $values = @($results | ForEach-Object { $_.CompletionText }) + + $values | Should -Contain 'CompWorkflow' + $values | Should -Not -Contain 'Foo' + $values | Should -Not -Contain 'OtherWf' + } + + It 'Emits CompletionResult objects with ParameterValue result type' { + $results = @(Invoke-Completer -WordToComplete '') + $results[0] | Should -BeOfType ([System.Management.Automation.CompletionResult]) + $results[0].ResultType | Should -Be ([System.Management.Automation.CompletionResultType]::ParameterValue) + } + } +}