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)
+ }
+ }
+}