mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Add read-only planning and repository-changing development skills so BCQuality knowledge can guide features, bug fixes, refactors, upgrades, and maintenance before the existing AL review gate runs. Track Microsoft Learn ingestion and add development and BCApps-shaped guidance evaluation fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 638b66d2-9f06-4f60-8781-808709e1485c
240 lines
9.2 KiB
PowerShell
240 lines
9.2 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Validates Microsoft Learn ingestion coverage and reports progress.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')),
|
|
[string] $CatalogPath,
|
|
[string] $CoveragePath,
|
|
[switch] $Json
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$Root = (Resolve-Path -LiteralPath $Root).Path
|
|
if (-not $CatalogPath) {
|
|
$CatalogPath = Join-Path $Root 'coverage/microsoft-learn-developer-catalog.json'
|
|
}
|
|
if (-not $CoveragePath) {
|
|
$CoveragePath = Join-Path $Root 'coverage/learn-coverage.json'
|
|
}
|
|
|
|
foreach ($path in @($CatalogPath, $CoveragePath)) {
|
|
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
|
throw "Coverage input not found: $path"
|
|
}
|
|
}
|
|
|
|
$catalog = Get-Content -LiteralPath $CatalogPath -Raw | ConvertFrom-Json
|
|
$coverage = Get-Content -LiteralPath $CoveragePath -Raw | ConvertFrom-Json
|
|
$problems = [System.Collections.Generic.List[string]]::new()
|
|
|
|
if ($catalog.version -ne 1) {
|
|
$problems.Add("Unsupported catalog version: $($catalog.version)") | Out-Null
|
|
}
|
|
if ($coverage.version -ne 1) {
|
|
$problems.Add("Unsupported coverage version: $($coverage.version)") | Out-Null
|
|
}
|
|
if ([string]$coverage.catalog -ne 'coverage/microsoft-learn-developer-catalog.json') {
|
|
$problems.Add("Coverage catalog path must be coverage/microsoft-learn-developer-catalog.json.") | Out-Null
|
|
}
|
|
|
|
$catalogUnits = @{}
|
|
foreach ($unit in @($catalog.units)) {
|
|
$uid = [string]$unit.uid
|
|
if ($catalogUnits.ContainsKey($uid)) {
|
|
$problems.Add("Duplicate catalog unit uid: $uid") | Out-Null
|
|
} else {
|
|
$catalogUnits[$uid] = $unit
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace([string]$unit.title)) {
|
|
$problems.Add("${uid}: catalog unit title is empty.") | Out-Null
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace([string]$unit.url) -or
|
|
-not ([string]$unit.url).StartsWith('https://learn.microsoft.com/', [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
$problems.Add("${uid}: catalog unit URL is missing or not a Microsoft Learn URL.") | Out-Null
|
|
}
|
|
}
|
|
|
|
$catalogModules = @{}
|
|
foreach ($module in @($catalog.modules)) {
|
|
$uid = [string]$module.uid
|
|
if ($catalogModules.ContainsKey($uid)) {
|
|
$problems.Add("Duplicate catalog module uid: $uid") | Out-Null
|
|
} else {
|
|
$catalogModules[$uid] = $module
|
|
}
|
|
foreach ($unitUid in @($module.unitUids)) {
|
|
if (-not $catalogUnits.ContainsKey([string]$unitUid)) {
|
|
$problems.Add("${uid}: references unknown unit '$unitUid'.") | Out-Null
|
|
}
|
|
}
|
|
}
|
|
|
|
$catalogPaths = @{}
|
|
foreach ($path in @($catalog.learningPaths)) {
|
|
$uid = [string]$path.uid
|
|
if ($catalogPaths.ContainsKey($uid)) {
|
|
$problems.Add("Duplicate catalog learning-path uid: $uid") | Out-Null
|
|
} else {
|
|
$catalogPaths[$uid] = $path
|
|
}
|
|
foreach ($moduleUid in @($path.moduleUids)) {
|
|
if (-not $catalogModules.ContainsKey([string]$moduleUid)) {
|
|
$problems.Add("${uid}: references unknown module '$moduleUid'.") | Out-Null
|
|
}
|
|
}
|
|
}
|
|
|
|
if ([int]$catalog.counts.units -ne $catalogUnits.Count) {
|
|
$problems.Add("Catalog unit count does not match units array.") | Out-Null
|
|
}
|
|
if ([int]$catalog.counts.modules -ne $catalogModules.Count) {
|
|
$problems.Add("Catalog module count does not match modules array.") | Out-Null
|
|
}
|
|
if ([int]$catalog.counts.learningPaths -ne $catalogPaths.Count) {
|
|
$problems.Add("Catalog learning-path count does not match learningPaths array.") | Out-Null
|
|
}
|
|
|
|
foreach ($unit in @($catalog.units)) {
|
|
foreach ($moduleUid in @($unit.moduleUids)) {
|
|
if (-not $catalogModules.ContainsKey([string]$moduleUid)) {
|
|
$problems.Add("$($unit.uid): references unknown module '$moduleUid'.") | Out-Null
|
|
} elseif (@($catalogModules[[string]$moduleUid].unitUids) -notcontains [string]$unit.uid) {
|
|
$problems.Add("$($unit.uid): module '$moduleUid' does not link back to the unit.") | Out-Null
|
|
}
|
|
}
|
|
}
|
|
|
|
$validStatuses = @('in-progress', 'complete')
|
|
$validDispositions = @('candidate', 'authored', 'covered-existing', 'rejected', 'deferred')
|
|
$seenUnits = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
|
|
$seenOutcomes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
|
|
$articlePaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
|
|
$dispositionCounts = @{}
|
|
foreach ($disposition in $validDispositions) {
|
|
$dispositionCounts[$disposition] = 0
|
|
}
|
|
|
|
foreach ($unitCoverage in @($coverage.units)) {
|
|
$uid = [string]$unitCoverage.uid
|
|
if (-not $seenUnits.Add($uid)) {
|
|
$problems.Add("Duplicate coverage unit uid: $uid") | Out-Null
|
|
}
|
|
if (-not $catalogUnits.ContainsKey($uid)) {
|
|
$problems.Add("Coverage unit is absent from the catalog: $uid") | Out-Null
|
|
}
|
|
|
|
$reviewStatus = [string]$unitCoverage.reviewStatus
|
|
if ($validStatuses -notcontains $reviewStatus) {
|
|
$problems.Add("${uid}: invalid reviewStatus '$reviewStatus'.") | Out-Null
|
|
}
|
|
|
|
[object[]]$outcomes = @()
|
|
if ($unitCoverage.PSObject.Properties.Name -contains 'outcomes') {
|
|
$outcomes = @($unitCoverage.outcomes)
|
|
}
|
|
if ($reviewStatus -eq 'in-progress' -and -not $outcomes.Count) {
|
|
$problems.Add("${uid}: an in-progress unit must contain at least one outcome.") | Out-Null
|
|
}
|
|
|
|
foreach ($outcome in $outcomes) {
|
|
$id = [string]$outcome.id
|
|
if ($id -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') {
|
|
$problems.Add("${uid}: outcome id must be kebab-case: '$id'.") | Out-Null
|
|
} elseif (-not $seenOutcomes.Add($id)) {
|
|
$problems.Add("Duplicate outcome id: $id") | Out-Null
|
|
}
|
|
|
|
$disposition = [string]$outcome.disposition
|
|
if ($validDispositions -notcontains $disposition) {
|
|
$problems.Add("${id}: invalid disposition '$disposition'.") | Out-Null
|
|
continue
|
|
}
|
|
$dispositionCounts[$disposition]++
|
|
|
|
if ([string]::IsNullOrWhiteSpace([string]$outcome.title)) {
|
|
$problems.Add("${id}: title is required.") | Out-Null
|
|
}
|
|
|
|
if ($disposition -in @('candidate', 'authored', 'covered-existing')) {
|
|
if ([string]::IsNullOrWhiteSpace([string]$outcome.domain)) {
|
|
$problems.Add("${id}: domain is required for disposition '$disposition'.") | Out-Null
|
|
}
|
|
}
|
|
|
|
[object[]]$paths = @()
|
|
if ($outcome.PSObject.Properties.Name -contains 'articlePaths') {
|
|
$paths = @($outcome.articlePaths)
|
|
}
|
|
if ($disposition -in @('authored', 'covered-existing')) {
|
|
if (-not $paths.Count) {
|
|
$problems.Add("${id}: articlePaths is required for disposition '$disposition'.") | Out-Null
|
|
}
|
|
foreach ($relativePath in $paths) {
|
|
$relativePath = [string]$relativePath
|
|
if ($relativePath.Contains('\') -or -not $relativePath.EndsWith('.md')) {
|
|
$problems.Add("${id}: article path must be a forward-slash .md path: $relativePath") | Out-Null
|
|
continue
|
|
}
|
|
if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath) -PathType Leaf)) {
|
|
$problems.Add("${id}: article does not exist: $relativePath") | Out-Null
|
|
}
|
|
$articlePaths.Add($relativePath) | Out-Null
|
|
}
|
|
} elseif ($paths.Count) {
|
|
$problems.Add("${id}: disposition '$disposition' must not set articlePaths.") | Out-Null
|
|
}
|
|
|
|
$rationale = if ($outcome.PSObject.Properties.Name -contains 'rationale') {
|
|
[string]$outcome.rationale
|
|
} else {
|
|
''
|
|
}
|
|
if ($disposition -in @('rejected', 'deferred') -and
|
|
[string]::IsNullOrWhiteSpace($rationale)) {
|
|
$problems.Add("${id}: rationale is required for disposition '$disposition'.") | Out-Null
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($problems.Count) {
|
|
Write-Host "Learn coverage validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red
|
|
$problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
|
exit 1
|
|
}
|
|
|
|
$complete = @($coverage.units | Where-Object reviewStatus -eq 'complete').Count
|
|
$inProgress = @($coverage.units | Where-Object reviewStatus -eq 'in-progress').Count
|
|
$summary = [ordered]@{
|
|
catalogUnits = $catalogUnits.Count
|
|
completeUnits = $complete
|
|
inProgressUnits = $inProgress
|
|
unreviewedUnits = $catalogUnits.Count - $complete - $inProgress
|
|
candidateOutcomes = $dispositionCounts.candidate
|
|
authoredOutcomes = $dispositionCounts.authored
|
|
coveredExistingOutcomes = $dispositionCounts.'covered-existing'
|
|
rejectedOutcomes = $dispositionCounts.rejected
|
|
deferredOutcomes = $dispositionCounts.deferred
|
|
referencedArticles = $articlePaths.Count
|
|
}
|
|
|
|
if ($Json) {
|
|
$summary | ConvertTo-Json
|
|
} else {
|
|
$message = (
|
|
'Learn coverage: {0} units; {1} complete, {2} in progress, {3} unreviewed; ' +
|
|
'{4} candidate outcomes, {5} authored outcomes, {6} existing-coverage outcomes.'
|
|
) -f @(
|
|
$summary.catalogUnits,
|
|
$summary.completeUnits,
|
|
$summary.inProgressUnits,
|
|
$summary.unreviewedUnits,
|
|
$summary.candidateOutcomes,
|
|
$summary.authoredOutcomes,
|
|
$summary.coveredExistingOutcomes
|
|
)
|
|
Write-Host $message
|
|
}
|