From 0283d7d7b7f4f77a7e16177d3ec16fc616c60472 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 7 Oct 2026 20:54:18 +0200 Subject: [PATCH 01/12] Stub analyzer packages: Add-Type -OutputAssembly build and child-process load smoke test Co-Authored-By: Claude Fable 5.1 --- tests/Rulebook.Extract.Tests.ps1 | 55 +++++++++++ .../stub-analyzers/Build-StubPackage.ps1 | 99 +++++++++++++++++++ tests/fixtures/stub-analyzers/CodeAnalysis.cs | 49 +++++++++ tests/fixtures/stub-analyzers/CodeCop.cs | 36 +++++++ tests/fixtures/stub-analyzers/README.md | 9 ++ 5 files changed, 248 insertions(+) create mode 100644 tests/Rulebook.Extract.Tests.ps1 create mode 100644 tests/fixtures/stub-analyzers/Build-StubPackage.ps1 create mode 100644 tests/fixtures/stub-analyzers/CodeAnalysis.cs create mode 100644 tests/fixtures/stub-analyzers/CodeCop.cs create mode 100644 tests/fixtures/stub-analyzers/README.md diff --git a/tests/Rulebook.Extract.Tests.ps1 b/tests/Rulebook.Extract.Tests.ps1 new file mode 100644 index 0000000..ae69a4f --- /dev/null +++ b/tests/Rulebook.Extract.Tests.ps1 @@ -0,0 +1,55 @@ +# Extract suite for WP08 (#10): the stub analyzer packages of tests/fixtures/stub-analyzers/ and the descriptor +# extraction of Rulebook.Extract (docs/reference/scan-mechanics.md). Offline: the real packages run only in CI job +# scan-action. + +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + $script:stubRoot = Join-Path $PSScriptRoot 'fixtures' 'stub-analyzers' + $script:pwsh = (Get-Process -Id $PID).Path + $script:feed = Join-Path $TestDrive 'feed' + $build = & $pwsh -NoProfile -NonInteractive -File (Join-Path $stubRoot 'Build-StubPackage.ps1') -Variant tools-stable -OutputPath $feed 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Build-StubPackage failed: $($build -join "`n")" } +} + +Describe 'stub packages' { + It 'writes a flat container with the index and the nupkg' { + $id = 'microsoft.dynamics.businesscentral.development.tools' + (Get-Content -Raw (Join-Path $feed $id 'index.json') | ConvertFrom-Json).versions | Should-BeCollection @('18.0.43.1464') + Test-Path -LiteralPath (Join-Path $feed $id '18.0.43.1464' "$id.18.0.43.1464.nupkg") | Should-BeTrue + } + + It 'loads in a child pwsh and advertises the stub descriptors' { + $id = 'microsoft.dynamics.businesscentral.development.tools' + $extract = Join-Path $TestDrive 'extract' + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory((Join-Path $feed $id '18.0.43.1464' "$id.18.0.43.1464.nupkg"), $extract) + $dir = Join-Path $extract 'tools' 'net8.0' 'any' + $probe = { + param($dir) + $ErrorActionPreference = 'Stop' + # A handler built with GetNewClosure and calling Join-Path/Test-Path overflowed the stack; keep it to .NET calls. + $script:StubDir = $dir + [AppDomain]::CurrentDomain.add_AssemblyResolve({ + $p = [System.IO.Path]::Combine($script:StubDir, ([Reflection.AssemblyName]$args[1].Name).Name + '.dll') + if ([System.IO.File]::Exists($p)) { return [Reflection.Assembly]::LoadFrom($p) } + return $null + }) + $ca = [Reflection.Assembly]::LoadFrom((Join-Path $dir 'Microsoft.Dynamics.Nav.CodeAnalysis.dll')) + $base = $ca.GetType('Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics.DiagnosticAnalyzer', $true) + $codes = [Enum]::GetNames($ca.GetType('Microsoft.Dynamics.Nav.CodeAnalysis.ErrorCode', $true)) + $cop = [Reflection.Assembly]::LoadFrom((Join-Path $dir 'Microsoft.Dynamics.Nav.CodeCop.dll')) + $rows = foreach ($t in $cop.GetTypes()) { + if (-not $t.IsClass -or $t.IsAbstract -or -not $base.IsAssignableFrom($t) -or -not $t.GetConstructor([Type]::EmptyTypes)) { continue } + foreach ($d in ([Activator]::CreateInstance($t)).SupportedDiagnostics) { '{0}:{1}:{2}:{3}' -f $d.Id, $d.DefaultSeverity, $d.IsEnabledByDefault, $d.IsDeprecated } + } + [pscustomobject]@{ Codes = $codes; Rows = @($rows | Sort-Object) } | ConvertTo-Json -Compress + } + $command = "& { $probe } '$($dir -replace "'", "''")'" + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $output = & $pwsh -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1 + $LASTEXITCODE | Should-Be 0 -Because ($output -join "`n") + $result = ($output | Select-Object -Last 1) | ConvertFrom-Json + $result.Codes | Should-ContainCollection @('WRN_StubWarning', 'INF_StubInfo', 'HDN_StubHidden') + $result.Rows | Should-BeCollection @('AA0001:Warning:True:False', 'AA0001:Warning:True:False', 'AA0003:Warning:True:True') + } +} diff --git a/tests/fixtures/stub-analyzers/Build-StubPackage.ps1 b/tests/fixtures/stub-analyzers/Build-StubPackage.ps1 new file mode 100644 index 0000000..83b1e18 --- /dev/null +++ b/tests/fixtures/stub-analyzers/Build-StubPackage.ps1 @@ -0,0 +1,99 @@ +#requires -Version 7.4 +<# +.SYNOPSIS +Builds one stub analyzer package variant into a NuGet flat container folder for the Extract and Scan suites. +.DESCRIPTION +Compiles the C# stubs next to this script with Add-Type -OutputAssembly (Microsoft.Dynamics.Nav.CodeAnalysis.dll +first, then every cop referencing it), lays them out like the real package (tools//any/ for the +Development.Tools package, lib// for ALCops.Analyzers), writes a minimal nuspec, zips the result to +///..nupkg and adds the version to //index.json. +A variant selects the package, the version and the sources; '#define STUB_' is prepended to every source +so one file can carry the differences between versions. Run it in its own pwsh process (the suites do): the +compiled types must not be loaded into the test session. -ExtraTfm adds folders the resolver must never pick +(tools/net99.0/any/, lib/netstandard2.1/) holding text files named like the DLLs. Returns the nupkg path. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('tools-stable', 'tools-prerelease')][string]$Variant, + [Parameter(Mandatory)][string]$OutputPath, + [string[]]$Tfm = @('net8.0', 'net10.0'), + [switch]$ExtraTfm +) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$toolsId = 'microsoft.dynamics.businesscentral.development.tools' +$toolsCops = [ordered]@{ 'Microsoft.Dynamics.Nav.CodeCop' = 'CodeCop.cs' } +$variants = @{ + 'tools-stable' = @{ Id = $toolsId; Version = '18.0.43.1464'; Layout = 'tools'; WithCodeAnalysis = $true; Cops = $toolsCops } + 'tools-prerelease' = @{ Id = $toolsId; Version = '30.0.42.60748-beta'; Layout = 'tools'; WithCodeAnalysis = $true; Cops = $toolsCops } +} +$spec = $variants[$Variant] +$define = "#define STUB_$(($Variant -replace '[^A-Za-z0-9]', '_').ToUpperInvariant())`n" + +# [System.IO] resolves relative paths against the process directory, not the PowerShell location. +$OutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +$work = Join-Path ([System.IO.Path]::GetTempPath()) ('stub-' + [guid]::NewGuid().ToString('n')) +$bin = Join-Path $work 'bin' +$pkg = Join-Path $work 'pkg' +[void](New-Item -ItemType Directory -Path $bin, $pkg -Force) +try { + $compile = { + param([string]$Source, [string]$AssemblyName, [string[]]$References) + $text = $define + [System.IO.File]::ReadAllText((Join-Path $PSScriptRoot $Source)) + $out = Join-Path $bin "$AssemblyName.dll" + Add-Type -TypeDefinition $text -OutputAssembly $out -OutputType Library -ReferencedAssemblies (@('System.Runtime', 'System.Collections.Immutable') + $References) + return $out + } + # The ALCops variants reference the CodeAnalysis stub but do not ship it (the tools package hosts it). + $codeAnalysis = & $compile 'CodeAnalysis.cs' 'Microsoft.Dynamics.Nav.CodeAnalysis' @() + $built = [System.Collections.Generic.List[string]]::new() + if ($spec.WithCodeAnalysis) { $built.Add($codeAnalysis) } + foreach ($cop in $spec.Cops.GetEnumerator()) { $built.Add((& $compile $cop.Value $cop.Key @($codeAnalysis))) } + + $folderOf = { param([string]$Framework) if ($spec.Layout -eq 'tools') { Join-Path $pkg 'tools' $Framework 'any' } else { Join-Path $pkg 'lib' $Framework } } + foreach ($framework in $Tfm) { + $folder = & $folderOf $framework + [void](New-Item -ItemType Directory -Path $folder -Force) + foreach ($dll in $built) { Copy-Item -LiteralPath $dll -Destination $folder } + } + if ($ExtraTfm) { + foreach ($framework in 'net99.0', 'netstandard2.1') { + $folder = & $folderOf $framework + [void](New-Item -ItemType Directory -Path $folder -Force) + foreach ($dll in $built) { [System.IO.File]::WriteAllText((Join-Path $folder (Split-Path -Leaf $dll)), "not an assembly`n") } + } + } + $nuspec = @" + + + + $($spec.Id) + $($spec.Version) + ALCops test stub + Stub package built by tests/fixtures/stub-analyzers/Build-StubPackage.ps1 ($Variant). + + +"@ + [System.IO.File]::WriteAllText((Join-Path $pkg "$($spec.Id).nuspec"), ($nuspec -replace "`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) + + $versionLower = $spec.Version.ToLowerInvariant() + $packageDir = Join-Path $OutputPath $spec.Id $versionLower + [void](New-Item -ItemType Directory -Path $packageDir -Force) + $nupkg = Join-Path $packageDir "$($spec.Id).$versionLower.nupkg" + if (Test-Path -LiteralPath $nupkg) { Remove-Item -LiteralPath $nupkg -Force } + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::CreateFromDirectory($pkg, $nupkg) + + $indexPath = Join-Path $OutputPath $spec.Id 'index.json' + $versions = [System.Collections.Generic.List[string]]::new() + if (Test-Path -LiteralPath $indexPath) { + foreach ($v in (Get-Content -Raw -LiteralPath $indexPath | ConvertFrom-Json).versions) { $versions.Add([string]$v) } + } + if ($versionLower -notin $versions) { $versions.Add($versionLower) } + $index = ConvertTo-Json -InputObject ([ordered]@{ versions = $versions.ToArray() }) -Depth 3 + [System.IO.File]::WriteAllText($indexPath, ($index -replace "`r`n", "`n") + "`n", [System.Text.UTF8Encoding]::new($false)) + return $nupkg +} finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/tests/fixtures/stub-analyzers/CodeAnalysis.cs b/tests/fixtures/stub-analyzers/CodeAnalysis.cs new file mode 100644 index 0000000..c9c3962 --- /dev/null +++ b/tests/fixtures/stub-analyzers/CodeAnalysis.cs @@ -0,0 +1,49 @@ +// Stub of Microsoft.Dynamics.Nav.CodeAnalysis.dll for the Extract suite (docs/reference/scan-mechanics.md). +// Only the surface Get-AnalyzerDescriptor reflects over: the internal ErrorCode enum, DiagnosticSeverity, +// DiagnosticDescriptor and the abstract DiagnosticAnalyzer. No embedded resources, so AL titles are null. +using System.Collections.Immutable; + +namespace Microsoft.Dynamics.Nav.CodeAnalysis +{ + public enum DiagnosticSeverity { Hidden = 0, Info = 1, Warning = 2, Error = 3 } + + internal enum ErrorCode + { + Void = 0, + ERR_BelowHundred = 42, + WRN_StubWarning = 200, + ERR_StubError = 1003, + INF_StubInfo = 1026, + HDN_StubHidden = 1030 + } +} + +namespace Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics +{ + public sealed class DiagnosticDescriptor + { + public DiagnosticDescriptor(string id, string title, string category, Microsoft.Dynamics.Nav.CodeAnalysis.DiagnosticSeverity defaultSeverity, bool isEnabledByDefault, string helpLinkUri = null, bool isDeprecated = false) + { + Id = id; + Title = title; + Category = category; + DefaultSeverity = defaultSeverity; + IsEnabledByDefault = isEnabledByDefault; + HelpLinkUri = helpLinkUri; + IsDeprecated = isDeprecated; + } + + public string Id { get; } + public string Title { get; } + public string Category { get; } + public Microsoft.Dynamics.Nav.CodeAnalysis.DiagnosticSeverity DefaultSeverity { get; } + public bool IsEnabledByDefault { get; } + public string HelpLinkUri { get; } + public bool IsDeprecated { get; } + } + + public abstract class DiagnosticAnalyzer + { + public abstract ImmutableArray SupportedDiagnostics { get; } + } +} diff --git a/tests/fixtures/stub-analyzers/CodeCop.cs b/tests/fixtures/stub-analyzers/CodeCop.cs new file mode 100644 index 0000000..e8aa408 --- /dev/null +++ b/tests/fixtures/stub-analyzers/CodeCop.cs @@ -0,0 +1,36 @@ +// Stub of Microsoft.Dynamics.Nav.CodeCop.dll: AA0001 advertised by two analyzers, AA0002 field-only, AA0003 +// deprecated, an abstract analyzer and one without a parameterless constructor (both skipped). +using System.Collections.Immutable; +using Microsoft.Dynamics.Nav.CodeAnalysis; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; + +namespace Microsoft.Dynamics.Nav.CodeCop +{ + internal static class Descriptors + { + private const string Link = "https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/analyzers/codecop-aa0001?wt.mc_id=stub"; + public static readonly DiagnosticDescriptor AA0001 = new DiagnosticDescriptor("AA0001", "There must be exactly one space character on each side of a binary operator", "Readability", DiagnosticSeverity.Warning, true, Link); + public static readonly DiagnosticDescriptor AA0002 = new DiagnosticDescriptor("AA0002", "Field-only descriptor", "Readability", DiagnosticSeverity.Info, false); + public static readonly DiagnosticDescriptor AA0003 = new DiagnosticDescriptor("AA0003", "Deprecated rule", "Readability", DiagnosticSeverity.Warning, true, null, true); + } + + public sealed class SpacingAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Descriptors.AA0001, Descriptors.AA0003); + } + + public sealed class SecondSpacingAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Descriptors.AA0001); + } + + public abstract class AbstractAnalyzer : DiagnosticAnalyzer + { + } + + public sealed class NoDefaultConstructorAnalyzer : DiagnosticAnalyzer + { + public NoDefaultConstructorAnalyzer(int unused) { _ = unused; } + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Empty; + } +} diff --git a/tests/fixtures/stub-analyzers/README.md b/tests/fixtures/stub-analyzers/README.md new file mode 100644 index 0000000..5a15545 --- /dev/null +++ b/tests/fixtures/stub-analyzers/README.md @@ -0,0 +1,9 @@ +# Stub analyzer packages + +C# stubs of the Business Central compiler and its cops, compiled at test time into fixture nupkgs so the Extract and +Scan suites run offline (WP08, plan 5.7). `Build-StubPackage.ps1 -Variant -OutputPath ` compiles them +with `Add-Type -OutputAssembly`, lays them out like the real packages (`tools//any/`, `lib//`) and writes a +NuGet flat container (`/index.json`, `//..nupkg`) that `Get-NuGetVersionIndex -Source` +reads as a folder. Run it in its own pwsh process so the stub types never load into the test session. + +The real packages are scanned only by the CI job `scan-action` and the live end-to-end run. From f7edffb0b65b7da5d84fa6e906676900ff95238e Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 7 Oct 2026 20:55:17 +0200 Subject: [PATCH 02/12] Extract smoke test: resolve pwsh from $PSHOME, not the process path Co-Authored-By: Claude Fable 5.1 --- tests/Rulebook.Extract.Tests.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Rulebook.Extract.Tests.ps1 b/tests/Rulebook.Extract.Tests.ps1 index ae69a4f..d0a7d7f 100644 --- a/tests/Rulebook.Extract.Tests.ps1 +++ b/tests/Rulebook.Extract.Tests.ps1 @@ -5,7 +5,9 @@ BeforeAll { $script:repoRoot = Split-Path -Parent $PSScriptRoot $script:stubRoot = Join-Path $PSScriptRoot 'fixtures' 'stub-analyzers' - $script:pwsh = (Get-Process -Id $PID).Path + # The process path is dotnet when pwsh runs as a .NET global tool (the WSL check); prefer the pwsh in $PSHOME. + $script:pwsh = @((Join-Path $PSHOME 'pwsh.exe'), (Join-Path $PSHOME 'pwsh')) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $pwsh) { $script:pwsh = (Get-Command pwsh -CommandType Application | Select-Object -First 1).Source } $script:feed = Join-Path $TestDrive 'feed' $build = & $pwsh -NoProfile -NonInteractive -File (Join-Path $stubRoot 'Build-StubPackage.ps1') -Variant tools-stable -OutputPath $feed 2>&1 if ($LASTEXITCODE -ne 0) { throw "Build-StubPackage failed: $($build -join "`n")" } From e7096c32dd91a7c469c4e925d2f22a9d7944466b Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Wed, 7 Oct 2026 21:39:04 +0200 Subject: [PATCH 03/12] Scan modules NuGet, Extract, Catalog, Quarantine, Scan; GitHub living pull request; schemas; ScanDiagnostics action and workflow Work in progress of WP08 (#10): the modules, the action, the template workflow, the scan settings key, the scan-state schema, C14 for the scan state, the stub analyzer packages (compiled with the Roslyn of pwsh, so the assemblies carry their real names) and the NuGet, Extract and Catalog suites. Co-Authored-By: Claude Fable 5.1 --- actions/ScanDiagnostics/ScanDiagnostics.ps1 | 269 ++++++ actions/ScanDiagnostics/action.yaml | 102 +++ modules/Rulebook.Catalog.psd1 | 35 + modules/Rulebook.Catalog.psm1 | 557 ++++++++++++ modules/Rulebook.Extract.psd1 | 28 + modules/Rulebook.Extract.psm1 | 445 ++++++++++ modules/Rulebook.GitHub.psd1 | 4 +- modules/Rulebook.GitHub.psm1 | 87 +- modules/Rulebook.NuGet.psd1 | 28 + modules/Rulebook.NuGet.psm1 | 226 +++++ modules/Rulebook.Quarantine.psd1 | 32 + modules/Rulebook.Quarantine.psm1 | 303 +++++++ modules/Rulebook.Scan.psd1 | 29 + modules/Rulebook.Scan.psm1 | 837 ++++++++++++++++++ modules/Rulebook.Template.psm1 | 24 +- modules/Rulebook.Update.psd1 | 1 + modules/Rulebook.Update.psm1 | 28 +- modules/Rulebook.Validate.psm1 | 16 +- schemas/rulebook-catalog.schema.json | 28 +- schemas/rulebook-scan-state.schema.json | 44 + schemas/rulebook-settings.schema.json | 8 + template/.github/Rulebook-Settings.json | 1 + .../.github/workflows/ScanDiagnostics.yaml | 68 ++ tests/Helpers/StubFeed.ps1 | 92 ++ tests/Rulebook.Catalog.Tests.ps1 | 260 ++++++ tests/Rulebook.Extract.Tests.ps1 | 246 ++++- tests/Rulebook.NuGet.Tests.ps1 | 146 +++ tests/Rulebook.Template.Tests.ps1 | 3 +- tests/Rulebook.Validate.Tests.ps1 | 23 +- .../rulebook-catalog/advertised-string.json | 7 + .../defaultChanges-unknown-field.json | 7 + .../rulebook-scan-state/extra-key.json | 10 + .../rulebook-scan-state/missing-version.json | 10 + .../scan-schedule-four-fields.json | 26 + .../rulebook-settings/scan-unknown-key.json | 26 + .../valid/rulebook-catalog/scan-fields.json | 8 + .../rulebook-scan-state/one-package.json | 10 + .../rulebook-scan-state/two-packages.json | 14 + .../rulebook-settings/scan-schedule-null.json | 26 + .../rulebook-settings/template-default.json | 1 + .../stub-analyzers/Build-StubPackage.ps1 | 75 +- tests/fixtures/stub-analyzers/CodeAnalysis.cs | 2 +- tests/fixtures/stub-analyzers/Common.cs | 13 + tests/fixtures/stub-analyzers/LinterCop.cs | 41 + tests/fixtures/stub-analyzers/OneIdCop.cs | 15 + .../stub-analyzers/TestAutomationCop.cs | 13 + 46 files changed, 4169 insertions(+), 105 deletions(-) create mode 100644 actions/ScanDiagnostics/ScanDiagnostics.ps1 create mode 100644 actions/ScanDiagnostics/action.yaml create mode 100644 modules/Rulebook.Catalog.psd1 create mode 100644 modules/Rulebook.Catalog.psm1 create mode 100644 modules/Rulebook.Extract.psd1 create mode 100644 modules/Rulebook.Extract.psm1 create mode 100644 modules/Rulebook.NuGet.psd1 create mode 100644 modules/Rulebook.NuGet.psm1 create mode 100644 modules/Rulebook.Quarantine.psd1 create mode 100644 modules/Rulebook.Quarantine.psm1 create mode 100644 modules/Rulebook.Scan.psd1 create mode 100644 modules/Rulebook.Scan.psm1 create mode 100644 schemas/rulebook-scan-state.schema.json create mode 100644 template/.github/workflows/ScanDiagnostics.yaml create mode 100644 tests/Helpers/StubFeed.ps1 create mode 100644 tests/Rulebook.Catalog.Tests.ps1 create mode 100644 tests/Rulebook.NuGet.Tests.ps1 create mode 100644 tests/fixtures/schemas/invalid/rulebook-catalog/advertised-string.json create mode 100644 tests/fixtures/schemas/invalid/rulebook-catalog/defaultChanges-unknown-field.json create mode 100644 tests/fixtures/schemas/invalid/rulebook-scan-state/extra-key.json create mode 100644 tests/fixtures/schemas/invalid/rulebook-scan-state/missing-version.json create mode 100644 tests/fixtures/schemas/invalid/rulebook-settings/scan-schedule-four-fields.json create mode 100644 tests/fixtures/schemas/invalid/rulebook-settings/scan-unknown-key.json create mode 100644 tests/fixtures/schemas/valid/rulebook-catalog/scan-fields.json create mode 100644 tests/fixtures/schemas/valid/rulebook-scan-state/one-package.json create mode 100644 tests/fixtures/schemas/valid/rulebook-scan-state/two-packages.json create mode 100644 tests/fixtures/schemas/valid/rulebook-settings/scan-schedule-null.json create mode 100644 tests/fixtures/stub-analyzers/Common.cs create mode 100644 tests/fixtures/stub-analyzers/LinterCop.cs create mode 100644 tests/fixtures/stub-analyzers/OneIdCop.cs create mode 100644 tests/fixtures/stub-analyzers/TestAutomationCop.cs diff --git a/actions/ScanDiagnostics/ScanDiagnostics.ps1 b/actions/ScanDiagnostics/ScanDiagnostics.ps1 new file mode 100644 index 0000000..dfc5c11 --- /dev/null +++ b/actions/ScanDiagnostics/ScanDiagnostics.ps1 @@ -0,0 +1,269 @@ +#requires -Version 7.4 +<# +.SYNOPSIS +Entry script of the ScanDiagnostics action: scan the analyzer packages and publish the result as the living pull +request of an organization rulebook repository (or a direct commit). +.DESCRIPTION +1. Reads the settings and the secret name (ghTokenWorkflowSecretName). +2. Reads the quarantine policy (Get-QuarantinePolicy): no policy is one error annotation with the documented + message, failure policy, before any request. +3. Unless -DryRun: the token guard (The secret is needed to scan diagnostics), the exchange and the mask. +4. Get-RulebookScanPlan: a NuGet, extraction or other failure is one error annotation (failure nuget, extract, + error). +5. Mode nothing-new: one notice, result nothing-new, exit code 0. +6. A candidate that does not validate: one error annotation per finding, failure validation, nothing pushed. +7. -DryRun: the summary and the outputs (candidatePath included; the work folder is kept), result dry-run. +8. Publish-RulebookScan (or -PublishCommand): pull-request, pull-request-updated, direct-commit or no-changes. +9. The job summary (capped at -SummaryLimit) and GITHUB_OUTPUT: result, newIds, quarantined, changedDefaults, + released, scannedVersions (@:, comma separated), pullRequestUrl, candidatePath, + elapsedSeconds, failure (policy, token, nuget, extract, validation, push, pull-request, error; empty on success). +Returns { ExitCode, Result, Failure, Plan, Publish, Annotations, Summary, Outputs } and never calls exit, so tests run +it in-process; action.yaml exits with ExitCode. -PackageSource (a flat container folder), -RemoteUrl, -ApiUrl, +-WorkPath, -SummaryLimit, -Now and -PublishCommand are test seams; a -WorkPath the caller passes is left in place, +the temporary work folder the script names itself is removed at the end except after a dry run. +#> +[CmdletBinding()] +param( + [string]$RepositoryRoot = '.', + [AllowEmptyString()][string]$Token, + [bool]$IncludePrerelease = $true, + [switch]$DirectCommit, + [switch]$DryRun, + [AllowEmptyString()][string]$BaseBranch = $env:GITHUB_REF_NAME, + [AllowEmptyString()][string]$Actor = $env:GITHUB_ACTOR, + [AllowEmptyString()][string]$Repository = $env:GITHUB_REPOSITORY, + [AllowEmptyString()][string]$RemoteUrl, + [string]$ApiUrl = $(if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { 'https://api.github.com' }), + [AllowEmptyString()][string]$PackageSource, + [string]$WorkPath, + [string]$SummaryPath = $env:GITHUB_STEP_SUMMARY, + [string]$WorkspaceRoot = $env:GITHUB_WORKSPACE, + [int]$SummaryLimit = 900KB, + [System.DateTimeOffset]$Now = [System.DateTimeOffset]::UtcNow, + # Test seam: runs instead of Publish-RulebookScan with the same parameters (the script re-imports the modules, + # so a Pester mock of the function does not reach it). + [scriptblock]$PublishCommand +) + +Set-StrictMode -Version 3.0 +$watch = [System.Diagnostics.Stopwatch]::StartNew() +$modules = Join-Path $PSScriptRoot '..' '..' 'modules' +Import-Module (Join-Path $modules 'Rulebook.Generate.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.GitHub.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Update.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Quarantine.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Scan.psd1') -Force + +$docsUrl = 'https://github.com/ALCops/rulebook/blob/main/docs/ghtokenworkflow.md' + +# The helpers below are copies of CheckForUpdates.ps1 (a shared action helper module is #58, WP12). +function Format-AnnotationText { + # Workflow command escaping: the message part escapes %, CR and LF; a property value also : and ,. + param([AllowNull()][string]$Text, [switch]$Property) + if ($null -eq $Text) { return '' } + $escaped = $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + if ($Property) { $escaped = $escaped.Replace(':', '%3A').Replace(',', '%2C') } + return $escaped +} + +function ConvertTo-SingleLine { + # A message on one Markdown line (list item or paragraph); no table escaping. + param([AllowNull()][string]$Text) + if ([string]::IsNullOrEmpty($Text)) { return '' } + return $Text.Replace("`r", ' ').Replace("`n", ' ') +} + +function Add-Annotation { + param([ValidateSet('error', 'warning', 'notice')][string]$Command = 'error', [string]$File, [string]$Title = 'ScanDiagnostics', [Parameter(Mandatory)][string]$Message) + $properties = "title=$(Format-AnnotationText $Title -Property)" + if ($File) { $properties = "file=$(Format-AnnotationText $File -Property),$properties" } + $line = "::$Command $properties::$(Format-AnnotationText $Message)" + $script:annotations.Add($line) + Write-Host $line +} + +function Write-Text { + param([AllowNull()][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Text) + if ([string]::IsNullOrEmpty($Path)) { return } + [System.IO.File]::AppendAllText($Path, $Text.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) +} + +# [System.IO.File] resolves a relative path against the process directory, not the PowerShell location. +$resolvePath = { param($Path) if ([string]::IsNullOrEmpty($Path)) { $Path } else { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } +$SummaryPath = & $resolvePath $SummaryPath +$ownWork = [string]::IsNullOrEmpty($WorkPath) +if ($ownWork) { + $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } + $WorkPath = Join-Path $tempRoot ('rulebook-scan-' + [guid]::NewGuid().ToString('n').Substring(0, 8)) +} +$WorkPath = & $resolvePath $WorkPath +$script:annotations = [System.Collections.Generic.List[string]]::new() +$script:failure = $null +function Add-Failure { + param([Parameter(Mandatory)][string]$Kind) + if ($null -eq $script:failure) { $script:failure = $Kind } +} + +# Every token obtained by an exchange is masked before anything can print it. +$maskToken = { param([string]$Value) if (-not [string]::IsNullOrEmpty($Value)) { Write-Host "::add-mask::$Value" } } +$plan = $null +$publish = $null +$result = $null +$summaryMessage = $null +$failed = $false +$secretName = 'GHTOKENWORKFLOW' + +try { + # 1. Settings and the secret name. + $root = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath + $workspace = if ([string]::IsNullOrEmpty($WorkspaceRoot)) { $root } else { (Resolve-Path -LiteralPath $WorkspaceRoot -ErrorAction Stop).ProviderPath } + $separator = [System.IO.Path]::DirectorySeparatorChar + $settingsFile = Join-Path $root '.github' 'Rulebook-Settings.json' + if (-not (Test-Path -LiteralPath $settingsFile -PathType Leaf)) { throw "Settings missing: .github/Rulebook-Settings.json in $root" } + try { + $settings = Get-Content -LiteralPath $settingsFile -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop + } catch { + throw "Invalid JSON in .github/Rulebook-Settings.json: $($_.Exception.Message)" + } + $setting = { param([string[]]$Path) $value = $settings; foreach ($key in $Path) { if ($value -isnot [System.Collections.IDictionary] -or -not $value.Contains($key)) { return $null }; $value = $value[$key] }; return $value } + $name = [string](& $setting 'ghTokenWorkflowSecretName') + $nameValid = $name -cmatch '^[A-Za-z_][A-Za-z0-9_]*$' -and $name -inotmatch '^GITHUB_' + if (-not [string]::IsNullOrEmpty($name) -and $nameValid) { $secretName = $name } + + # 2. The quarantine policy, before any request (D14: no default). + try { + $null = Get-QuarantinePolicy -Settings $settings + } catch { + Add-Failure 'policy' + throw + } + + # 3. The write token, unless this is a dry run. + $access = $null + if (-not $DryRun) { + if (-not [string]::IsNullOrEmpty($name) -and -not $nameValid) { + Add-Failure 'token' + throw "ghTokenWorkflowSecretName '$name' in .github/Rulebook-Settings.json is not a valid secret name (letters, digits and underscores, not starting with a digit or GITHUB_). Read $docsUrl" + } + if ([string]::IsNullOrWhiteSpace($Token)) { + Add-Failure 'token' + throw "The $secretName secret is needed to scan diagnostics. Read $docsUrl" + } + if ([string]::IsNullOrEmpty($Repository)) { throw 'The scan needs the repository (GITHUB_REPOSITORY) as owner/name.' } + if ([string]::IsNullOrEmpty($BaseBranch)) { throw 'The scan needs baseBranch.' } + try { + $access = Get-GitHubAccessToken -Token $Token -Repository $Repository -ApiUrl $ApiUrl + } catch { + Add-Failure 'token' + throw "The $secretName secret could not be used: $($_.Exception.Message)" + } + if (-not [string]::IsNullOrEmpty($access.Token)) { & $maskToken $access.Token } + Write-Host "Write token: $($access.Kind)" + } + + # 4. The plan. + $plan = Get-RulebookScanPlan -RepositoryRoot $root -IncludePrerelease $IncludePrerelease -Source $PackageSource -WorkPath (Join-Path $WorkPath 'plan') -Now $Now + foreach ($channel in $plan.Channels) { Write-Host "NuGet: $($channel.PackageId) stable $($channel.Stable), prerelease $(if ($channel.Prerelease) { $channel.Prerelease } else { '-' })" } + foreach ($item in $plan.Scanned) { Write-Host "Scanned $($item.PackageId) $($item.Version) ($($item.Channel)): $($item.Records.Records.Count) ids, $(@($item.Diff.NewIds).Count) new, $($item.Extraction.ElapsedSeconds) s in the child process on .NET $($item.Extraction.Runtime)" } + foreach ($note in $plan.Notes) { Write-Host "Note: $note" } + if ($plan.Failure) { + Add-Failure $plan.Failure + $what = switch ($plan.Failure) { 'nuget' { 'The NuGet packages could not be read' } 'extract' { 'The diagnostics could not be extracted' } default { 'The scan failed' } } + throw "$what`: $($plan.FailureMessage)" + } + + if ($plan.Mode -eq 'nothing-new') { + # 5. Nothing new. + $versions = @($plan.Channels | ForEach-Object { "$($_.PackageId) $($_.Stable) stable" + $(if ($_.Prerelease) { ", $($_.Prerelease) prerelease" } else { '' }) }) + $summaryMessage = "No new package version ($($versions -join '; ')); nothing to do" + Add-Annotation -Command notice -Message $summaryMessage + $result = 'nothing-new' + } elseif (-not $plan.Valid) { + # 6. The candidate does not validate. + foreach ($finding in @($plan.Findings | Where-Object Severity -EQ 'error')) { + $message = if ($finding.Id) { "$($finding.Id): $($finding.Message)" } else { $finding.Message } + $file = if ($finding.File) { [System.IO.Path]::GetRelativePath($workspace, (Join-Path $root $finding.File)).Replace($separator, '/') } else { $null } + Add-Annotation -File $file -Title $finding.Rule -Message "The scanned rulebook would not validate: $message" + } + Add-Failure 'validation' + $summaryMessage = 'The scanned rulebook does not validate; nothing was pushed. The findings come from the repository after the scan.' + $failed = $true + } elseif ($DryRun) { + # 7. Dry run. + $summaryMessage = "Dry run: $(Get-ScanTitle -Plan $plan); nothing was pushed" + Add-Annotation -Command notice -Message $summaryMessage + $result = 'dry-run' + } else { + # 8. Publish. + $labels = [string[]]@(& $setting 'commitOptions', 'pullRequestLabels' | Where-Object { $_ -is [string] -and $_ -ne '' }) + try { + $publishParameters = @{ + Plan = $plan; RepositoryRoot = $root; Repository = $Repository; RemoteUrl = $RemoteUrl; Token = $access.Token; BaseBranch = $BaseBranch + DirectCommit = [bool]$DirectCommit; Actor = $Actor; Labels = $labels; WorkPath = (Join-Path $WorkPath 'publish'); ApiUrl = $ApiUrl + } + $publish = if ($null -ne $PublishCommand) { & $PublishCommand @publishParameters } else { Publish-RulebookScan @publishParameters } + } catch { + $stage = [string]$_.Exception.Data['Stage'] + if ($stage -cnotin 'push', 'pull-request') { $stage = 'push' } + Add-Failure $stage + $what = if ($stage -eq 'pull-request') { 'Failed to create or update the scan pull request' } else { 'Failed to push the scan' } + throw "$what. Make sure that the token in the secret $secretName is not expired and may write contents and pull requests of $Repository. Read $docsUrl (Error was: $($_.Exception.Message))" + } + $result = $publish.Result + switch ($publish.Result) { + 'no-changes' { $summaryMessage = 'No changes to commit' } + 'direct-commit' { $summaryMessage = "Scan committed to $($publish.Branch) ($($publish.Sha.Substring(0, [math]::Min(7, $publish.Sha.Length))))" } + 'pull-request-updated' { $summaryMessage = "Pull request updated: $($publish.PullRequestUrl)" } + default { $summaryMessage = "Pull request: $($publish.PullRequestUrl)" + $(if ($publish.Fallback) { ' (the direct commit was refused)' } else { '' }) } + } + Add-Annotation -Command notice -Message $summaryMessage + } +} catch { + Add-Annotation -Message $_.Exception.Message + Add-Failure 'error' + $summaryMessage = $_.Exception.Message + $failed = $true +} finally { + if ($ownWork -and -not ($DryRun -and $result -eq 'dry-run') -and (Test-Path -LiteralPath $WorkPath)) { Remove-Item -LiteralPath $WorkPath -Recurse -Force -ErrorAction SilentlyContinue } +} + +# 9. Summary and outputs. +$summary = "## Diagnostic scan`n`n$(ConvertTo-SingleLine $summaryMessage)`n`n" +if ($null -ne $plan) { + try { + $summary = ConvertTo-ScanSummary -Plan $plan -Result $publish -Message $summaryMessage + } catch { + Write-Host "The summary could not be written in full: $($_.Exception.Message)" + } +} +$summary = $summary.Replace("`r`n", "`n") +# The runner caps a step summary at 1 MiB; stay well below it (-SummaryLimit, 900 KiB). +$where = if ($null -ne $publish -and $publish.PSObject.Properties['Body'] -and $publish.Body) { 'the pull request body' } else { 'the job log' } +$summary = Limit-SummaryText -Text $summary -MaxBytes $SummaryLimit -Footer "The summary was cut at $([math]::Floor($SummaryLimit / 1KB)) KiB; the full lists are in $where." +Write-Text -Path $SummaryPath -Text $summary +$counts = if ($null -ne $plan -and $null -ne $plan.Counts) { $plan.Counts } else { $null } +$outputs = [ordered]@{ + result = $(if ($failed) { '' } elseif ($result) { $result } else { '' }) + newIds = $(if ($counts) { $counts.NewIds } else { 0 }) + quarantined = $(if ($counts) { $counts.Quarantined } else { 0 }) + changedDefaults = $(if ($counts) { $counts.ChangedDefaults } else { 0 }) + released = $(if ($counts) { $counts.Released } else { 0 }) + scannedVersions = $(if ($null -ne $plan) { @($plan.Scanned | ForEach-Object { "$($_.PackageId)@$($_.Version):$($_.Channel)" }) -join ',' } else { '' }) + pullRequestUrl = $(if ($null -ne $publish -and $publish.PullRequestUrl) { $publish.PullRequestUrl } else { '' }) + candidatePath = $(if ($null -ne $plan -and $plan.CandidatePath -and $result -eq 'dry-run') { $plan.CandidatePath } else { '' }) + elapsedSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1).ToString([System.Globalization.CultureInfo]::InvariantCulture) + failure = $script:failure +} +if ($env:GITHUB_OUTPUT) { + Write-Text -Path $env:GITHUB_OUTPUT -Text ((@($outputs.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "`n") + "`n") +} +[pscustomobject]@{ + ExitCode = $(if ($failed) { 1 } else { 0 }) + Result = $outputs.result + Failure = $script:failure + Plan = $plan + Publish = $publish + Annotations = $script:annotations.ToArray() + Summary = $summary + Outputs = $outputs +} diff --git a/actions/ScanDiagnostics/action.yaml b/actions/ScanDiagnostics/action.yaml new file mode 100644 index 0000000..8cc5c20 --- /dev/null +++ b/actions/ScanDiagnostics/action.yaml @@ -0,0 +1,102 @@ +name: Rulebook ScanDiagnostics +description: Scans the newest Business Central compiler and ALCops packages for diagnostic ids, quarantines new ids by the organization's policy, records changed analyzer defaults and publishes the result as one living pull request (or a direct commit). +author: ALCops + +inputs: + token: + description: The GHTOKENWORKFLOW value (a personal access token or GitHub App JSON). Needed unless dryRun is 'true'. + required: false + default: '' + includePrerelease: + description: "'true' also scans the newest prerelease of each package when it is newer than the stable one." + required: false + default: 'true' + directCommit: + description: "'true' pushes to baseBranch instead of opening a pull request (falls back to the scan branch when the push is refused)." + required: false + default: 'false' + dryRun: + description: "'true' builds and validates the scan without a token and pushes nothing; the candidatePath output names the result." + required: false + default: 'false' + baseBranch: + description: The branch the scan starts from and the pull request targets. + required: false + default: ${{ github.ref_name }} + repositoryRoot: + description: Path of the rulebook repository, relative to the workspace. + required: false + default: '.' + actor: + description: The git author name of the scan commit. + required: false + default: ${{ github.actor }} + packageSource: + description: A NuGet flat container (URL or folder) instead of https://api.nuget.org/v3-flatcontainer (engine tests). + required: false + default: '' + +outputs: + result: + description: "pull-request, pull-request-updated, direct-commit, no-changes, nothing-new or dry-run; empty on failure." + value: ${{ steps.scan.outputs.result }} + newIds: + description: The number of new advertised diagnostic ids. + value: ${{ steps.scan.outputs.newIds }} + quarantined: + description: The number of new ids added to a quarantine file. + value: ${{ steps.scan.outputs.quarantined }} + changedDefaults: + description: The number of analyzer defaults a stable version changed. + value: ${{ steps.scan.outputs.changedDefaults }} + released: + description: The number of quarantine entries released because a level file mentions the id. + value: ${{ steps.scan.outputs.released }} + scannedVersions: + description: The scanned versions as @:, comma separated. + value: ${{ steps.scan.outputs.scannedVersions }} + pullRequestUrl: + description: The URL of the scan pull request (new or updated). + value: ${{ steps.scan.outputs.pullRequestUrl }} + candidatePath: + description: After a dry run, the folder holding the scanned repository. + value: ${{ steps.scan.outputs.candidatePath }} + elapsedSeconds: + description: The run time of the step in seconds. + value: ${{ steps.scan.outputs.elapsedSeconds }} + failure: + description: "Why the step failed, empty on success: policy, token, nuget, extract, validation, push, pull-request or error." + value: ${{ steps.scan.outputs.failure }} + +runs: + using: composite + steps: + - name: Scan diagnostics + id: scan + shell: pwsh + # Inputs reach the script through the environment, never interpolated into the script (script injection). + env: + INPUT_TOKEN: ${{ inputs.token }} + INPUT_INCLUDEPRERELEASE: ${{ inputs.includePrerelease }} + INPUT_DIRECTCOMMIT: ${{ inputs.directCommit }} + INPUT_DRYRUN: ${{ inputs.dryRun }} + INPUT_BASEBRANCH: ${{ inputs.baseBranch }} + INPUT_REPOSITORYROOT: ${{ inputs.repositoryRoot }} + INPUT_ACTOR: ${{ inputs.actor }} + INPUT_PACKAGESOURCE: ${{ inputs.packageSource }} + GITHUB_TOKEN: ${{ github.token }} + run: | + $parameters = @{ + RepositoryRoot = $env:INPUT_REPOSITORYROOT + Token = $env:INPUT_TOKEN + IncludePrerelease = $env:INPUT_INCLUDEPRERELEASE -ne 'false' + DirectCommit = $env:INPUT_DIRECTCOMMIT -eq 'true' + DryRun = $env:INPUT_DRYRUN -eq 'true' + BaseBranch = $env:INPUT_BASEBRANCH + Actor = $env:INPUT_ACTOR + PackageSource = $env:INPUT_PACKAGESOURCE + } + # The result object is the last pipeline output; anything a module emits before it is ignored. + $result = @(& (Join-Path $env:GITHUB_ACTION_PATH 'ScanDiagnostics.ps1') @parameters)[-1] + if ($null -eq $result -or $null -eq $result.PSObject.Properties['ExitCode']) { Write-Host '::error::ScanDiagnostics.ps1 returned no result'; exit 1 } + exit $result.ExitCode diff --git a/modules/Rulebook.Catalog.psd1 b/modules/Rulebook.Catalog.psd1 new file mode 100644 index 0000000..73011b2 --- /dev/null +++ b/modules/Rulebook.Catalog.psd1 @@ -0,0 +1,35 @@ +@{ + RootModule = 'Rulebook.Catalog.psm1' + ModuleVersion = '0.1.0' + GUID = 'bff9c35b-cf48-4385-b0ae-da5704a72ea1' + Author = 'ALCops' + CompanyName = 'ALCops' + Copyright = '(c) ALCops. MIT License.' + Description = 'Rulebook catalog and scan state: catalog/diagnostics.json with every scan field, the docs URL rule, one scanned package version applied to the catalog, and catalog/scan-state.json. See docs/reference/scan-mechanics.md.' + PowerShellVersion = '7.4' + CompatiblePSEditions = @('Core') + FunctionsToExport = @( + 'ConvertFrom-CatalogFileText' + 'ConvertFrom-ScanStateText' + 'ConvertTo-CatalogJson' + 'ConvertTo-ScanStateJson' + 'Get-CatalogDocsUrl' + 'Get-NewPackageVersion' + 'Get-SortedCatalogEntry' + 'Read-CatalogFile' + 'Read-ScanState' + 'Update-CatalogFromScan' + 'Write-CatalogFile' + 'Write-ScanState' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('ALCops', 'Rulebook', 'AL', 'BusinessCentral', 'ruleset') + LicenseUri = 'https://github.com/ALCops/rulebook-engine/blob/main/LICENSE' + ProjectUri = 'https://github.com/ALCops/rulebook-engine' + } + } +} diff --git a/modules/Rulebook.Catalog.psm1 b/modules/Rulebook.Catalog.psm1 new file mode 100644 index 0000000..1934f6e --- /dev/null +++ b/modules/Rulebook.Catalog.psm1 @@ -0,0 +1,557 @@ +#requires -Version 7.4 +# Rulebook.Catalog: catalog/diagnostics.json and catalog/scan-state.json of an organization rulebook repository +# (WP08, R9, D24, D46). Reads and writes the catalog with every scan field and unknown keys preserved, normalises +# docs URLs, applies one scanned package version to the catalog (Update-CatalogFromScan, pure) and keeps the scan +# state (which version of each package and channel was scanned last). ConvertTo-CatalogJson is the one catalog +# writer; Rulebook.Template uses it for the seed. Contract: docs/reference/scan-mechanics.md section 3. + +Set-StrictMode -Version 3.0 +Import-Module (Join-Path $PSScriptRoot 'Rulebook.Generate.psd1') +Import-Module (Join-Path $PSScriptRoot 'Rulebook.NuGet.psd1') + +$script:CatalogSchemaUrl = 'https://raw.githubusercontent.com/ALCops/rulebook-engine/v1/schemas/rulebook-catalog.schema.json' +$script:ScanStateSchemaUrl = 'https://raw.githubusercontent.com/ALCops/rulebook-engine/v1/schemas/rulebook-scan-state.schema.json' +$script:ToolsPackageId = 'microsoft.dynamics.businesscentral.development.tools' +$script:AlcopsPackageId = 'alcops.analyzers' +$script:PackageOrder = @($script:ToolsPackageId, $script:AlcopsPackageId) +$script:KnownKeys = @('id', 'analyzer', 'defaultSeverity', 'enabledByDefault', 'title', 'docs', 'package', 'firstSeenVersion', 'firstSeenChannel', 'firstStableVersion', 'lastSeenVersion', 'advertised', 'deprecated', 'defaultChanges') +$script:Utf8NoBom = [System.Text.UTF8Encoding]::new($false) +$script:AlDocsUrl = 'https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/diagnostics/diagnostic-al{0}' + +#region Internal helpers + +function Get-EntryValue { + # A property of an entry object, $null when it has none (the seed entries of Rulebook.Template carry six). + param($Entry, [Parameter(Mandatory)][string]$Name) + if ($null -eq $Entry) { return $null } + $property = $Entry.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return $property.Value +} + +function ConvertTo-TextOrNull { + # A JSON value as text: ConvertFrom-Json -AsHashtable turns an ISO date-time string into a [DateTime] (pwsh 7.4 has + # no -DateKind), which is written back as UTC with seconds. + param($Value) + if ($null -eq $Value) { return $null } + if ($Value -is [datetime]) { return $Value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", [System.Globalization.CultureInfo]::InvariantCulture) } + if ($Value -is [System.DateTimeOffset]) { return $Value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", [System.Globalization.CultureInfo]::InvariantCulture) } + return [string]$Value +} + +function ConvertTo-JsonValue { + # A JSON literal of a scalar or a structure; strings through ConvertTo-JsonString like every engine writer. + param($Value) + if ($null -eq $Value) { return 'null' } + if ($Value -is [string]) { return ConvertTo-JsonString -Value $Value } + if ($Value -is [bool]) { return $Value.ToString().ToLowerInvariant() } + if ($Value -is [datetime] -or $Value -is [System.DateTimeOffset]) { return ConvertTo-JsonString -Value (ConvertTo-TextOrNull $Value) } + return ConvertTo-Json -InputObject $Value -Compress -Depth 10 +} + +function New-CatalogEntry { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an object; changes no state')] + param([Parameter(Mandatory)][string]$Id) + return [pscustomobject]@{ + PSTypeName = 'Rulebook.CatalogEntry' + Id = $Id + Analyzer = $null + DefaultSeverity = $null + EnabledByDefault = $true + Title = $null + Docs = $null + Package = $null + FirstSeenVersion = $null + FirstSeenChannel = $null + FirstStableVersion = $null + LastSeenVersion = $null + Advertised = $true + Deprecated = $false + DefaultChanges = @() + Extra = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + } +} + +function Copy-CatalogEntry { + param([Parameter(Mandatory)]$Entry) + $copy = New-CatalogEntry -Id $Entry.Id + foreach ($name in 'Analyzer', 'DefaultSeverity', 'EnabledByDefault', 'Title', 'Docs', 'Package', 'FirstSeenVersion', 'FirstSeenChannel', 'FirstStableVersion', 'LastSeenVersion', 'Advertised', 'Deprecated') { + $copy.$name = $Entry.$name + } + $copy.DefaultChanges = @($Entry.DefaultChanges | ForEach-Object { [ordered]@{ version = $_['version']; field = $_['field']; from = $_['from']; to = $_['to'] } }) + foreach ($key in $Entry.Extra.Keys) { $copy.Extra[$key] = $Entry.Extra[$key] } + return $copy +} + +function Get-OrdinalMap { + return , [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) +} + +function Get-FileChange { + # created or modified when Bytes differ from the file at Path, else $null. + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][byte[]]$Bytes) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return 'created' } + if ([System.Linq.Enumerable]::SequenceEqual([byte[]][System.IO.File]::ReadAllBytes($Path), $Bytes)) { return $null } + return 'modified' +} + +function Write-FileByte { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][byte[]]$Bytes) + $parent = Split-Path -Parent $Path + if ($parent -and -not (Test-Path -LiteralPath $parent -PathType Container)) { [void][System.IO.Directory]::CreateDirectory($parent) } + [System.IO.File]::WriteAllBytes($Path, $Bytes) +} + +function Get-PackageRank { + param([string]$PackageId) + $index = [System.Array]::IndexOf($script:PackageOrder, $PackageId) + if ($index -lt 0) { return $script:PackageOrder.Count } + return $index +} + +#endregion + +#region Catalog + +function ConvertFrom-CatalogFileText { + <# + .SYNOPSIS + A catalog text as a Rulebook.Catalog { Schema, Version, Entries (ordinal ordered map id -> entry), Path }. + .DESCRIPTION + Entry { Id, Analyzer, DefaultSeverity, EnabledByDefault, Title, Docs, Package, FirstSeenVersion, + FirstSeenChannel, FirstStableVersion, LastSeenVersion, Advertised (default $true), Deprecated (default $false), + DefaultChanges, Extra (unknown keys in file order) }. An entry without an id or an id listed twice throws. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][AllowEmptyString()][string]$Text, [string]$Path = 'catalog/diagnostics.json') + try { + $json = ConvertFrom-Json -InputObject $Text -AsHashtable -Depth 20 -ErrorAction Stop + } catch { + throw "Invalid JSON in $Path`: $($_.Exception.Message)" + } + if ($json -isnot [System.Collections.IDictionary] -or $json['diagnostics'] -isnot [System.Collections.IList]) { throw "$Path has no diagnostics array" } + $entries = Get-OrdinalMap + foreach ($item in $json['diagnostics']) { + if ($item -isnot [System.Collections.IDictionary]) { throw "$Path has an entry that is not an object" } + $id = [string]$item['id'] + if ([string]::IsNullOrEmpty($id)) { throw "$Path has an entry without an id" } + if ($entries.Contains($id)) { throw "$Path lists $id twice" } + $entry = New-CatalogEntry -Id $id + $entry.Analyzer = ConvertTo-TextOrNull $item['analyzer'] + $entry.DefaultSeverity = ConvertTo-TextOrNull $item['defaultSeverity'] + $entry.EnabledByDefault = if ($item.Contains('enabledByDefault')) { [bool]$item['enabledByDefault'] } else { $true } + $entry.Title = ConvertTo-TextOrNull $item['title'] + $entry.Docs = ConvertTo-TextOrNull $item['docs'] + $entry.Package = ConvertTo-TextOrNull $item['package'] + $entry.FirstSeenVersion = ConvertTo-TextOrNull $item['firstSeenVersion'] + $entry.FirstSeenChannel = ConvertTo-TextOrNull $item['firstSeenChannel'] + $entry.FirstStableVersion = ConvertTo-TextOrNull $item['firstStableVersion'] + $entry.LastSeenVersion = ConvertTo-TextOrNull $item['lastSeenVersion'] + $entry.Advertised = -not ($item.Contains('advertised') -and $item['advertised'] -eq $false) + $entry.Deprecated = $item.Contains('deprecated') -and $item['deprecated'] -eq $true + $entry.DefaultChanges = @(foreach ($change in @($item['defaultChanges'] | Where-Object { $_ -is [System.Collections.IDictionary] })) { + [ordered]@{ version = ConvertTo-TextOrNull $change['version']; field = ConvertTo-TextOrNull $change['field']; from = $change['from']; to = $change['to'] } + }) + foreach ($key in $item.Keys) { if ($key -cnotin $script:KnownKeys) { $entry.Extra[$key] = $item[$key] } } + $entries[$id] = $entry + } + return [pscustomobject]@{ + PSTypeName = 'Rulebook.Catalog' + Schema = ConvertTo-TextOrNull $json['$schema'] + Version = $json['version'] + Entries = $entries + Path = $Path + } +} + +function Read-CatalogFile { + <# + .SYNOPSIS + Reads catalog/diagnostics.json into a Rulebook.Catalog (ConvertFrom-CatalogFileText); throws when it is missing. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][string]$Path) + $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { throw "Catalog missing: $Path" } + $catalog = ConvertFrom-CatalogFileText -Text ([System.IO.File]::ReadAllText($full, $script:Utf8NoBom)) -Path $Path + $catalog.Path = $full + return $catalog +} + +function ConvertTo-CatalogJson { + <# + .SYNOPSIS + The text of catalog/diagnostics.json: $schema, version 1 and one entry per line in the given order. + .DESCRIPTION + Keys in the order id, analyzer, defaultSeverity, enabledByDefault, title, docs (the seed keys, title and docs only + when not empty), then the scan keys when set: package, firstSeenVersion, firstSeenChannel, firstStableVersion, + lastSeenVersion, advertised (only false), deprecated (only true), defaultChanges (only when not empty, inline), + then the unknown keys of Extra. A seed entry of Rulebook.Template (six properties) is written byte for byte as + before. LF line ends and one trailing LF. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Entries, [AllowNull()][AllowEmptyString()][string]$Schema) + if ([string]::IsNullOrEmpty($Schema)) { $Schema = $script:CatalogSchemaUrl } + $items = foreach ($entry in $Entries) { + $item = '{ "id": ' + (ConvertTo-JsonString $entry.Id) + $analyzer = Get-EntryValue $entry 'Analyzer' + if ($null -ne $analyzer) { $item += ', "analyzer": ' + (ConvertTo-JsonString $analyzer) } + $item += ', "defaultSeverity": ' + (ConvertTo-JsonString (Get-EntryValue $entry 'DefaultSeverity')) + $item += ', "enabledByDefault": ' + $(if (Get-EntryValue $entry 'EnabledByDefault') { 'true' } else { 'false' }) + foreach ($pair in @(@('title', 'Title'), @('docs', 'Docs'), @('package', 'Package'), @('firstSeenVersion', 'FirstSeenVersion'), @('firstSeenChannel', 'FirstSeenChannel'), @('firstStableVersion', 'FirstStableVersion'), @('lastSeenVersion', 'LastSeenVersion'))) { + $value = Get-EntryValue $entry $pair[1] + if (-not [string]::IsNullOrEmpty($value)) { $item += ', "' + $pair[0] + '": ' + (ConvertTo-JsonString $value) } + } + if ((Get-EntryValue $entry 'Advertised') -eq $false) { $item += ', "advertised": false' } + if ((Get-EntryValue $entry 'Deprecated') -eq $true) { $item += ', "deprecated": true' } + $changes = @(Get-EntryValue $entry 'DefaultChanges' | Where-Object { $null -ne $_ }) + if ($changes.Count -gt 0) { + $elements = foreach ($change in $changes) { + '{ "version": ' + (ConvertTo-JsonValue $change['version']) + ', "field": ' + (ConvertTo-JsonValue $change['field']) + ', "from": ' + (ConvertTo-JsonValue $change['from']) + ', "to": ' + (ConvertTo-JsonValue $change['to']) + ' }' + } + $item += ', "defaultChanges": [ ' + ($elements -join ', ') + ' ]' + } + $extra = Get-EntryValue $entry 'Extra' + if ($extra -is [System.Collections.IDictionary]) { + foreach ($key in $extra.Keys) { $item += ', ' + (ConvertTo-JsonString ([string]$key)) + ': ' + (ConvertTo-JsonValue $extra[$key]) } + } + $item + ' }' + } + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('{') + $lines.Add(' "$schema": ' + (ConvertTo-JsonString $Schema) + ',') + $lines.Add(' "version": 1,') + $items = @($items) + if ($items.Count -eq 0) { + $lines.Add(' "diagnostics": []') + } else { + $lines.Add(' "diagnostics": [') + for ($i = 0; $i -lt $items.Count; $i++) { $lines.Add(' ' + $items[$i] + $(if ($i -lt $items.Count - 1) { ',' } else { '' })) } + $lines.Add(' ]') + } + $lines.Add('}') + return ($lines -join "`n") + "`n" +} + +function Get-SortedCatalogEntry { + <# + .SYNOPSIS + The entries of a catalog sorted by Get-DiagnosticSortKey and then the id, ordinal (the order of the seed). + #> + [CmdletBinding()] + [OutputType([object[]])] + param([Parameter(Mandatory)]$Catalog) + # A Comparison sort: [System.Array]::Sort(keys, items, comparer) called from PowerShell leaves the items array + # in its order. + $keyed = [System.Collections.Generic.List[object]]::new() + foreach ($entry in $Catalog.Entries.Values) { $keyed.Add([pscustomobject]@{ Key = (Get-DiagnosticSortKey -Id $entry.Id) + '|' + $entry.Id; Entry = $entry }) } + $keyed.Sort([System.Comparison[object]] { param($left, $right) [string]::CompareOrdinal($left.Key, $right.Key) }) + return , [object[]]@($keyed | ForEach-Object Entry) +} + +function Write-CatalogFile { + <# + .SYNOPSIS + Writes -Catalog to -Path sorted (Get-SortedCatalogEntry) when the bytes differ: { File, Change } or nothing. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$Catalog, [string]$File = 'catalog/diagnostics.json') + $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + $bytes = $script:Utf8NoBom.GetBytes((ConvertTo-CatalogJson -Entries (Get-SortedCatalogEntry -Catalog $Catalog) -Schema $Catalog.Schema)) + $change = Get-FileChange -Path $Path -Bytes $bytes + if ($null -eq $change) { return } + if ($PSCmdlet.ShouldProcess($File, "Write ($change)")) { Write-FileByte -Path $Path -Bytes $bytes } + return [pscustomobject]@{ PSTypeName = 'Rulebook.FileChange'; File = $File; Change = $change } +} + +function Get-CatalogDocsUrl { + <# + .SYNOPSIS + The docs URL the catalog records for an id, $null when there is none. + .DESCRIPTION + Compiler ids (AL, or -Analyzer Compiler) get the Learn page diagnostic-al (no leading zeros). Otherwise + -HelpLinkUri without its query string (the Microsoft links carry ?wt.mc_id=...), with the path lowercased for + alcops.dev (the TestAutomationCop links of the package say testautomationCop, which is a 404). + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)][string]$Id, [AllowNull()][AllowEmptyString()][string]$HelpLinkUri, [AllowNull()][AllowEmptyString()][string]$Analyzer) + if ($Id -cmatch '^AL0*([0-9]+)$') { return $script:AlDocsUrl -f $Matches[1] } + if ($Analyzer -ceq 'Compiler' -and $Id -cmatch '^[A-Z]+0*([0-9]+)$') { return $script:AlDocsUrl -f $Matches[1] } + if ([string]::IsNullOrWhiteSpace($HelpLinkUri)) { return $null } + $link = $HelpLinkUri.Trim() + $query = $link.IndexOf([char]'?') + if ($query -ge 0) { $link = $link.Substring(0, $query) } + if ($link -match '^(https?://(www\.)?alcops\.dev)(/.*)?$') { $link = $Matches[1] + $(if ($Matches[3]) { $Matches[3].ToLowerInvariant() } else { '' }) } + if ($link -eq '') { return $null } + return $link +} + +function Update-CatalogFromScan { + <# + .SYNOPSIS + Applies the records of one scanned package version to a catalog: a Rulebook.CatalogDiff with the new catalog. + .DESCRIPTION + Pure: -Catalog is not changed. Rules (docs/reference/scan-mechanics.md section 3): + - A new id gets a full entry (analyzer, defaults, title, docs, package, firstSeenVersion and firstSeenChannel of + this version, firstStableVersion when stable, advertised false when no analyzer returns it): NewIds. + - A catalog id without package (a seeded id) gets the package fields: Recorded, never NewIds. + - Stable: firstStableVersion is set once; Promoted when the id was first seen in a prerelease and -QuarantinedIds + lists it (quarantined by an earlier scan). Defaults that differ are overwritten and one defaultChanges element + per field and version is appended: ChangedDefaults. Title and docs are refreshed: Refreshed. advertised and + deprecated follow the descriptor. Catalog ids of this package that the version does not carry: Vanished (kept). + - Prerelease: differing defaults are listed in PrereleaseDefaultChanges only; text is never changed. + - lastSeenVersion is the highest version carrying the id (any channel) and only moves forward. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Pure: returns a new catalog, writes nothing')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)]$Catalog, + [Parameter(Mandatory)][System.Collections.IDictionary]$Records, + [Parameter(Mandatory)][string]$PackageId, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][ValidateSet('stable', 'prerelease')][string]$Channel, + [AllowNull()][AllowEmptyCollection()][string[]]$QuarantinedIds + ) + $stable = $Channel -eq 'stable' + $quarantined = [System.Collections.Generic.HashSet[string]]::new([string[]]@($QuarantinedIds | Where-Object { $_ }), [System.StringComparer]::Ordinal) + $entries = Get-OrdinalMap + foreach ($key in $Catalog.Entries.Keys) { $entries[$key] = Copy-CatalogEntry -Entry $Catalog.Entries[$key] } + $lists = @{} + foreach ($name in 'NewIds', 'Promoted', 'ChangedDefaults', 'PrereleaseDefaultChanges', 'Refreshed', 'Vanished', 'Unadvertised', 'Deprecated', 'Recorded') { $lists[$name] = [System.Collections.Generic.List[object]]::new() } + + foreach ($id in $Records.Keys) { + $record = $Records[$id] + if (-not $record.Advertised) { $lists.Unadvertised.Add($id) } + if ($record.Deprecated) { $lists.Deprecated.Add($id) } + if (-not $entries.Contains($id)) { + $entry = New-CatalogEntry -Id $id + $entry.Analyzer = $record.Analyzer + $entry.DefaultSeverity = $record.DefaultSeverity + $entry.EnabledByDefault = $record.EnabledByDefault + $entry.Title = $record.Title + $entry.Docs = $record.Docs + $entry.Package = $PackageId + $entry.FirstSeenVersion = $Version + $entry.FirstSeenChannel = $Channel + $entry.FirstStableVersion = if ($stable) { $Version } else { $null } + $entry.LastSeenVersion = $Version + $entry.Advertised = [bool]$record.Advertised + $entry.Deprecated = [bool]$record.Deprecated + $entries[$id] = $entry + $lists.NewIds.Add($id) + continue + } + $entry = $entries[$id] + if ([string]::IsNullOrEmpty($entry.Package)) { + $entry.Package = $PackageId + $entry.FirstSeenVersion = $Version + $entry.FirstSeenChannel = $Channel + $lists.Recorded.Add($id) + } + if ([string]::IsNullOrEmpty($entry.LastSeenVersion) -or (Compare-NuGetVersion -Reference $Version -Difference $entry.LastSeenVersion) -gt 0) { $entry.LastSeenVersion = $Version } + $fields = @( + @{ Field = 'defaultSeverity'; Property = 'DefaultSeverity'; Value = $record.DefaultSeverity } + @{ Field = 'enabledByDefault'; Property = 'EnabledByDefault'; Value = [bool]$record.EnabledByDefault } + ) + foreach ($field in $fields) { + $current = $entry.($field.Property) + if ($null -eq $field.Value -or ($null -ne $current -and $current -ceq $field.Value) -or ($current -is [bool] -and $current -eq $field.Value)) { continue } + $change = [pscustomobject]@{ Id = $id; Field = $field.Field; From = $current; To = $field.Value; Version = $Version } + if (-not $stable) { + $lists.PrereleaseDefaultChanges.Add($change) + continue + } + $entry.($field.Property) = $field.Value + $known = @($entry.DefaultChanges | Where-Object { $_['version'] -ceq $Version -and $_['field'] -ceq $field.Field }) + if ($known.Count -eq 0) { $entry.DefaultChanges = @($entry.DefaultChanges) + @([ordered]@{ version = $Version; field = $field.Field; from = $current; to = $field.Value }) } + $lists.ChangedDefaults.Add($change) + } + if (-not $stable) { continue } + if ([string]::IsNullOrEmpty($entry.FirstStableVersion)) { + $entry.FirstStableVersion = $Version + if ($entry.FirstSeenChannel -ceq 'prerelease' -and $quarantined.Contains($id)) { $lists.Promoted.Add($id) } + } + foreach ($text in @(@{ Field = 'title'; Property = 'Title'; Value = $record.Title }, @{ Field = 'docs'; Property = 'Docs'; Value = $record.Docs })) { + if ([string]::IsNullOrEmpty($text.Value) -or $entry.($text.Property) -ceq $text.Value) { continue } + $lists.Refreshed.Add([pscustomobject]@{ Id = $id; Field = $text.Field; From = $entry.($text.Property); To = $text.Value }) + $entry.($text.Property) = $text.Value + } + $entry.Advertised = [bool]$record.Advertised + $entry.Deprecated = [bool]$record.Deprecated + } + if ($stable) { + foreach ($entry in $entries.Values) { + if ($entry.Package -ceq $PackageId -and -not $Records.Contains($entry.Id)) { $lists.Vanished.Add($entry.Id) } + } + } + $after = [pscustomobject]@{ PSTypeName = 'Rulebook.Catalog'; Schema = $Catalog.Schema; Version = $Catalog.Version; Entries = $entries; Path = $Catalog.Path } + return [pscustomobject]@{ + PSTypeName = 'Rulebook.CatalogDiff' + PackageId = $PackageId + Version = $Version + Channel = $Channel + NewIds = [string[]]@($lists.NewIds) + Promoted = [string[]]@($lists.Promoted) + ChangedDefaults = $lists.ChangedDefaults.ToArray() + PrereleaseDefaultChanges = $lists.PrereleaseDefaultChanges.ToArray() + Refreshed = $lists.Refreshed.ToArray() + Vanished = [string[]]@($lists.Vanished) + Unadvertised = [string[]]@($lists.Unadvertised) + Deprecated = [string[]]@($lists.Deprecated) + Recorded = [string[]]@($lists.Recorded) + Conflicts = @() + Catalog = $after + } +} + +#endregion + +#region Scan state + +function ConvertFrom-ScanStateText { + <# + .SYNOPSIS + A scan-state text as a Rulebook.ScanState { Schema, Version, Packages (id -> { Stable, Prerelease }, each $null or + { Version, ScannedAt }) }. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][AllowEmptyString()][string]$Text, [string]$Path = 'catalog/scan-state.json') + try { + $json = ConvertFrom-Json -InputObject $Text -AsHashtable -Depth 10 -ErrorAction Stop + } catch { + throw "Invalid JSON in $Path`: $($_.Exception.Message)" + } + if ($json -isnot [System.Collections.IDictionary]) { throw "$Path is not a JSON object" } + $packages = Get-OrdinalMap + if ($json['packages'] -is [System.Collections.IDictionary]) { + foreach ($id in $json['packages'].Keys) { + $item = $json['packages'][$id] + $channels = [ordered]@{ Stable = $null; Prerelease = $null } + foreach ($channel in 'stable', 'prerelease') { + $value = if ($item -is [System.Collections.IDictionary]) { $item[$channel] } else { $null } + if ($value -is [System.Collections.IDictionary] -and $value['version']) { + $channels[$(if ($channel -eq 'stable') { 'Stable' } else { 'Prerelease' })] = [pscustomobject]@{ Version = ConvertTo-TextOrNull $value['version']; ScannedAt = ConvertTo-TextOrNull $value['scannedAt'] } + } + } + $packages[[string]$id] = [pscustomobject]$channels + } + } + return [pscustomobject]@{ PSTypeName = 'Rulebook.ScanState'; Schema = ConvertTo-TextOrNull $json['$schema']; Version = $(if ($json.Contains('version')) { $json['version'] } else { 1 }); Packages = $packages } +} + +function Read-ScanState { + <# + .SYNOPSIS + Reads catalog/scan-state.json; a missing file is an empty state (nothing scanned yet). + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][string]$Path) + $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { + return [pscustomobject]@{ PSTypeName = 'Rulebook.ScanState'; Schema = $script:ScanStateSchemaUrl; Version = 1; Packages = (Get-OrdinalMap) } + } + return ConvertFrom-ScanStateText -Text ([System.IO.File]::ReadAllText($full, $script:Utf8NoBom)) -Path $Path +} + +function ConvertTo-ScanStateJson { + <# + .SYNOPSIS + The text of catalog/scan-state.json: the tools package first, then alcops.analyzers, then any other id ordinal. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)]$State) + $schema = if ([string]::IsNullOrEmpty($State.Schema)) { $script:ScanStateSchemaUrl } else { $State.Schema } + $ordered = [System.Collections.Generic.List[object]]::new() + foreach ($id in $State.Packages.Keys) { $ordered.Add([pscustomobject]@{ Key = ('{0:00}|{1}' -f (Get-PackageRank $id), $id); Id = [string]$id }) } + $ordered.Sort([System.Comparison[object]] { param($left, $right) [string]::CompareOrdinal($left.Key, $right.Key) }) + [string[]]$ids = @($ordered | ForEach-Object Id) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('{') + $lines.Add(' "$schema": ' + (ConvertTo-JsonString $schema) + ',') + $lines.Add(' "version": 1,') + if ($ids.Count -eq 0) { + $lines.Add(' "packages": {}') + } else { + $lines.Add(' "packages": {') + for ($i = 0; $i -lt $ids.Count; $i++) { + $package = $State.Packages[$ids[$i]] + $lines.Add(' ' + (ConvertTo-JsonString $ids[$i]) + ': {') + $channel = { param($Value) if ($null -eq $Value) { 'null' } else { '{ "version": ' + (ConvertTo-JsonString $Value.Version) + ', "scannedAt": ' + (ConvertTo-JsonString $Value.ScannedAt) + ' }' } } + $lines.Add(' "stable": ' + (& $channel $package.Stable) + ',') + $lines.Add(' "prerelease": ' + (& $channel $package.Prerelease)) + $lines.Add(' }' + $(if ($i -lt $ids.Count - 1) { ',' } else { '' })) + } + $lines.Add(' }') + } + $lines.Add('}') + return ($lines -join "`n") + "`n" +} + +function Write-ScanState { + <# + .SYNOPSIS + Writes the scan state when the bytes differ: { File, Change } or nothing. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$State, [string]$File = 'catalog/scan-state.json') + $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + $bytes = $script:Utf8NoBom.GetBytes((ConvertTo-ScanStateJson -State $State)) + $change = Get-FileChange -Path $Path -Bytes $bytes + if ($null -eq $change) { return } + if ($PSCmdlet.ShouldProcess($File, "Write ($change)")) { Write-FileByte -Path $Path -Bytes $bytes } + return [pscustomobject]@{ PSTypeName = 'Rulebook.FileChange'; File = $File; Change = $change } +} + +function Get-NewPackageVersion { + <# + .SYNOPSIS + The package versions the scan has not recorded yet: { PackageId, Channel, Version }[]. + .DESCRIPTION + -Channels holds { PackageId, Stable, Prerelease } (Select-NuGetChannelVersion per package). A channel whose + version differs from the state is new; a $null version (no prerelease, or prerelease not included) is skipped. + Ordered tools before alcops.analyzers, stable before prerelease. + #> + [CmdletBinding()] + [OutputType([object[]])] + param([Parameter(Mandatory)]$State, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Channels) + $sorted = @($Channels | Sort-Object { Get-PackageRank $_.PackageId }, { $_.PackageId }) + $result = foreach ($item in $sorted) { + $recorded = if ($State.Packages.Contains($item.PackageId)) { $State.Packages[$item.PackageId] } else { $null } + foreach ($channel in 'stable', 'prerelease') { + $version = if ($channel -eq 'stable') { $item.Stable } else { $item.Prerelease } + if ([string]::IsNullOrEmpty($version)) { continue } + $last = if ($null -eq $recorded) { $null } elseif ($channel -eq 'stable') { $recorded.Stable } else { $recorded.Prerelease } + if ($null -ne $last -and $last.Version -ceq $version) { continue } + [pscustomobject]@{ PackageId = $item.PackageId; Channel = $channel; Version = $version } + } + } + # Unrolled on purpose: callers collect it with @(). + return @($result) +} + +#endregion + +Export-ModuleMember -Function @( + 'ConvertFrom-CatalogFileText' + 'ConvertFrom-ScanStateText' + 'ConvertTo-CatalogJson' + 'ConvertTo-ScanStateJson' + 'Get-CatalogDocsUrl' + 'Get-NewPackageVersion' + 'Get-SortedCatalogEntry' + 'Read-CatalogFile' + 'Read-ScanState' + 'Update-CatalogFromScan' + 'Write-CatalogFile' + 'Write-ScanState' +) diff --git a/modules/Rulebook.Extract.psd1 b/modules/Rulebook.Extract.psd1 new file mode 100644 index 0000000..233dae2 --- /dev/null +++ b/modules/Rulebook.Extract.psd1 @@ -0,0 +1,28 @@ +@{ + RootModule = 'Rulebook.Extract.psm1' + ModuleVersion = '0.1.0' + GUID = '15da7f9e-bea7-4bf9-94e4-9ac09c04ce56' + Author = 'ALCops' + CompanyName = 'ALCops' + Copyright = '(c) ALCops. MIT License.' + Description = 'Rulebook descriptor extraction for the diagnostic scan: the analyzer folder of a package, every diagnostic descriptor by reflection in a child pwsh per package version, and one record per id. See docs/reference/scan-mechanics.md.' + PowerShellVersion = '7.4' + CompatiblePSEditions = @('Core') + FunctionsToExport = @( + 'ConvertTo-DiagnosticRecord' + 'Get-AnalyzerDescriptor' + 'Get-ExpectedAssembly' + 'Invoke-DescriptorExtraction' + 'Resolve-AnalyzerFolder' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('ALCops', 'Rulebook', 'AL', 'BusinessCentral', 'ruleset') + LicenseUri = 'https://github.com/ALCops/rulebook-engine/blob/main/LICENSE' + ProjectUri = 'https://github.com/ALCops/rulebook-engine' + } + } +} diff --git a/modules/Rulebook.Extract.psm1 b/modules/Rulebook.Extract.psm1 new file mode 100644 index 0000000..0d71213 --- /dev/null +++ b/modules/Rulebook.Extract.psm1 @@ -0,0 +1,445 @@ +#requires -Version 7.4 +# Rulebook.Extract: the diagnostic descriptors of the analyzer packages by reflection in pwsh (WP08, R9; spike (b), +# docs/reference/spikes/b-analyzer-dll-extraction.md). Get-AnalyzerDescriptor loads the compiler and the cop DLLs of +# one package version and reads every advertised descriptor, the compiler's configurable ids and the descriptors +# defined as static fields only; it runs in a child pwsh started by Invoke-DescriptorExtraction, one process per +# package version and channel, so two versions of Microsoft.Dynamics.Nav.CodeAnalysis never meet in one process. +# ConvertTo-DiagnosticRecord turns a result into one record per id of one package. Imports Rulebook.Catalog for the +# docs URL rule only. Contract: docs/reference/scan-mechanics.md section 2. + +Set-StrictMode -Version 3.0 +Import-Module (Join-Path $PSScriptRoot 'Rulebook.Catalog.psd1') + +$script:ToolsPackageId = 'microsoft.dynamics.businesscentral.development.tools' +$script:AlcopsPackageId = 'alcops.analyzers' +# The minimum expected set: a package version without one of these fails the extraction. Further cop DLLs are +# scanned by the file pattern. +$script:ToolsAssemblies = @( + 'Microsoft.Dynamics.Nav.CodeAnalysis' + 'Microsoft.Dynamics.Nav.CodeCop' + 'Microsoft.Dynamics.Nav.UICop' + 'Microsoft.Dynamics.Nav.AppSourceCop' + 'Microsoft.Dynamics.Nav.PerTenantExtensionCop' +) +$script:AlcopsAssemblies = @( + 'ALCops.ApplicationCop' + 'ALCops.Common' + 'ALCops.DocumentationCop' + 'ALCops.FormattingCop' + 'ALCops.LinterCop' + 'ALCops.PlatformCop' + 'ALCops.TestAutomationCop' +) +$script:KnownPrefixes = @('AL', 'AA', 'AW', 'PTE', 'AS', 'PC', 'AC', 'LC', 'DC', 'FC', 'TA', 'CM') +$script:CompilerAssembly = 'Microsoft.Dynamics.Nav.CodeAnalysis' +$script:Utf8NoBom = [System.Text.UTF8Encoding]::new($false) +# The folders the AssemblyResolve handler probes, in order (ALCops first, then the tools folder). +$script:ResolveDirs = @() + +#region Internal helpers + +function Get-PropertyValue { + # A property of a .NET object, $null when the type has none (StrictMode safe: IsDeprecated is not on every build). + param($Object, [Parameter(Mandatory)][string]$Name) + if ($null -eq $Object) { return $null } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return $property.Value +} + +function New-ExtractException { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an exception; changes no state')] + param([Parameter(Mandatory)][string]$Message, [System.Exception]$Inner) + $exception = if ($Inner) { [System.InvalidOperationException]::new($Message, $Inner) } else { [System.InvalidOperationException]::new($Message) } + $exception.Data['Stage'] = 'extract' + return $exception +} + +function Get-DefaultPwshPath { + # The pwsh of this session from $PSHOME. Under a .NET global tool install the process path is dotnet, so the + # process path is never used; Get-Command pwsh is the fallback. + foreach ($name in 'pwsh.exe', 'pwsh') { + $candidate = Join-Path $PSHOME $name + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + } + $command = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $command) { throw (New-ExtractException -Message "No pwsh found in $PSHOME or on the PATH; pass -PwshPath") } + return $command.Source +} + +function ConvertTo-DescriptorRow { + param($Descriptor, [Parameter(Mandatory)][string]$Assembly, [Parameter(Mandatory)][string]$Source, [bool]$Advertised) + $link = Get-PropertyValue $Descriptor 'HelpLinkUri' + return [ordered]@{ + id = [string]$Descriptor.Id + assembly = $Assembly + analyzerType = $Source + defaultSeverity = [string]$Descriptor.DefaultSeverity + enabledByDefault = [bool]$Descriptor.IsEnabledByDefault + # Title is a LocalizableString in the real compiler, a string in the stubs; [string] reads both. + title = [string](Get-PropertyValue $Descriptor 'Title') + helpLinkUri = $(if ([string]::IsNullOrEmpty([string]$link)) { $null } else { [string]$link }) + isDeprecated = [bool](Get-PropertyValue $Descriptor 'IsDeprecated') + advertised = $Advertised + } +} + +function Get-AnalyzerName { + # Compiler for the compiler assembly, else the last part of Microsoft.Dynamics.Nav. or ALCops.. + param([Parameter(Mandatory)][string]$Assembly) + if ($Assembly -ceq $script:CompilerAssembly) { return 'Compiler' } + if ($Assembly -cmatch '^Microsoft\.Dynamics\.Nav\.(.+)$') { return $Matches[1] } + if ($Assembly -cmatch '^ALCops\.(.+)$') { return $Matches[1] } + return $Assembly +} + +function Test-PackageAssembly { + param([Parameter(Mandatory)][string]$Assembly, [Parameter(Mandatory)][string]$PackageId) + if ($PackageId -ceq $script:AlcopsPackageId) { return $Assembly -clike 'ALCops.*' } + if ($PackageId -ceq $script:ToolsPackageId) { return $Assembly -clike 'Microsoft.Dynamics.Nav.*' } + return $false +} + +#endregion + +function Resolve-AnalyzerFolder { + <# + .SYNOPSIS + The analyzer folder of an extracted package for this runtime: tools//any (tools) or lib/ (alcops). + .DESCRIPTION + Among the subfolders named net.0 the one with the highest N that is not newer than -RuntimeMajor (default + the major version of the running .NET); netstandard* is never chosen. Throws, naming the folders found, when + none qualifies. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)][string]$PackageRoot, + [Parameter(Mandatory)][ValidateSet('tools', 'alcops')][string]$Kind, + [int]$RuntimeMajor = [System.Environment]::Version.Major + ) + $base = Join-Path $PackageRoot $(if ($Kind -eq 'tools') { 'tools' } else { 'lib' }) + $names = @(if (Test-Path -LiteralPath $base -PathType Container) { Get-ChildItem -LiteralPath $base -Directory | ForEach-Object Name | Sort-Object }) + $best = $null + $bestMajor = -1 + foreach ($name in $names) { + if ($name -cnotmatch '^net(\d+)\.0$') { continue } + $major = [int]$Matches[1] + if ($major -le $RuntimeMajor -and $major -gt $bestMajor) { + $best = $name + $bestMajor = $major + } + } + $layout = if ($Kind -eq 'tools') { 'tools//any' } else { 'lib/' } + if ($null -eq $best) { + $found = if ($names.Count -gt 0) { $names -join ', ' } else { 'none' } + throw (New-ExtractException -Message "No $layout folder for .NET $RuntimeMajor in $PackageRoot (found: $found)") + } + $folder = if ($Kind -eq 'tools') { Join-Path $base $best 'any' } else { Join-Path $base $best } + if (-not (Test-Path -LiteralPath $folder -PathType Container)) { throw (New-ExtractException -Message "No $layout folder for .NET $RuntimeMajor in $PackageRoot (found: $($names -join ', '))") } + return $folder +} + +function Get-AnalyzerDescriptor { + <# + .SYNOPSIS + Every diagnostic descriptor of one package version by reflection; run it in a child process only. + .DESCRIPTION + Loads Microsoft.Dynamics.Nav.CodeAnalysis.dll from -ToolsDir, then every Microsoft.Dynamics.Nav.*Cop.dll there and + every ALCops.*.dll in -AlcopsDir, with an AssemblyResolve handler that probes -AlcopsDir and then -ToolsDir. + Compiler ids: the internal ErrorCode enum, members of 100 and up named WRN_, INF_ or HDN_, titles from the + CompilerDiagnosticsResources resource (a missing resource sets compilerTitlesMissing and leaves the titles null). + Cop ids: every non-abstract DiagnosticAnalyzer with a parameterless constructor is instantiated and its + SupportedDiagnostics read; a second pass reads the static fields and properties of type DiagnosticDescriptor + (fieldOnly: defined, advertised by no analyzer). Fails loudly: an assembly that does not load, a + ReflectionTypeLoadException, an analyzer that cannot be instantiated or a missing -ExpectedAssembly throws. + Writes the result to -OutFile (UTF-8 without BOM) and returns it: { runtime, toolsDir, alcopsDir, assemblies + [{ name, path, types, analyzers, descriptors }], rows [...], fieldOnly [...], compilerTitlesMissing, + elapsedSeconds }. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)][string]$ToolsDir, + [string]$AlcopsDir, + [string[]]$ExpectedAssembly, + [string]$OutFile + ) + $watch = [System.Diagnostics.Stopwatch]::StartNew() + $ToolsDir = (Resolve-Path -LiteralPath $ToolsDir -ErrorAction Stop).ProviderPath + if ($AlcopsDir) { $AlcopsDir = (Resolve-Path -LiteralPath $AlcopsDir -ErrorAction Stop).ProviderPath } + $script:ResolveDirs = @(@($AlcopsDir, $ToolsDir) | Where-Object { $_ }) + # The handler reads $script:ResolveDirs and makes only .NET calls. A handler built with GetNewClosure() that + # called Join-Path and Test-Path overflowed the stack of the child process (WP08 smoke test), so it stays this + # plain form. + $handler = [System.ResolveEventHandler] { + $name = ([System.Reflection.AssemblyName]$args[1].Name).Name + foreach ($dir in $script:ResolveDirs) { + $candidate = [System.IO.Path]::Combine($dir, $name + '.dll') + if ([System.IO.File]::Exists($candidate)) { return [System.Reflection.Assembly]::LoadFrom($candidate) } + } + return $null + } + [System.AppDomain]::CurrentDomain.add_AssemblyResolve($handler) + try { + $compilerPath = Join-Path $ToolsDir "$($script:CompilerAssembly).dll" + if (-not (Test-Path -LiteralPath $compilerPath -PathType Leaf)) { throw (New-ExtractException -Message "$($script:CompilerAssembly).dll is not in $ToolsDir") } + try { + $compiler = [System.Reflection.Assembly]::LoadFrom($compilerPath) + } catch { + throw (New-ExtractException -Message "Could not load $compilerPath`: $($_.Exception.GetBaseException().Message)" -Inner $_.Exception) + } + $analyzerBase = $compiler.GetType('Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics.DiagnosticAnalyzer', $true) + $descriptorType = $compiler.GetType('Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics.DiagnosticDescriptor', $true) + $rows = [System.Collections.Generic.List[object]]::new() + $assemblies = [System.Collections.Generic.List[object]]::new() + + # The compiler's configurable ids. + $errorCode = $compiler.GetType('Microsoft.Dynamics.Nav.CodeAnalysis.ErrorCode', $true) + $resources = [System.Resources.ResourceManager]::new('Microsoft.Dynamics.Nav.CodeAnalysis.CompilerDiagnosticsResources', $compiler) + $titlesMissing = $false + $compilerCount = 0 + foreach ($name in [System.Enum]::GetNames($errorCode)) { + $value = [int][System.Enum]::Parse($errorCode, $name) + if ($value -lt 100) { continue } + $severity = switch -CaseSensitive -Regex ($name) { '^WRN_' { 'Warning' } '^INF_' { 'Info' } '^HDN_' { 'Hidden' } default { $null } } + if ($null -eq $severity) { continue } + $title = $null + if (-not $titlesMissing) { + $key = if ($name -clike 'WRN_ERR_*') { $name.Substring(4) } else { $name } + try { $title = $resources.GetString($key) } catch [System.Resources.MissingManifestResourceException] { $titlesMissing = $true } + } + $rows.Add([ordered]@{ + id = 'AL{0:0000}' -f $value; assembly = $script:CompilerAssembly; analyzerType = "ErrorCode.$name"; defaultSeverity = $severity + enabledByDefault = $true; title = $title; helpLinkUri = $null; isDeprecated = $false; advertised = $true + }) + $compilerCount++ + } + $assemblies.Add([ordered]@{ name = $script:CompilerAssembly; path = $compilerPath; types = 0; analyzers = 0; descriptors = $compilerCount }) + + # The cops. + $dlls = @(Get-ChildItem -LiteralPath $ToolsDir -File -Filter 'Microsoft.Dynamics.Nav.*Cop.dll' | Sort-Object Name) + if ($AlcopsDir) { $dlls += @(Get-ChildItem -LiteralPath $AlcopsDir -File -Filter 'ALCops.*.dll' | Sort-Object Name) } + $found = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + [void]$found.Add($script:CompilerAssembly) + foreach ($dll in $dlls) { [void]$found.Add([System.IO.Path]::GetFileNameWithoutExtension($dll.Name)) } + foreach ($expected in @($ExpectedAssembly | Where-Object { $_ })) { + if (-not $found.Contains($expected)) { throw (New-ExtractException -Message "Expected assembly $expected.dll is missing (searched $($script:ResolveDirs -join ', '))") } + } + $fieldRows = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + $flags = [System.Reflection.BindingFlags]'Static, Public, NonPublic, DeclaredOnly' + foreach ($dll in $dlls) { + try { + $assembly = [System.Reflection.Assembly]::LoadFrom($dll.FullName) + } catch { + throw (New-ExtractException -Message "Could not load $($dll.FullName): $($_.Exception.GetBaseException().Message)" -Inner $_.Exception) + } + $assemblyName = $assembly.GetName().Name + try { + $types = $assembly.GetTypes() + } catch [System.Reflection.ReflectionTypeLoadException] { + $loaded = @($_.Exception.Types | Where-Object { $null -ne $_ }).Count + $first = @($_.Exception.LoaderExceptions | Where-Object { $null -ne $_ } | Select-Object -First 1) + $reason = if ($first.Count -gt 0) { $first[0].Message } else { 'unknown' } + throw (New-ExtractException -Message "$($dll.Name): $($_.Exception.Types.Count - $loaded) of $($_.Exception.Types.Count) types could not be loaded ($reason)" -Inner $_.Exception) + } + $analyzerCount = 0 + $descriptorCount = 0 + foreach ($type in $types) { + if (-not $type.IsClass -or $type.IsAbstract -or -not $analyzerBase.IsAssignableFrom($type) -or $null -eq $type.GetConstructor([type]::EmptyTypes)) { continue } + try { + $instance = [System.Activator]::CreateInstance($type) + $supported = $instance.SupportedDiagnostics + } catch { + throw (New-ExtractException -Message "Could not instantiate $($type.FullName) of $($dll.Name): $($_.Exception.GetBaseException().Message)" -Inner $_.Exception) + } + $analyzerCount++ + # foreach enumerates an ImmutableArray and an array alike. + foreach ($descriptor in $supported) { + $rows.Add((ConvertTo-DescriptorRow -Descriptor $descriptor -Assembly $assemblyName -Source $type.FullName -Advertised $true)) + $descriptorCount++ + } + } + # Second pass: descriptors defined as static members, whether or not an analyzer returns them. + foreach ($type in $types) { + if ($type.ContainsGenericParameters) { continue } + $members = @($type.GetFields($flags) | Where-Object { $_.FieldType -eq $descriptorType }) + @($type.GetProperties($flags) | Where-Object { $_.PropertyType -eq $descriptorType -and $_.GetIndexParameters().Count -eq 0 }) + foreach ($member in $members) { + try { $descriptor = $member.GetValue($null) } catch { continue } + if ($null -eq $descriptor -or $fieldRows.Contains([string]$descriptor.Id)) { continue } + $fieldRows[[string]$descriptor.Id] = ConvertTo-DescriptorRow -Descriptor $descriptor -Assembly $assemblyName -Source "$($type.FullName).$($member.Name)" -Advertised $false + } + } + $assemblies.Add([ordered]@{ name = $assemblyName; path = $dll.FullName; types = $types.Count; analyzers = $analyzerCount; descriptors = $descriptorCount }) + } + $advertised = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($row in $rows) { [void]$advertised.Add($row.id) } + $fieldOnly = @($fieldRows.Values | Where-Object { -not $advertised.Contains($_.id) }) + $result = [ordered]@{ + runtime = [System.Environment]::Version.ToString() + toolsDir = $ToolsDir + alcopsDir = $(if ($AlcopsDir) { $AlcopsDir } else { $null }) + assemblies = $assemblies.ToArray() + rows = $rows.ToArray() + fieldOnly = $fieldOnly + compilerTitlesMissing = $titlesMissing + elapsedSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 2) + } + } finally { + [System.AppDomain]::CurrentDomain.remove_AssemblyResolve($handler) + } + if ($OutFile) { + $OutFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutFile) + [System.IO.File]::WriteAllText($OutFile, (ConvertTo-Json -InputObject $result -Depth 6), $script:Utf8NoBom) + } + return $result +} + +function Invoke-DescriptorExtraction { + <# + .SYNOPSIS + Runs Get-AnalyzerDescriptor in a child pwsh and returns its result (a dictionary). + .DESCRIPTION + pwsh -NoProfile -NonInteractive -EncodedCommand imports this module and writes the result to + /descriptors-.json. -PwshPath defaults to the pwsh in $PSHOME (Get-Command pwsh as the fallback). + A non-zero exit, a timeout (-TimeoutSeconds, default 300; the child is killed) or a missing result file throws + 'Extraction failed for : ' with Data['Stage'] = 'extract'. + #> + [CmdletBinding()] + [OutputType([System.Collections.IDictionary])] + param( + [Parameter(Mandatory)][string]$ToolsDir, + [string]$AlcopsDir, + [string[]]$ExpectedAssembly, + [Parameter(Mandatory)][string]$WorkPath, + [string]$PwshPath, + [int]$TimeoutSeconds = 300 + ) + if ([string]::IsNullOrEmpty($PwshPath)) { $PwshPath = Get-DefaultPwshPath } + $WorkPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($WorkPath) + [void][System.IO.Directory]::CreateDirectory($WorkPath) + $outFile = Join-Path $WorkPath ('descriptors-' + [guid]::NewGuid().ToString('n') + '.json') + $quote = { param([string]$Value) "'" + $Value.Replace("'", "''") + "'" } + $command = [System.Text.StringBuilder]::new() + [void]$command.Append("`$ErrorActionPreference = 'Stop'; `$PSStyle.OutputRendering = 'PlainText'; ") + [void]$command.Append("Import-Module $(& $quote (Join-Path $PSScriptRoot 'Rulebook.Extract.psd1')); ") + [void]$command.Append("`$null = Get-AnalyzerDescriptor -ToolsDir $(& $quote $ToolsDir) -OutFile $(& $quote $outFile)") + if ($AlcopsDir) { [void]$command.Append(" -AlcopsDir $(& $quote $AlcopsDir)") } + $expected = @($ExpectedAssembly | Where-Object { $_ }) + if ($expected.Count -gt 0) { [void]$command.Append(' -ExpectedAssembly @(' + (@($expected | ForEach-Object { & $quote $_ }) -join ', ') + ')') } + $encoded = [System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command.ToString())) + + $info = [System.Diagnostics.ProcessStartInfo]::new($PwshPath) + foreach ($argument in '-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', $encoded) { $info.ArgumentList.Add($argument) } + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.UseShellExecute = $false + $info.StandardOutputEncoding = $script:Utf8NoBom + $info.StandardErrorEncoding = $script:Utf8NoBom + $process = [System.Diagnostics.Process]::Start($info) + $outputTask = $process.StandardOutput.ReadToEndAsync() + $errorTask = $process.StandardError.ReadToEndAsync() + $tail = { + $lines = @(($outputTask.Result + "`n" + $errorTask.Result).Split("`n") | ForEach-Object { $_.TrimEnd() } | Where-Object { $_ }) + return (@($lines | Select-Object -Last 20) -join "`n") + } + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + try { $process.Kill($true) } catch { Write-Verbose "The extraction process could not be killed: $($_.Exception.Message)" } + $process.WaitForExit() + throw (New-ExtractException -Message "Extraction failed for $ToolsDir`: no result after $TimeoutSeconds s (the process was stopped)") + } + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw (New-ExtractException -Message "Extraction failed for $ToolsDir`: $(& $tail)") } + if (-not (Test-Path -LiteralPath $outFile -PathType Leaf)) { throw (New-ExtractException -Message "Extraction failed for $ToolsDir`: no result file. $(& $tail)") } + try { + return ConvertFrom-Json -InputObject ([System.IO.File]::ReadAllText($outFile, $script:Utf8NoBom)) -AsHashtable -Depth 20 -ErrorAction Stop + } catch { + throw (New-ExtractException -Message "Extraction failed for $ToolsDir`: the result is not JSON ($($_.Exception.Message))") + } finally { + Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue + } +} + +function ConvertTo-DiagnosticRecord { + <# + .SYNOPSIS + One record per diagnostic id of -PackageId from an extraction result: { PackageId, Records, Conflicts, + UnknownPrefixes, CompilerTitlesMissing }. + .DESCRIPTION + Keeps the rows of the package's assemblies (ALCops.* for alcops.analyzers, Microsoft.Dynamics.Nav.* for the + Development.Tools package). Records is an ordinal ordered map id -> { Id, Analyzer (Compiler, or of + Microsoft.Dynamics.Nav. and ALCops.), DefaultSeverity, EnabledByDefault, Title (white space folded to one + space, $null when empty), HelpLinkUri, Docs (Get-CatalogDocsUrl), Advertised ($false for a descriptor no analyzer + returns), Deprecated, DescriptorCount (analyzer types returning it), Assembly }. Duplicates are merged; when + they disagree on severity or enablement the id is added to Conflicts { Id, Variants } and the first row wins. + UnknownPrefixes lists the ids whose prefix the engine does not know; they are kept, never dropped. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][System.Collections.IDictionary]$Result, [Parameter(Mandatory)][string]$PackageId) + $groups = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + foreach ($row in @(@($Result['rows']) + @($Result['fieldOnly']))) { + if ($row -isnot [System.Collections.IDictionary] -or -not (Test-PackageAssembly -Assembly ([string]$row['assembly']) -PackageId $PackageId)) { continue } + $id = [string]$row['id'] + if (-not $groups.Contains($id)) { $groups[$id] = [System.Collections.Generic.List[object]]::new() } + $groups[$id].Add($row) + } + $records = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + $conflicts = [System.Collections.Generic.List[object]]::new() + $unknown = [System.Collections.Generic.List[string]]::new() + foreach ($id in $groups.Keys) { + $all = @($groups[$id]) + $advertisedRows = @($all | Where-Object { [bool]$_['advertised'] }) + $used = @(if ($advertisedRows.Count -gt 0) { $advertisedRows } else { $all }) + $first = $used[0] + $variants = @($used | ForEach-Object { '{0}/{1}' -f $_['defaultSeverity'], ([bool]$_['enabledByDefault']).ToString().ToLowerInvariant() } | Sort-Object -Unique) + if ($variants.Count -gt 1) { $conflicts.Add([pscustomobject]@{ Id = $id; Variants = $variants }) } + $title = ([regex]::Replace([string]$first['title'], '\s+', ' ')).Trim() + $analyzer = Get-AnalyzerName -Assembly ([string]$first['assembly']) + $link = if ([string]::IsNullOrEmpty([string]$first['helpLinkUri'])) { $null } else { [string]$first['helpLinkUri'] } + $records[$id] = [pscustomobject]@{ + PSTypeName = 'Rulebook.DiagnosticRecord' + Id = $id + Analyzer = $analyzer + DefaultSeverity = [string]$first['defaultSeverity'] + EnabledByDefault = [bool]$first['enabledByDefault'] + Title = $(if ($title) { $title } else { $null }) + HelpLinkUri = $link + Docs = Get-CatalogDocsUrl -Id $id -HelpLinkUri $link -Analyzer $analyzer + Advertised = $advertisedRows.Count -gt 0 + Deprecated = @($used | Where-Object { [bool]$_['isDeprecated'] }).Count -gt 0 + DescriptorCount = $advertisedRows.Count + Assembly = [string]$first['assembly'] + } + $prefix = if ($id -cmatch '^([A-Z]+)[0-9]') { $Matches[1] } else { '' } + if ($prefix -cnotin $script:KnownPrefixes) { $unknown.Add($id) } + } + return [pscustomobject]@{ + PSTypeName = 'Rulebook.DiagnosticRecords' + PackageId = $PackageId + Records = $records + Conflicts = $conflicts.ToArray() + UnknownPrefixes = $unknown.ToArray() + CompilerTitlesMissing = [bool]$Result['compilerTitlesMissing'] + } +} + +function Get-ExpectedAssembly { + <# + .SYNOPSIS + The minimum set of assembly names an extraction of -PackageId must find (alcops includes its tools host). + #> + [CmdletBinding()] + [OutputType([string[]])] + param([Parameter(Mandatory)][string]$PackageId) + if ($PackageId -ceq $script:AlcopsPackageId) { return [string[]]($script:ToolsAssemblies + $script:AlcopsAssemblies) } + return [string[]]$script:ToolsAssemblies +} + +Export-ModuleMember -Function @( + 'ConvertTo-DiagnosticRecord' + 'Get-AnalyzerDescriptor' + 'Get-ExpectedAssembly' + 'Invoke-DescriptorExtraction' + 'Resolve-AnalyzerFolder' +) diff --git a/modules/Rulebook.GitHub.psd1 b/modules/Rulebook.GitHub.psd1 index 11654b9..33d1be3 100644 --- a/modules/Rulebook.GitHub.psd1 +++ b/modules/Rulebook.GitHub.psd1 @@ -5,11 +5,12 @@ Author = 'ALCops' CompanyName = 'ALCops' Copyright = '(c) ALCops. MIT License.' - Description = 'Rulebook GitHub plumbing for the update workflow: one REST wrapper, the GHTOKENWORKFLOW exchange (personal access token or GitHub App JSON), the template zipball, pull requests, and the clone, commit and push with the token in the git environment only. See docs/reference/update-mechanics.md.' + Description = 'Rulebook GitHub plumbing for the update workflow: one REST wrapper, the GHTOKENWORKFLOW exchange (personal access token or GitHub App JSON), the template zipball, pull requests (the living pull request of the scan included), and the clone, commit and push (a lease push for the scan branch) with the token in the git environment only. See docs/reference/update-mechanics.md.' PowerShellVersion = '7.4' CompatiblePSEditions = @('Core') FunctionsToExport = @( 'Find-GitHubPullRequest' + 'Find-GitHubPullRequestByHead' 'Get-GitHubAccessToken' 'Get-GitHubBranchSha' 'Invoke-GitHubApi' @@ -18,6 +19,7 @@ 'New-GitHubPullRequest' 'Publish-GitHubChange' 'Save-GitHubZipball' + 'Update-GitHubPullRequest' ) CmdletsToExport = @() VariablesToExport = @() diff --git a/modules/Rulebook.GitHub.psm1 b/modules/Rulebook.GitHub.psm1 index a651580..ff917f5 100644 --- a/modules/Rulebook.GitHub.psm1 +++ b/modules/Rulebook.GitHub.psm1 @@ -2,7 +2,8 @@ # Rulebook.GitHub: the GitHub plumbing of the update workflow (WP07). One REST wrapper (Invoke-GitHubApi, the single # mock point of the suites), the GHTOKENWORKFLOW exchange (a personal access token passes through, GitHub App JSON # becomes a short-lived installation token, D44), the template download as a zipball, the pull request helpers, -# and the clone, commit and push of the update. No engine imports. The token never enters a git URL or git config: +# and the clone, commit and push of the update and of the scan (WP08: the living pull request, a lease push). No +# engine imports. The token never enters a git URL or git config: # git receives it as an http./.extraheader through GIT_CONFIG_COUNT in the environment of each git call. # Contract: docs/reference/update-mechanics.md section 6 and 8. Ported from AL-Go (Github-Helper.psm1, # AL-Go-Helper.ps1), behaviour only: docs/reference/al-go-template-mechanics.md sections 5.5 and 8. @@ -177,7 +178,7 @@ function Invoke-GitHubApi { [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([pscustomobject])] param( - [ValidateSet('GET', 'POST', 'HEAD')][string]$Method = 'GET', + [ValidateSet('GET', 'POST', 'PATCH', 'HEAD')][string]$Method = 'GET', [Parameter(Mandatory, ParameterSetName = 'Path')][string]$Path, [Parameter(Mandatory, ParameterSetName = 'Uri')][string]$Uri, [AllowNull()][AllowEmptyString()][string]$Token, @@ -423,6 +424,60 @@ function Find-GitHubPullRequest { return $null } +function Find-GitHubPullRequestByHead { + <# + .SYNOPSIS + The open pull request from branch -Head of -Repository into -Base, or $null: { Number, Url, Title }. + .DESCRIPTION + GET /repos/{r}/pulls?state=open&head=:&base=; the first item wins. Used by the scan, whose + living pull request is found by its fixed branch, not by its title. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)][string]$Repository, + [Parameter(Mandatory)][string]$Head, + [Parameter(Mandatory)][string]$Base, + [AllowNull()][AllowEmptyString()][string]$Token, + [AllowNull()][AllowEmptyString()][string]$ApiUrl + ) + $owner = $Repository.Substring(0, $Repository.IndexOf([char]'/')) + $query = 'state=open&head={0}&base={1}&per_page=100' -f [System.Uri]::EscapeDataString("$($owner):$Head"), [System.Uri]::EscapeDataString($Base) + $response = Invoke-GitHubApi -Method GET -Path ((Join-ApiPath -Part 'repos', $Repository, 'pulls') + "?$query") -Token $Token -ApiUrl $ApiUrl + if ($response.StatusCode -ne 200) { throw "Could not list the pull requests of $Repository (HTTP $($response.StatusCode): $(Get-ApiMessage $response))" } + foreach ($pull in @($response.Body)) { + if ($pull -is [System.Collections.IDictionary]) { + return [pscustomobject]@{ Number = [int]$pull['number']; Url = [string]$pull['html_url']; Title = [string]$pull['title'] } + } + } + return $null +} + +function Update-GitHubPullRequest { + <# + .SYNOPSIS + Replaces the title and body of pull request -Number: { Number, Url }. + .DESCRIPTION + PATCH /repos/{r}/pulls/{n} with { title, body }; an answer other than 200 throws with the status and the message. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Called only by a scan that validated and pushed; a dry run never calls it')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)][string]$Repository, + [Parameter(Mandatory)][int]$Number, + [Parameter(Mandatory)][string]$Title, + [AllowNull()][AllowEmptyString()][string]$Body, + [AllowNull()][AllowEmptyString()][string]$Token, + [AllowNull()][AllowEmptyString()][string]$ApiUrl + ) + $request = [ordered]@{ title = $Title; body = $(if ($null -eq $Body) { '' } else { $Body }) } + $response = Invoke-GitHubApi -Method PATCH -Path (Join-ApiPath -Part 'repos', $Repository, 'pulls', ([string]$Number)) -Token $Token -ApiUrl $ApiUrl -Body $request + if ($response.StatusCode -ne 200) { throw "Could not update pull request #$Number of $Repository (HTTP $($response.StatusCode): $(Get-ApiMessage $response))" } + $url = if ($response.Body -is [System.Collections.IDictionary]) { [string]$response.Body['html_url'] } else { '' } + return [pscustomobject]@{ Number = $Number; Url = $url } +} + function New-GitHubPullRequest { <# .SYNOPSIS @@ -532,7 +587,10 @@ function Publish-GitHubChange { git add -A; nothing to commit gives Pushed $false and Reason no-changes. -DirectCommit commits on the cloned branch and pushes it; a rejected push (branch protection) moves the commit to -NewBranch (reset --soft HEAD~, checkout -b, commit) and pushes that instead, Fallback $true. Otherwise the commit goes to -NewBranch, pushed - with -u. AL-Go behaviour (CommitFromNewFolder). + with -u. AL-Go behaviour (CommitFromNewFolder). -Force rebuilds -NewBranch from the cloned head instead (the scan's + living branch, also after a refused direct push): the remote head of -NewBranch is read with git ls-remote, the + branch is created with checkout -B and pushed with --force-with-lease against that head (absent: the branch must + not exist), so a push someone made in between is rejected, never overwritten. #> [CmdletBinding()] [OutputType([pscustomobject])] @@ -540,7 +598,8 @@ function Publish-GitHubChange { [Parameter(Mandatory)]$Clone, [Parameter(Mandatory)][string]$Message, [Parameter(Mandatory)][string]$NewBranch, - [switch]$DirectCommit + [switch]$DirectCommit, + [switch]$Force ) $root = $Clone.Path $environment = $Clone.Environment @@ -561,9 +620,21 @@ function Publish-GitHubChange { $null = Assert-Git -Root $root -Arguments @('reset', '--soft', 'HEAD~') -Environment $environment -What 'git reset' $fallback = $true } - $null = Assert-Git -Root $root -Arguments @('checkout', '--quiet', '-b', $NewBranch) -Environment $environment -What 'git checkout -b' - $null = Assert-Git -Root $root -Arguments @('commit', '--quiet', '-m', $Message) -Environment $environment -What 'git commit' - $null = Assert-Git -Root $root -Arguments @('push', '--quiet', '-u', 'origin', $NewBranch) -Environment $environment -What "git push $NewBranch" + if ($Force) { + $remote = Assert-Git -Root $root -Arguments @('ls-remote', '--heads', 'origin', "refs/heads/$NewBranch") -Environment $environment -What 'git ls-remote' + $lease = '' + foreach ($line in $remote.Output.Split("`n")) { + $parts = @($line.Trim() -split '\s+') + if ($parts.Count -ge 2 -and $parts[1] -ceq "refs/heads/$NewBranch") { $lease = $parts[0] } + } + $null = Assert-Git -Root $root -Arguments @('checkout', '--quiet', '-B', $NewBranch) -Environment $environment -What 'git checkout -B' + $null = Assert-Git -Root $root -Arguments @('commit', '--quiet', '-m', $Message) -Environment $environment -What 'git commit' + $null = Assert-Git -Root $root -Arguments @('push', '--quiet', "--force-with-lease=refs/heads/$($NewBranch):$lease", 'origin', "HEAD:refs/heads/$NewBranch") -Environment $environment -What "git push --force-with-lease $NewBranch" + } else { + $null = Assert-Git -Root $root -Arguments @('checkout', '--quiet', '-b', $NewBranch) -Environment $environment -What 'git checkout -b' + $null = Assert-Git -Root $root -Arguments @('commit', '--quiet', '-m', $Message) -Environment $environment -What 'git commit' + $null = Assert-Git -Root $root -Arguments @('push', '--quiet', '-u', 'origin', $NewBranch) -Environment $environment -What "git push $NewBranch" + } $sha = (Assert-Git -Root $root -Arguments @('rev-parse', 'HEAD') -What 'git rev-parse').Output.Trim() return [pscustomobject]@{ Pushed = $true; Branch = $NewBranch; Direct = $false; Fallback = $fallback; Sha = $sha; Reason = 'branch' } } @@ -572,6 +643,7 @@ function Publish-GitHubChange { Export-ModuleMember -Function @( 'Find-GitHubPullRequest' + 'Find-GitHubPullRequestByHead' 'Get-GitHubAccessToken' 'Get-GitHubBranchSha' 'Invoke-GitHubApi' @@ -580,4 +652,5 @@ Export-ModuleMember -Function @( 'New-GitHubPullRequest' 'Publish-GitHubChange' 'Save-GitHubZipball' + 'Update-GitHubPullRequest' ) diff --git a/modules/Rulebook.NuGet.psd1 b/modules/Rulebook.NuGet.psd1 new file mode 100644 index 0000000..23b8df9 --- /dev/null +++ b/modules/Rulebook.NuGet.psd1 @@ -0,0 +1,28 @@ +@{ + RootModule = 'Rulebook.NuGet.psm1' + ModuleVersion = '0.1.0' + GUID = 'b64dfd40-5b3b-4c87-b95f-e7aaec3f23a0' + Author = 'ALCops' + CompanyName = 'ALCops' + Copyright = '(c) ALCops. MIT License.' + Description = 'Rulebook NuGet plumbing for the diagnostic scan: NuGet version comparison and channel selection, the flat-container index, and the download and extraction of a package version. See docs/reference/scan-mechanics.md.' + PowerShellVersion = '7.4' + CompatiblePSEditions = @('Core') + FunctionsToExport = @( + 'Compare-NuGetVersion' + 'Get-NuGetPackageUrl' + 'Get-NuGetVersionIndex' + 'Save-NuGetPackage' + 'Select-NuGetChannelVersion' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('ALCops', 'Rulebook', 'AL', 'BusinessCentral', 'ruleset') + LicenseUri = 'https://github.com/ALCops/rulebook-engine/blob/main/LICENSE' + ProjectUri = 'https://github.com/ALCops/rulebook-engine' + } + } +} diff --git a/modules/Rulebook.NuGet.psm1 b/modules/Rulebook.NuGet.psm1 new file mode 100644 index 0000000..8191b44 --- /dev/null +++ b/modules/Rulebook.NuGet.psm1 @@ -0,0 +1,226 @@ +#requires -Version 7.4 +# Rulebook.NuGet: the NuGet side of the diagnostic scan (WP08, R9). Version comparison and channel selection by +# NuGet semantic versioning, the flat-container index of a package, and the download and extraction of one package +# version. No engine imports. Invoke-NuGetRequest is the single web request point, the mock point of the suites; a +# -Source that is an existing folder is read as a flat container on disk (the offline seam of the suites). +# Contract: docs/reference/scan-mechanics.md section 1. + +Set-StrictMode -Version 3.0 + +$script:DefaultSource = 'https://api.nuget.org/v3-flatcontainer' +$script:VersionPattern = '^(?\d+(\.\d+){0,3})(-(?