bcquality/tools/Suggest-ArticleSignals.ps1
dayland 94d3d4358d Authoring-assist: promote to first-class tool with non-blocking PR advisory
Promotes the authoring-assist prototype to a first-class BCQuality feature that
reviews knowledge-article front-matter and proposes missing routing `signals:`
(raise triggers and `effect: suppress` suppressors). The suggestion is ADVISORY
ONLY: it never fails a build; authors apply it via a normal PR under existing
R29 + CI + CODEOWNERS review.

Feature:
- tools/Suggest-ArticleSignals.ps1: suggestion engine hardened with a stable JSON
  contract (schemaVersion/toolVersion) and a deterministic, effect-sensitive
  per-proposal suggestionId, plus -ChangedFiles scoping for PR-diff runs. Runs
  self-contained: the routing seed is now OPTIONAL (built-in domain map), so this
  does not depend on the separate routing-index tuning thread.
- tools/New-AuthoringAssistComment.ps1: renders one upsertable, feedback-instrumented
  advisory comment (stable anchor + aa:meta/aa:article markers carrying suggestionIds
  + reaction footer) for the downstream acceptance-measurement work.
- .github/workflows/authoring-assist*.yml: unprivileged pull_request intake +
  trusted workflow_run runner (review/publish split) + in-repo self-test.
- tools/Test-SuggestArticleSignals.ps1: 23-check smoke test (contract, determinism,
  scoping, suppressor/prohibition, renderer markers, seed-free graceful degradation).

Dormant schema support:
- .github/scripts/validate_frontmatter.py: adds R29, validating the optional
  `signals` block shape (bare token or mapping with token + optional
  pattern/domain/effect in {raise,suppress}). Reserved & dormant — no production
  pipeline consumes it; the existing PR Reviewer only reads `effect: suppress`
  behind its BCQ_INDEX_V2 flag and falls back safely when absent. Existing rules
  (incl. R24 skill-id uniqueness, R27, R28) are unchanged.

Excludes the routing-index tuning thread entirely (separate work).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 75d852d6-18a6-4c58-986f-ed5ab16618fa
2026-07-24 10:03:29 +01:00

500 lines
26 KiB
PowerShell

<#
.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 '(?<!^)(?=[A-Z])', '-').ToLowerInvariant() })))) -ForegroundColor DarkCyan
}
}
Write-Host ""
Write-Host "Review each suggestion; add the ones that fit via a normal PR. R29 + CI validate the shape." -ForegroundColor DarkGray