<# .SYNOPSIS Authoring-assist (PROTOTYPE): reviews the routing-relevant front-matter of BCQuality knowledge articles. It proposes `signals:` (trigger AND suppressor), flags declared-`domain` vs sample-content mismatches, cross-checks `technologies`, and suggests `keywords` — all suggestion-only. .DESCRIPTION Routing quality is capped by how well each article's front-matter describes the code it catches. Today most articles declare no explicit `signals:` block and several review domains have zero detection signals, so a large slice of knowledge is only reachable via the catch-all pass. This tool lowers the authoring cost of closing that gap. For each article it reads the co-located `good`/`bad` `.al` samples AND the article prose (inline code + fenced `al` blocks), extracts candidate AL constructs, and emits: * A proposed `signals:` block. For a normal article these are RAISE triggers. For a "this-is-not-a-violation" article (detected from the title/prose) they are `effect: suppress` entries, which DAMPEN the domain score instead of raising it. * A domain-mismatch flag when the samples look like another domain. * An applicability note when the samples use a technology (e.g. JavaScript control add-ins) not declared in `technologies`. * Keyword suggestions for strong constructs missing from `keywords`. It is OFFLINE and OPEN: no feedback data, deterministic, ships in the repo so the community can run it. It SUGGESTS ONLY — it never edits an article. The output is a review report (Markdown by default, JSON with -AsJson) an author or maintainer eyeballs; the front-matter validator (R29), CI, and CODEOWNERS review remain the gate. This is the on-demand prototype the team agreed to before formalizing an automated advisory PR check. .PARAMETER BCQualityRoot Content root to scan. Defaults to the clone root (parent of tools/). .PARAMETER Path Optional filter: only articles whose repo-relative path CONTAINS this string (e.g. 'knowledge/style' or a single article file name). Enables on-demand, per-PR-style scoping. Omit to scan every article. .PARAMETER ChangedFiles Optional exact-match filter for the automated PR advisory: one or more repo-relative article paths (forward-slash, e.g. 'microsoft/knowledge/performance/avoid-commit-inside-loops.md'). Only these articles are reported. Non-'.md' entries and paths outside a knowledge tree are ignored, so the workflow can pass the raw changed-file list verbatim. Combine with -AsJson to feed the comment renderer. Ignored when empty. .PARAMETER Top Max suggested signals per article (default 3). .PARAMETER OnlyGaps Only report articles that either have NO existing signal coverage for their domain, are a suppressor candidate, or trigger a domain-mismatch/applicability flag — i.e. where the suggestion adds the most value. Off = report every article that yields a suggestion. .PARAMETER NoProse Ignore article prose; use only the `.al` samples as the construct source. .PARAMETER AsJson Emit a machine-readable JSON report instead of Markdown (for a future automated PR check to consume). .PARAMETER SeedPath OPTIONAL routing seed (domain-normalization + the existing signal catalog to de-dupe against and to power domain-mismatch detection). When supplied it must exist. When omitted, tools/routing-seed.json is used only if present; otherwise the tool runs seed-free (self-contained) with a built-in domain-normalization map and no catalog de-dup/mismatch. The routing-index tuning thread owns the seed; authoring-assist works with or without it. .OUTPUTS Writes the report to stdout. Returns nothing. #> [CmdletBinding()] param( [string] $BCQualityRoot, [string] $Path, [string[]] $ChangedFiles, [int] $Top = 3, [switch] $OnlyGaps, [switch] $NoProse, [switch] $AsJson, [string] $SeedPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # JSON output contract. Bump SchemaVersion only for breaking shape changes; the # comment renderer and the BC-ALAgentsInternal harvester key off it. ToolVersion # tracks behavioral changes to ranking/extraction and feeds each suggestionId so # a re-run after a tool change yields distinct, attributable ids. $SchemaVersion = '1.0' $ToolVersion = '1.0.0' # Stable, attributable identity for a single proposal. Deterministic across runs # and machines: sha256 over the normalized article path + token + effect + tool # version, truncated to 16 hex chars. Used to correlate PR-comment feedback # (reactions, applied/not-applied) back to the exact suggestion that produced it. function Get-SuggestionId { param([string] $ArticlePath, [string] $Token, [string] $Effect) $material = "$ArticlePath|$Token|$Effect|$ToolVersion" $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($material)) } finally { $sha.Dispose() } return (($bytes[0..7] | ForEach-Object { $_.ToString('x2') }) -join '') } if (-not $BCQualityRoot) { $BCQualityRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path } $BCQualityRoot = (Resolve-Path -LiteralPath $BCQualityRoot).Path # Default canonical-domain taxonomy. Kept in-tool so authoring-assist is # self-contained and does NOT depend on the routing-index tuning thread. When a # routing seed IS present (supplied via -SeedPath or dropped at # tools/routing-seed.json by the routing-index work), it overrides/extends this # map AND unlocks the seed-catalog features below (de-dup against already-routed # tokens + domain-mismatch detection). Absent a seed, the tool still normalizes # domains and proposes signals from each article's own samples/prose. $domainNorm = @{ 'error-handling' = 'Error Handling' 'performance' = 'Performance' 'privacy' = 'Privacy' 'security' = 'Security' 'style' = 'Style' 'testing' = 'Testing' 'ui' = 'Accessibility' 'upgrade' = 'Upgrade' 'breaking-changes' = 'Breaking Changes' 'events' = 'Events' 'interfaces' = 'Interfaces' 'web-services' = 'Web Services' 'telemetry' = 'Privacy' } # Resolve an OPTIONAL routing seed. Explicit -SeedPath must exist; otherwise the # conventional tools/routing-seed.json is used only if present. No seed is not an # error — seed-catalog features (alreadyRoutes de-dup, domain-mismatch) are then # simply inactive. if ($SeedPath) { if (-not (Test-Path -LiteralPath $SeedPath)) { throw "Routing seed not found: $SeedPath" } } else { $defaultSeed = Join-Path $PSScriptRoot 'routing-seed.json' if (Test-Path -LiteralPath $defaultSeed) { $SeedPath = $defaultSeed } } # Existing seed tokens by canonical domain, so we only propose NEW value. $seedByToken = @{} # lowercased construct -> canonical domain it already routes if ($SeedPath) { $seed = Get-Content -LiteralPath $SeedPath -Raw | ConvertFrom-Json foreach ($p in $seed.'domain-normalization'.PSObject.Properties) { $domainNorm[$p.Name] = $p.Value } foreach ($s in $seed.signals) { # Derive the bare words a seed pattern is likely to match (best effort) so a # candidate construct already covered by the catalog is recognised. foreach ($w in ([regex]::Matches($s.pattern, '[A-Za-z][A-Za-z0-9]{2,}') | ForEach-Object { $_.Value })) { $lw = $w.ToLowerInvariant() if (-not $seedByToken.ContainsKey($lw)) { $seedByToken[$lw] = $s.domain } } $seedByToken[$s.token.ToLowerInvariant()] = $s.domain } } function ConvertTo-CanonicalDomain { param([string] $Domain) if ([string]::IsNullOrWhiteSpace($Domain)) { return '' } $d = $Domain.Trim() if ($domainNorm.ContainsKey($d)) { return $domainNorm[$d] } return $d } function Get-RelativePath { param([string] $Root, [string] $Full) return (($Full.Substring($Root.Length).TrimStart([char]'/', [char]'\')) -replace '\\', '/') } # Structural / common AL vocabulary that carries no routing signal — never # propose these. Object kinds, primitive types, control-flow, and the sample # scaffolding tables/fields we see repeatedly. $stop = @{} @( 'record','codeunit','page','report','query','xmlport','enum','interface','table','profile', 'tableextension','pageextension','reportextension','enumextension','permissionset','entitlement', 'procedure','trigger','begin','end','var','local','internal','protected','exit','then','else','repeat','until','case', 'text','integer','boolean','decimal','code','date','datetime','time','guid','option','biginteger','duration','char','byte','label', 'customer','item','vendor','salesheader','salesline','glentry','username','name','value','field','fields', 'uppercase','lowercase','format','copystr','strsubstno','strlen','maxstrlen','abs','round','power', 'get','next','insert','modify','delete','init','sourcetable','pagetype','list','card','document','worksheet', 'true','false','array','temporary','count','isempty','testfield','fieldno','recordid','tabledata', 'action','group','field','part','area','layout','actions','usercontrol','cuegroup','repeater' ) | ForEach-Object { $stop[$_] = $true } function Get-ArticleFrontmatter { param([string[]] $Lines) if (-not $Lines -or @($Lines).Count -lt 1 -or $Lines[0].Trim() -ne '---') { return $null } $fmEnd = -1 for ($i = 1; $i -lt $Lines.Count; $i++) { if ($Lines[$i].Trim() -eq '---') { $fmEnd = $i; break } } if ($fmEnd -lt 0) { return $null } $domain = ''; $keywords = @(); $technologies = @(); $hasSignals = $false for ($i = 1; $i -lt $fmEnd; $i++) { if ($Lines[$i] -match '^\s*domain\s*:\s*(.+?)\s*$') { $domain = $Matches[1].Trim() } elseif ($Lines[$i] -match '^\s*keywords\s*:\s*\[(.*)\]\s*$') { $inner = $Matches[1].Trim() if ($inner) { $keywords = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim() }) } } elseif ($Lines[$i] -match '^\s*technologies\s*:\s*\[(.*)\]\s*$') { $inner = $Matches[1].Trim() if ($inner) { $technologies = @($inner -split '\s*,\s*' | ForEach-Object { $_.Trim().ToLowerInvariant() }) } } elseif ($Lines[$i] -match '^\s*signals\s*:') { $hasSignals = $true } } return [pscustomobject]@{ domain = $domain; keywords = $keywords; technologies = $technologies; hasSignals = $hasSignals; fmEnd = $fmEnd } } # A "suppressor" article exists to say a construct is NOT a violation of its # domain (e.g. page-display-is-not-a-privacy-concern). Its constructs should be # proposed as effect: suppress, not raise. Detected from the title/basename and # prose — but NOT prohibitions ("do-not-add-X" means X *is* a violation). function Test-SuppressorArticle { param([string] $BaseName, [string] $Body) $bn = $BaseName.ToLowerInvariant() if ($bn -match '^(do-not|dont|do-nt|avoid|never|no-inline|prevent|disallow|must-not)') { return $false } $suppressName = $bn -match '(is-not-a|is-not-an|not-a-violation|not-a-[a-z-]*concern|not-an-[a-z-]*issue|need-no|need-not|-allowed(-|$)|allowed-on|is-fine|is-acceptable|no-[a-z-]*concern|exempt)' if ($suppressName) { return $true } # Prose phrasing as a weaker secondary signal (first ~40 lines of body). $head = ($Body -split "`n" | Select-Object -First 40) -join "`n" if ($head -match '(?i)\b(is not a (violation|concern|problem|privacy)|not a violation|no .{0,20}concern|need no |perfectly (fine|acceptable)|is acceptable here|not flagged)\b') { # Guard against prohibition prose. if ($head -notmatch '(?i)\b(do not|don''t|avoid|never|must not|should not)\b') { return $true } } return $false } # Extract candidate AL constructs from ARTICLE PROSE: fenced ```al code blocks # (same shape as samples) and inline `code` spans that look like AL constructs. # Prose is a weaker source than a .al sample — used to reach the articles that # ship no samples, and to corroborate sample constructs. function Get-ProseConstructs { param([string] $Body) $out = @{} if (-not $Body) { return $out } # Fenced al/pascal code blocks. foreach ($m in [regex]::Matches($Body, '(?s)```(?:al|pascal)?\s*(.*?)```')) { foreach ($kv in (Get-SampleConstructs -Text $m.Groups[1].Value -IsBad:$false).GetEnumerator()) { if (-not $out.ContainsKey($kv.Key)) { $out[$kv.Key] = 0 } $out[$kv.Key] += $kv.Value.count } } # Inline code spans that are a single AL-construct-shaped token. foreach ($m in [regex]::Matches($Body, '`([A-Za-z][A-Za-z0-9]*)\s*(?:\(\))?`')) { $tok = $m.Groups[1].Value if ($tok -notmatch '^[A-Z][A-Za-z0-9]{2,}$') { continue } if ($stop.ContainsKey($tok.ToLowerInvariant())) { continue } if (-not $out.ContainsKey($tok)) { $out[$tok] = 0 } $out[$tok] += 1 } return $out } # Extract candidate AL constructs from sample text: method calls (Foo(), # property assignments (Foo =), and attributes ([Foo]). Returns a hashtable of # construct -> @{ count; inBad } so anti-pattern (bad-sample) constructs rank up. function Get-SampleConstructs { param([string] $Text, [bool] $IsBad) $out = @{} if (-not $Text) { return $out } # Identifiers the sample itself DECLARES (object name, procedures, triggers) # are sample-local scaffolding, not reusable routing constructs — exclude. $defined = @{} foreach ($dm in [regex]::Matches($Text, '(?im)^\s*(?:procedure|trigger)\s+([A-Za-z][A-Za-z0-9]*)')) { $defined[$dm.Groups[1].Value.ToLowerInvariant()] = $true } foreach ($dm in [regex]::Matches($Text, '(?im)^\s*(?:codeunit|page|report|query|table|xmlport|enum|interface|codeunit)\s+\d+\s+"?([A-Za-z][A-Za-z0-9 ]*)')) { foreach ($w in ($dm.Groups[1].Value -split '\s+')) { if ($w) { $defined[$w.ToLowerInvariant()] = $true } } } $patterns = @( '\b([A-Z][A-Za-z0-9]{2,})\s*\(', # method call '(?m)^\s*([A-Z][A-Za-z0-9]{2,})\s*=', # property assignment '\[([A-Z][A-Za-z0-9]{2,})' # attribute ) foreach ($pat in $patterns) { foreach ($m in [regex]::Matches($Text, $pat)) { $tok = $m.Groups[1].Value if ($stop.ContainsKey($tok.ToLowerInvariant())) { continue } if ($defined.ContainsKey($tok.ToLowerInvariant())) { continue } if (-not $out.ContainsKey($tok)) { $out[$tok] = @{ count = 0; inBad = $false } } $out[$tok].count++ if ($IsBad) { $out[$tok].inBad = $true } } } return $out } # ---- Scan articles --------------------------------------------------------- $reports = [System.Collections.Generic.List[object]]::new() # Normalize the optional exact-match changed-file set (automated advisory path). # Accept the workflow's raw changed-file list: keep only knowledge '*.md' files, # normalize to forward slashes, and match case-insensitively against each # article's repo-relative path. $changedSet = $null if ($ChangedFiles -and @($ChangedFiles).Count -gt 0) { $changedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($cf in $ChangedFiles) { if ([string]::IsNullOrWhiteSpace($cf)) { continue } $norm = ($cf.Trim() -replace '\\', '/').TrimStart('/') if ($norm -notmatch '\.md$') { continue } if ($norm -notmatch '(^|/)knowledge/') { continue } [void]$changedSet.Add($norm) } if ($changedSet.Count -eq 0) { $changedSet = [System.Collections.Generic.HashSet[string]]::new() } } foreach ($layerDir in @('microsoft', 'community', 'custom')) { $kbRoot = Join-Path $BCQualityRoot (Join-Path $layerDir 'knowledge') if (-not (Test-Path $kbRoot)) { continue } Get-ChildItem -LiteralPath $kbRoot -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | Sort-Object FullName | ForEach-Object { $rel = Get-RelativePath -Root $BCQualityRoot -Full $_.FullName if ($Path -and ($rel -notlike "*$Path*")) { return } if ($null -ne $changedSet -and -not $changedSet.Contains($rel)) { return } $lines = Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue $fm = Get-ArticleFrontmatter -Lines $lines if (-not $fm) { return } $canonDomain = ConvertTo-CanonicalDomain $fm.domain $body = ($lines | Select-Object -Skip ($fm.fmEnd + 1)) -join "`n" $isSuppressor = Test-SuppressorArticle -BaseName $_.BaseName -Body $body # Collect constructs from co-located samples (good + bad). $constructs = @{} $sampleCount = 0 foreach ($sample in Get-ChildItem -LiteralPath $_.DirectoryName -Filter "$($_.BaseName)*.al" -ErrorAction SilentlyContinue) { $sampleCount++ $isBad = $sample.Name -match '\.bad\.al$' $text = Get-Content -LiteralPath $sample.FullName -Raw -ErrorAction SilentlyContinue foreach ($kv in (Get-SampleConstructs -Text $text -IsBad:$isBad).GetEnumerator()) { if (-not $constructs.ContainsKey($kv.Key)) { $constructs[$kv.Key] = @{ count = 0; inBad = $false; prose = $false; sample = $false } } $constructs[$kv.Key].count += $kv.Value.count $constructs[$kv.Key].sample = $true if ($kv.Value.inBad) { $constructs[$kv.Key].inBad = $true } } } # Corroborate / fill gaps from prose (fenced al + inline code spans). if (-not $NoProse) { foreach ($kv in (Get-ProseConstructs -Body $body).GetEnumerator()) { if (-not $constructs.ContainsKey($kv.Key)) { $constructs[$kv.Key] = @{ count = 0; inBad = $false; prose = $false; sample = $false } } $constructs[$kv.Key].count += $kv.Value $constructs[$kv.Key].prose = $true } } # Applicability cross-check: samples/prose that use JavaScript / control # add-ins but `technologies` omits javascript (small but concrete). $applicability = $null $jsUse = @($constructs.Keys | Where-Object { $_ -in @('ControlAddIn','StartupScript','RecreateControls','Scripts') }).Count -gt 0 if (-not $jsUse -and ($body -match '(?i)\bcontrol\s*add-?in\b|\.js\b|javascript')) { $jsUse = $true } if ($jsUse -and ($fm.technologies -notcontains 'javascript')) { $applicability = "samples/prose reference a JavaScript control add-in but 'technologies' does not list 'javascript'" } # Keyword set (normalized) for relatedness scoring. $kwset = @{} foreach ($k in $fm.keywords) { $kwset[($k -replace '-', '').ToLowerInvariant()] = $true } # Score + classify candidates. $cands = foreach ($c in $constructs.Keys) { $lc = $c.ToLowerInvariant() $seedDomain = if ($seedByToken.ContainsKey($lc)) { $seedByToken[$lc] } else { $null } $score = [double]$constructs[$c].count if ($constructs[$c].inBad) { $score += 2 } # anti-pattern construct if ($kwset.ContainsKey($lc)) { $score += 3 } # backed by a declared keyword if ($constructs[$c].sample) { $score += 1 } # seen in an actual sample [pscustomobject]@{ token = $c; score = $score; inBad = $constructs[$c].inBad keywordBacked = $kwset.ContainsKey($lc) proseOnly = ($constructs[$c].prose -and -not $constructs[$c].sample) seedDomain = $seedDomain alreadyRoutes = ($seedDomain -eq $canonDomain) } } $cands = @($cands | Sort-Object -Property @{ Expression = 'score'; Descending = $true }, @{ Expression = 'token'; Descending = $false }) # Domain-mismatch flag: among candidates already known to the seed, # what domain dominates? If it disagrees with the declared domain, # the article's code looks like it belongs elsewhere. Skipped for # suppressor articles, which deliberately reference other domains. $seedMatched = @($cands | Where-Object { $_.seedDomain }) $mismatch = $null if (-not $isSuppressor -and $seedMatched.Count -ge 2) { $byDom = $seedMatched | Group-Object seedDomain | Sort-Object Count -Descending $dominant = $byDom[0] if ($dominant.Name -ne $canonDomain -and $dominant.Count -ge 2) { $mismatch = "declared domain '$canonDomain' but $($dominant.Count) sample construct(s) route to '$($dominant.Name)' ($([string]::Join(', ', ($dominant.Group | ForEach-Object { $_.token } | Select-Object -First 4))))" } } # Proposals: for a suppressor, the highest-value constructs (they say # "seeing this here is fine") become effect: suppress. For a normal # article, the highest-value NEW triggers = not already routed here. $effect = if ($isSuppressor) { 'suppress' } else { 'raise' } $proposals = @(if ($isSuppressor) { @($cands | Select-Object -First $Top) } else { @($cands | Where-Object { -not $_.alreadyRoutes } | Select-Object -First $Top) }) # Keyword suggestions: strong proposed tokens not already a keyword. $keywordSuggestions = @($proposals | Where-Object { -not $_.keywordBacked } | ForEach-Object { $_.token } | Select-Object -First 3) $domainHasSeed = ($seedByToken.Values -contains $canonDomain) $isGap = (-not $domainHasSeed) -or ($mismatch) -or ($applicability) -or ($isSuppressor) -or (-not $fm.hasSignals -and $proposals.Count -gt 0) if ($proposals.Count -eq 0 -and -not $mismatch -and -not $applicability) { return } if ($OnlyGaps -and -not $isGap) { return } $reports.Add([pscustomobject]@{ path = $rel; layer = $layerDir; domain = $canonDomain; rawDomain = $fm.domain hasSignals = $fm.hasSignals; domainHasSeed = $domainHasSeed suppressor = $isSuppressor; effect = $effect sampleCount = $sampleCount mismatch = $mismatch applicability = $applicability keywordSuggestions = $keywordSuggestions proposals = @($proposals | ForEach-Object { [pscustomobject]@{ suggestionId = Get-SuggestionId -ArticlePath $rel -Token $_.token -Effect $effect token = $_.token pattern = '\b' + [regex]::Escape($_.token) + '\b' effect = $effect keywordBacked = $_.keywordBacked inBad = $_.inBad proseOnly = $_.proseOnly note = if ($_.seedDomain) { "also seen in seed domain '$($_.seedDomain)'" } else { 'new construct' } } }) }) | Out-Null } } # ---- Emit ------------------------------------------------------------------ if ($AsJson) { [pscustomobject]@{ schemaVersion = $SchemaVersion toolVersion = $ToolVersion generatedAt = (Get-Date).ToUniversalTime().ToString('o') root = $BCQualityRoot filter = $Path articleCount = $reports.Count reports = @($reports) } | ConvertTo-Json -Depth 8 return } $flagged = @($reports | Where-Object { $_.mismatch }).Count $zeroDomain = @($reports | Where-Object { -not $_.domainHasSeed }).Count $suppressors = @($reports | Where-Object { $_.suppressor }).Count $applic = @($reports | Where-Object { $_.applicability }).Count Write-Host "" Write-Host "BCQuality authoring-assist (prototype) — SUGGESTIONS ONLY, nothing written." -ForegroundColor Cyan Write-Host ("Articles with suggestions: {0} Suppressor articles: {1} Domain-mismatch: {2} Applicability: {3} Zero-signal domain: {4}" -f $reports.Count, $suppressors, $flagged, $applic, $zeroDomain) if ($Path) { Write-Host ("Filter: *{0}*" -f $Path) } Write-Host ("-" * 78) foreach ($r in ($reports | Sort-Object domain, path)) { Write-Host "" $title = if ($r.suppressor) { "$($r.path) [SUPPRESSOR]" } else { $r.path } Write-Host $title -ForegroundColor White $domNote = if (-not $r.domainHasSeed) { " [zero-signal domain]" } else { "" } Write-Host (" domain: {0}{1} existing signals: {2} samples: {3}" -f $r.domain, $domNote, $(if ($r.hasSignals) { 'yes' } else { 'none' }), $r.sampleCount) if ($r.mismatch) { Write-Host (" ⚠ domain check: {0}" -f $r.mismatch) -ForegroundColor Yellow } if ($r.applicability) { Write-Host (" ⚠ applicability: {0}" -f $r.applicability) -ForegroundColor Yellow } if ($r.proposals.Count -gt 0) { $kind = if ($r.suppressor) { "SUPPRESS (this construct is NOT a violation here)" } else { "RAISE" } Write-Host (" proposed signals [{0}]: front-matter block ->" -f $kind) -ForegroundColor Green Write-Host " signals:" foreach ($p in $r.proposals) { $why = @() if ($p.keywordBacked) { $why += 'keyword-backed' } if ($p.inBad) { $why += 'anti-pattern sample' } if ($p.proseOnly) { $why += 'from prose' } $whyStr = if ($why) { " # " + ($why -join ', ') } else { "" } if ($r.suppressor) { Write-Host (" - token: {0}{1}" -f $p.token, $whyStr) Write-Host " effect: suppress" } else { Write-Host (" - {0}{1}" -f $p.token, $whyStr) } } } if ($r.keywordSuggestions.Count -gt 0) { Write-Host (" keyword idea: consider adding to 'keywords': {0}" -f ([string]::Join(', ', @($r.keywordSuggestions | ForEach-Object { ($_ -creplace '(?