diff --git a/.conductor/registry/workflows/actionable.yaml b/.conductor/registry/workflows/actionable.yaml index fb7ad55f..905dce29 100644 --- a/.conductor/registry/workflows/actionable.yaml +++ b/.conductor/registry/workflows/actionable.yaml @@ -783,7 +783,7 @@ agents: - "-NoProfile" - "-Command" - >- - $prNumber = {{ open_evidence_pr.output.pr_number }}; + $prNumber = [int]'{{ open_evidence_pr.output.pr_number }}'; $prUrl = '{{ open_evidence_pr.output.pr_url }}'; try { gh pr merge $prNumber --squash --auto --delete-branch 2>&1 | Out-Null; diff --git a/.conductor/registry/workflows/apex-driver.yaml b/.conductor/registry/workflows/apex-driver.yaml index f496ce06..921801d4 100644 --- a/.conductor/registry/workflows/apex-driver.yaml +++ b/.conductor/registry/workflows/apex-driver.yaml @@ -608,8 +608,8 @@ agents: - "-NoProfile" - "-Command" - >- - $failed = {{ wave_dispatch_loop.errors | length }}; - $total = {{ wave_dispatch_loop.count }}; + $failed = [int]'{{ wave_dispatch_loop.errors | length }}'; + $total = [int]'{{ wave_dispatch_loop.count }}'; [ordered]@{ total_waves = $total; failed_waves = $failed; diff --git a/.conductor/registry/workflows/implement-merge-group.yaml b/.conductor/registry/workflows/implement-merge-group.yaml index 8aea895d..48270ca7 100644 --- a/.conductor/registry/workflows/implement-merge-group.yaml +++ b/.conductor/registry/workflows/implement-merge-group.yaml @@ -606,7 +606,7 @@ agents: - >- $ErrorActionPreference = 'Stop'; $PSNativeCommandUseErrorActionPreference = $true; - $taskId = {{ primary_router.output.primary_id }}; + $taskId = [int]'{{ primary_router.output.primary_id }}'; twig set $taskId; twig note --text 'Item implementation merged into MG branch via impl PR'; twig sync; diff --git a/.conductor/registry/workflows/plan-level.yaml b/.conductor/registry/workflows/plan-level.yaml index 38a09944..7a91cb73 100644 --- a/.conductor/registry/workflows/plan-level.yaml +++ b/.conductor/registry/workflows/plan-level.yaml @@ -665,7 +665,7 @@ agents: - "-NoProfile" - "-Command" - | - $count = {{ context.history | select('eq', 'research_dispatch') | list | length }} + $count = [int]'{{ context.history | select('eq', 'research_dispatch') | list | length }}' $maxLoops = 3 $capReached = $count -ge $maxLoops $hasTopics = '{{ (architect.output.research_needs.topics | default([]) | length > 0) | string | lower }}' -eq 'true' @@ -784,8 +784,8 @@ agents: - "-NoProfile" - "-Command" - | - $count = {{ context.history | select('eq', 'open_questions_gate') | list | length }} - $maxLoops = {{ open_questions_policy.output.max_question_loops }} + $count = [int]'{{ context.history | select('eq', 'open_questions_gate') | list | length }}' + $maxLoops = [int]'{{ open_questions_policy.output.max_question_loops }}' $capReached = $count -ge $maxLoops @{ iteration = $count; max_loops = $maxLoops; cap_reached = $capReached } | ConvertTo-Json routes: @@ -1494,7 +1494,7 @@ agents: - "-Command" - | $ErrorActionPreference = 'Stop' - $prNumber = {{ poll_status.output.pr_number }} + $prNumber = [int]'{{ poll_status.output.pr_number }}' $headSha = '{{ poll_status.output.head_sha }}' $repoSlug = '{{ poll_status.output.repo_slug }}' $body = "polyphony:approve $headSha" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1575eb04..637fddf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,23 @@ jobs: $result = Invoke-Pester -Path .conductor/registry/tests/lint-strict-undefined.Tests.ps1 -Output Detailed -PassThru if ($result.FailedCount -gt 0) { exit 1 } + - name: Lint workflow YAMLs (PowerShell + Jinja bareword booleans) + # Catches the AB#3156 Bug 1 class: a `command: pwsh` script body + # that assigns a Jinja render unquoted (`$x = {{ … }}`). When + # Jinja emits `true` / `false` (Python bool → lowercase string), + # PowerShell parses the bareword as a cmdlet name, errors silently, + # and leaves `$x = $null` — which downstream Jinja then treats as + # `null != false`, routing to the wrong branch (killed an AB#3127 + # dogfood relaunch). PR #354 fixed plan-level.yaml; this lint stops + # the pattern from coming back across every workflow YAML. Required + # form: `$x = '{{ … }}' -eq 'true'` (or, for a numeric render, + # `[int]'{{ … }}'`). Sequenced after `Install powershell-yaml` so + # the YAML parser is available. + if: always() + shell: pwsh + working-directory: polyphony + run: pwsh -NoProfile -File tests/lint-pwsh-jinja-bareword.ps1 -Format github + - name: Lint workflow YAMLs (Jinja2 resolver) # Resolves every `{{ .output. }}` reference in workflow # YAMLs against the verb-output schema registry (#173 / PR #184). diff --git a/tests/lint-pwsh-jinja-bareword.Tests.ps1 b/tests/lint-pwsh-jinja-bareword.Tests.ps1 new file mode 100644 index 00000000..7a5f693d --- /dev/null +++ b/tests/lint-pwsh-jinja-bareword.Tests.ps1 @@ -0,0 +1,303 @@ +BeforeAll { + $script:LintScript = Join-Path $PSScriptRoot 'lint-pwsh-jinja-bareword.ps1' + + function New-TempWorkflowsDir { + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ` + "lint-pwsh-jinja-bareword-$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $dir -Force | Out-Null + return $dir + } + + function Invoke-Lint { + param([string] $WorkflowsDir, [string] $Format = 'human') + $output = pwsh -NoProfile -File $script:LintScript ` + -WorkflowsDir $WorkflowsDir -Format $Format 2>&1 + return @{ Output = ($output -join "`n"); ExitCode = $global:LASTEXITCODE } + } +} + +Describe 'lint-pwsh-jinja-bareword.ps1' { + + BeforeEach { + $script:WorkflowsDir = New-TempWorkflowsDir + $global:LASTEXITCODE = 0 + } + + AfterEach { + Remove-Item $script:WorkflowsDir -Recurse -Force -ErrorAction SilentlyContinue + } + + Context 'Clean inputs (exit 0)' { + + It 'Passes on an empty workflows directory' { + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 0 + } + + It 'Passes when workflows directory does not exist' { + $missing = Join-Path $script:WorkflowsDir 'does-not-exist' + $r = Invoke-Lint -WorkflowsDir $missing + $r.ExitCode | Should -Be 0 + } + + It 'Passes a single-quoted Jinja render compared to a string (the canonical safe form)' { + $body = @' +agents: + - name: ok-quoted-bool + type: script + command: pwsh + args: + - "-NoProfile" + - "-Command" + - | + $hasTopics = '{{ (architect.output.topics | default([]) | length > 0) | string | lower }}' -eq 'true' + Write-Host $hasTopics +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'ok.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 0 + } + + It 'Passes a double-quoted Jinja render' { + $body = @' +agents: + - name: ok-double-quote + type: script + command: pwsh + args: + - "-NoProfile" + - "-Command" + - | + $name = "{{ architect.output.name }}" +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'ok-double.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 0 + } + + It 'Passes Jinja renders embedded inside an array literal of quoted strings' { + $body = @' +agents: + - name: ok-array + type: script + command: pwsh + args: + - "-NoProfile" + - "-Command" + - | + $arr = @('{{ architect.output.a }}', '{{ architect.output.b }}') +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'ok-array.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 0 + } + + It 'Passes the quote-and-cast pattern: $count = [int]''{{ ... }}''' { + $body = @' +agents: + - name: ok-int-cast + type: script + command: pwsh + args: + - "-NoProfile" + - "-Command" + - | + $count = [int]'{{ context.history | length }}' +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'ok-int.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 0 + } + + It 'Ignores agents whose command is not pwsh' { + $body = @' +agents: + - name: a-twig-step + type: script + command: twig + args: + - "set" + - "{{ workflow.input.id }}" +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'twig.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 0 + } + } + + Context 'Violations (exit 1)' { + + It 'Flags $hasTopics = {{ ... }} (the AB#3156 Bug 1 shape)' { + $body = @' +agents: + - name: bad-bool + type: script + command: pwsh + args: + - "-NoProfile" + - "-Command" + - | + $hasTopics = {{ (architect.output.topics | default([]) | length > 0) | string | lower }} + Write-Host $hasTopics +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'bad-bool.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match '\$hasTopics' + $r.Output | Should -Match 'bad-bool\.yaml' + } + + It 'Flags $count = {{ items | length }} (integer bareword)' { + $body = @' +agents: + - name: bad-int + type: script + command: pwsh + args: + - "-NoProfile" + - "-Command" + - | + $count = {{ items | length }} + Write-Host $count +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'bad-int.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match '\$count' + } + + It 'Flags a bareword statement inside a folded (>-) block scalar with semicolons' { + $body = @' +agents: + - name: bad-folded + type: script + command: pwsh + args: + - "-NoProfile" + - "-Command" + - >- + $prNumber = {{ poll.output.pr_number }}; + Write-Host $prNumber +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'bad-folded.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match '\$prNumber' + } + + It 'Aggregates violations across multiple agents in the same file' { + $body = @' +agents: + - name: bad-a + type: script + command: pwsh + args: + - "-Command" + - | + $a = {{ x.output.a }} + - name: bad-b + type: script + command: pwsh + args: + - "-Command" + - | + $b = {{ x.output.b }} +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'multi.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match '\$a' + $r.Output | Should -Match '\$b' + } + + It 'Reports the source line number of the violation' { + $body = @' +agents: + - name: bad-line + type: script + command: pwsh + args: + - "-Command" + - | + $hasTopics = {{ x.output.topics | length > 0 }} +'@ + $path = Join-Path $script:WorkflowsDir 'bad-line.yaml' + Set-Content -Path $path -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 1 + # The body line `$hasTopics = …` lives on line 8 of the file + # (1-based). Match against the file:line snippet pattern the + # human formatter emits. + $r.Output | Should -Match 'bad-line\.yaml:8' + } + } + + Context 'Whitelist marker' { + + It 'Suppresses a violation when # bareword-ok appears on the preceding line' { + $body = @' +agents: + - name: ok-whitelisted + type: script + command: pwsh + args: + - "-Command" + - | + # bareword-ok: integer used in arithmetic; render is always digits + $count = {{ items | length }} + Write-Host $count +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'wl.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 0 + } + + It 'Suppresses a violation when # bareword-ok appears on the same line' { + $body = @' +agents: + - name: ok-same-line + type: script + command: pwsh + args: + - "-Command" + - | + $count = {{ items | length }} # bareword-ok: integer literal +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'wl-same.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir + $r.ExitCode | Should -Be 0 + } + } + + Context 'GitHub Actions output format' { + + It 'Emits ::error annotations under -Format github' { + $body = @' +agents: + - name: gha-bad + type: script + command: pwsh + args: + - "-Command" + - | + $hasTopics = {{ x.output.topics | length > 0 }} +'@ + Set-Content -Path (Join-Path $script:WorkflowsDir 'gha.yaml') -Value $body + $r = Invoke-Lint -WorkflowsDir $script:WorkflowsDir -Format 'github' + $r.ExitCode | Should -Be 1 + $r.Output | Should -Match '::error file=\.conductor/registry/workflows/gha\.yaml,line=\d+::' + $r.Output | Should -Match '\$hasTopics' + } + } + + Context 'Live tree' { + + It 'The real .conductor/registry/workflows/ tree passes the lint' { + # Defense: this test is the gate that catches a real workflow + # introducing the bareword pattern. If it ever fails, fix the + # workflow — do not loosen the lint. + $r = pwsh -NoProfile -File $script:LintScript 2>&1 + $global:LASTEXITCODE | Should -Be 0 + } + } +} diff --git a/tests/lint-pwsh-jinja-bareword.ps1 b/tests/lint-pwsh-jinja-bareword.ps1 new file mode 100644 index 00000000..d9262b8e --- /dev/null +++ b/tests/lint-pwsh-jinja-bareword.ps1 @@ -0,0 +1,240 @@ +<# +.SYNOPSIS + CI lint — bans the bareword Jinja boolean trap in `command: pwsh` script + bodies inside conductor workflow YAMLs. + +.DESCRIPTION + Rooted in AB#3156 Bug 1 (PR #354 / commit e48a31d). A pwsh script in + plan-level.yaml had: + + $hasTopics = {{ (architect.output.research_needs.topics + | default([]) | length > 0) | string | lower }} + + Jinja renders `true` / `false` (Python bool → lowercase string). PowerShell + parses bareword `true` as a cmdlet name, finds nothing, errors silently — + `$hasTopics` ends up `$null`. Serialized to JSON as `null`; downstream + Jinja then evaluates `null != false` and routes to the wrong branch. + Killed an AB#3127 dogfood relaunch. + + The fix is to quote the rendered token and string-compare it: + + $hasTopics = '{{ (...) | string | lower }}' -eq 'true' + + The result is `[bool]` regardless of what Jinja emits. Same pattern works + for any value (string, int, bool) where the safe form is to wrap the + Jinja render in a string literal: `'{{ ... }}'`, `"{{ ... }}"`, or + `@('{{ a }}', '{{ b }}')`. + + This lint scans every `.conductor/registry/workflows/*.yaml`. For every + `command: pwsh` agent it walks the `args:` list, splits each string arg + into lines, and flags any line containing: + + $ = {{ ... }} # bareword Jinja render + + The intentional "bareword Jinja render" case (e.g. an integer rendered + directly into an arithmetic expression) can be opted out via a comment + marker on the same or preceding line: + + # bareword-ok: integer render — pr_number is always int + $prNumber = {{ poll_status.output.pr_number }} + + Use sparingly — defense-in-depth says cast and let the cast fail loudly + instead. + +.PARAMETER WorkflowsDir + Directory of workflow YAMLs to scan. Defaults to + `/.conductor/registry/workflows`. + +.PARAMETER Format + Output format: `human` (default) or `github` (Actions annotations). + + Exits 0 if clean, 1 if any violations are found, 2 on configuration error. +#> +[CmdletBinding()] +param( + [string] $WorkflowsDir, + [ValidateSet('human', 'github')] + [string] $Format = 'human' +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path + +if (-not $WorkflowsDir) { + $WorkflowsDir = Join-Path $repoRoot '.conductor/registry/workflows' +} + +if (-not (Test-Path $WorkflowsDir)) { + Write-Host "SKIP: workflows dir not found: $WorkflowsDir" -ForegroundColor Yellow + exit 0 +} + +# ── Module dependency ──────────────────────────────────────────────────── +if (-not (Get-Module -ListAvailable -Name powershell-yaml)) { + Write-Host "FATAL: the powershell-yaml module is required by lint-pwsh-jinja-bareword.ps1." -ForegroundColor Red + Write-Host "Install with: Install-Module -Name powershell-yaml -Force -SkipPublisherCheck -Scope CurrentUser" -ForegroundColor Cyan + exit 2 +} +Import-Module powershell-yaml -ErrorAction Stop + +# ── Detection regex ────────────────────────────────────────────────────── +# Match `$ = {{ ... }}` where the `{{` is NOT preceded by a quote +# character. The negative form is always `=\s*\{\{`; the safe form has +# `=\s*['"]\{\{` (or any other non-whitespace char between `=` and `{{`), +# which `\s*\{\{` cannot match. +$BarewordPattern = '\$\w+\s*=\s*\{\{[^}]+\}\}' +$WhitelistPattern = '#\s*bareword-ok\s*:' + +# ── Helpers ────────────────────────────────────────────────────────────── +function Split-IntoLines { + <# + Split a script body into lines, normalizing CRLF. Each returned + element is one logical script line as the YAML literal-block scalar + preserved it. We do NOT split on `;` — the whitelist marker check + must be able to look at the previous *line*, and a `;` inside a + comment must not start a new "statement" for whitelist purposes. + Multiple violations on the same line are still found because the + regex scan uses [regex]::Matches per line. + + Always returns an array (the leading `,` defeats PowerShell's + single-element unwrap, which would otherwise turn the result into a + bare string and make `$lines.Count` report the string length). + #> + param([string] $Body) + if ([string]::IsNullOrEmpty($Body)) { return ,@() } + $arr = ($Body -replace "`r`n", "`n") -split "`n" + return ,$arr +} + +function Find-FileLine { + <# + Search a file's raw line array for the first line (at or after + $StartIndex) whose trimmed text contains the trimmed needle. + Returns the 1-based line number, or 0 if not found. Used to map + a violation back to its source line in the YAML file. + #> + param( + [string[]] $Lines, + [int] $StartIndex, + [string] $Needle + ) + $needleTrim = $Needle.Trim() + if ([string]::IsNullOrEmpty($needleTrim)) { return 0 } + for ($i = $StartIndex; $i -lt $Lines.Count; $i++) { + if ($Lines[$i].Trim().Contains($needleTrim)) { + return $i + 1 + } + } + return 0 +} + +function Find-AgentLine { + param([string[]] $Lines, [string] $AgentName) + $escaped = [regex]::Escape($AgentName) + for ($i = 0; $i -lt $Lines.Count; $i++) { + if ($Lines[$i] -match "^\s*-\s+name:\s+$escaped\s*$") { + return $i + } + } + return 0 +} + +# ── Main scan ──────────────────────────────────────────────────────────── +$yamlFiles = @(Get-ChildItem -Path $WorkflowsDir -Filter '*.yaml' -File) +$violations = @() + +foreach ($file in $yamlFiles) { + $rawText = Get-Content -LiteralPath $file.FullName -Raw + $rawLines = @(Get-Content -LiteralPath $file.FullName) + + try { + $yaml = ConvertFrom-Yaml $rawText + } catch { + Write-Host "FATAL: failed to parse $($file.Name) as YAML: $($_.Exception.Message)" -ForegroundColor Red + exit 2 + } + + if ($null -eq $yaml -or -not $yaml.ContainsKey('agents')) { continue } + + foreach ($agent in $yaml['agents']) { + if ($null -eq $agent) { continue } + if (-not $agent.ContainsKey('command')) { continue } + if ([string]$agent['command'] -ne 'pwsh') { continue } + if (-not $agent.ContainsKey('args')) { continue } + + $agentName = if ($agent.ContainsKey('name')) { [string]$agent['name'] } else { '' } + $agentLineIdx = Find-AgentLine -Lines $rawLines -AgentName $agentName + + foreach ($arg in $agent['args']) { + if ($null -eq $arg) { continue } + if ($arg -isnot [string]) { continue } + + # Tracks the raw-arg lines so we can spot a `# bareword-ok:` + # marker on the immediately preceding line. + $lines = Split-IntoLines -Body $arg + for ($k = 0; $k -lt $lines.Count; $k++) { + $line = $lines[$k] + if ($line -match $WhitelistPattern) { continue } + if ($k -gt 0 -and $lines[$k - 1] -match $WhitelistPattern) { continue } + + $matchList = [regex]::Matches($line, $BarewordPattern) + foreach ($m in $matchList) { + # `=\s*\{\{` matches both `= {{` (unsafe) and we need to + # confirm the char before `{{` (after the `=`) is whitespace, + # not a quote — defensive check in case the [^}]+ inside + # absorbed something weird. + $matchText = $m.Value + $bracesIdx = $matchText.IndexOf('{{') + if ($bracesIdx -gt 0) { + $charBefore = $matchText[$bracesIdx - 1] + if ($charBefore -eq "'" -or $charBefore -eq '"') { continue } + } + + $fileLine = Find-FileLine -Lines $rawLines -StartIndex $agentLineIdx -Needle $matchText + $violations += [PSCustomObject]@{ + File = $file.Name + Line = $fileLine + Agent = $agentName + Snippet = $matchText.Trim() + } + } + } + } + } +} + +# ── Report ─────────────────────────────────────────────────────────────── +if ($violations.Count -eq 0) { + Write-Host "[OK] lint-pwsh-jinja-bareword passed ($($yamlFiles.Count) workflow(s) scanned)" -ForegroundColor Green + exit 0 +} + +$message = "PowerShell bareword Jinja render — Jinja bool/string `true`/`false` parses as a cmdlet name and silently nulls the variable. Wrap in quotes and string-compare: " + "``" + "`$x = '{{ ... }}' -eq 'true'" + "``" + " (or for non-bool: " + "``" + "`$x = '{{ ... }}'" + "``" + " / " + "``" + "[int]'{{ ... }}'" + "``" + "). See AB#3156 / PR #354." + +if ($Format -eq 'github') { + foreach ($v in $violations) { + $msg = "lint-pwsh-jinja-bareword [$($v.Agent)] $($v.Snippet) — $message" + # GitHub annotations are single-line. + $msg = $msg -replace "`r?`n", ' ' + $relPath = ".conductor/registry/workflows/$($v.File)" + if ($v.Line -gt 0) { + Write-Output "::error file=$relPath,line=$($v.Line)::$msg" + } else { + Write-Output "::error file=$relPath::$msg" + } + } +} else { + Write-Host "`n[FAIL] lint-pwsh-jinja-bareword failed ($($violations.Count) violation(s)):`n" -ForegroundColor Red + foreach ($v in $violations) { + $loc = if ($v.Line -gt 0) { "$($v.File):$($v.Line)" } else { "$($v.File):?" } + Write-Host " $loc [agent: $($v.Agent)]" -ForegroundColor Red + Write-Host " $($v.Snippet)" -ForegroundColor DarkGray + } + Write-Host "`nFix: $message" -ForegroundColor Yellow + Write-Host "Or, for an intentional bareword render (rare — e.g. integer in arithmetic), add" -ForegroundColor Yellow + Write-Host " # bareword-ok: " -ForegroundColor Yellow + Write-Host "on the line above the assignment.`n" -ForegroundColor Yellow +} + +exit 1