mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-06 23:26:55 +01:00
Add knowledge-backed AL development
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
This commit is contained in:
parent
1a5afdc0eb
commit
56b80e6dcf
50 changed files with 11589 additions and 79 deletions
240
tools/Test-LearnCoverage.ps1
Normal file
240
tools/Test-LearnCoverage.ps1
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
<#
|
||||
.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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue