mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 17:36:53 +01:00
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
This commit is contained in:
parent
ad8ccde595
commit
94d3d4358d
8 changed files with 1313 additions and 1 deletions
500
tools/Suggest-ArticleSignals.ps1
Normal file
500
tools/Suggest-ArticleSignals.ps1
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
<#
|
||||
.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
|
||||
Loading…
Add table
Add a link
Reference in a new issue