From 65a49f24c7a06b42bcb1e6bba9b111b778a30e35 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Thu, 8 Oct 2026 21:54:36 +0200 Subject: [PATCH 1/8] Add Rulebook.Edit: overrides.json and the change set engine The Change Rule workflow (#11) and later the dashboard write path (WP15) need one function that applies a change set to overrides.json, all or nothing, and lands it. Rulebook.Edit reads and writes overrides.json in the template layout (byte-identical round trip), sets entries in place by order-insensitive selector equality and removes them with the existing entries named on a miss, validates a change set against the settings and the catalog, and plans it on a candidate copy so the repository itself is never written. The plan has one row per matching endpoint with provenance and the no-op rule of D48 (no endpoint and not the file changes); the rendering covers the table, the pull request body and the job summary with the effective diff; Publish-RulebookChange pushes change-rule// or a direct commit with the fallback of D47. Co-Authored-By: Claude Fable 5.1 --- modules/Rulebook.Edit.psd1 | 36 ++ modules/Rulebook.Edit.psm1 | 940 +++++++++++++++++++++++++++++++++++++ 2 files changed, 976 insertions(+) create mode 100644 modules/Rulebook.Edit.psd1 create mode 100644 modules/Rulebook.Edit.psm1 diff --git a/modules/Rulebook.Edit.psd1 b/modules/Rulebook.Edit.psd1 new file mode 100644 index 0000000..838fba0 --- /dev/null +++ b/modules/Rulebook.Edit.psd1 @@ -0,0 +1,36 @@ +@{ + RootModule = 'Rulebook.Edit.psm1' + ModuleVersion = '0.1.0' + GUID = '43be2693-8cbe-4092-a9e9-53eb1903c922' + Author = 'ALCops' + CompanyName = 'ALCops' + Copyright = '(c) ALCops. MIT License.' + Description = 'Rulebook edit: reads and writes overrides.json, applies a change set (set, remove) to a candidate copy of an organization rulebook repository, plans the effective change per endpoint and the no-op rule, renders the table, pull request body and summary, and lands the change as a pull request or direct commit. See docs/reference/change-mechanics.md.' + PowerShellVersion = '7.4' + CompatiblePSEditions = @('Core') + FunctionsToExport = @( + 'ConvertTo-ChangePullRequestBody' + 'ConvertTo-ChangeSummary' + 'ConvertTo-ChangeTable' + 'ConvertTo-OverridesJson' + 'ConvertTo-RulebookChangeSet' + 'Get-RulebookChangeTitle' + 'Invoke-RulebookChangeSet' + 'Publish-RulebookChange' + 'Read-OverridesFile' + 'Remove-RulebookOverride' + 'Set-RulebookOverride' + 'Test-RulebookChangeSet' + 'Write-OverridesFile' + ) + 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.Edit.psm1 b/modules/Rulebook.Edit.psm1 new file mode 100644 index 0000000..122ab8a --- /dev/null +++ b/modules/Rulebook.Edit.psm1 @@ -0,0 +1,940 @@ +#requires -Version 7.4 +# Rulebook.Edit: changes to overrides.json as change sets (WP09, R10, D32, D47, D48). Reads and writes overrides.json +# in the template layout, sets and removes entries in memory, applies a change set { note?, changes[] } all or nothing +# on a candidate copy of the repository (never the repository itself), regenerates and validates the candidate, works +# out the effective change per endpoint and whether the change is a no-op, renders the table, the pull request body +# and the job summary, and lands the change as a pull request or a direct commit through Rulebook.GitHub. ChangeRule +# is a one-item client; the dashboard write path (WP15) adds the release operation and the changeset file. +# Contract: docs/reference/change-mechanics.md. + +Set-StrictMode -Version 3.0 +Import-Module (Join-Path $PSScriptRoot 'Rulebook.Generate.psd1') +Import-Module (Join-Path $PSScriptRoot 'Rulebook.Validate.psd1') +Import-Module (Join-Path $PSScriptRoot 'Rulebook.Update.psd1') +Import-Module (Join-Path $PSScriptRoot 'Rulebook.GitHub.psd1') +Import-Module (Join-Path $PSScriptRoot 'Rulebook.Action.psd1') + +$script:OverridesSchemaUrl = 'https://raw.githubusercontent.com/ALCops/rulebook-engine/v1/schemas/rulebook-overrides.schema.json' +$script:OverridesFile = 'overrides.json' +$script:Actions = @('Error', 'Warning', 'Info', 'Hidden', 'None') +$script:IdPattern = '^[A-Z]{2,3}\d{4}i?\z' +$script:Utf8NoBom = [System.Text.UTF8Encoding]::new($false) +# A pull request body is limited to 65536 characters; stay below it. +$script:BodyLimit = 60000 + +#region Internal helpers + +function New-ValidationException { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an exception; changes no state')] + param([Parameter(Mandatory)][string]$Message) + $exception = [System.InvalidOperationException]::new($Message) + $exception.Data['Stage'] = 'validation' + return $exception +} + +function New-ChangeFinding { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an object; changes no state')] + param([AllowNull()][string]$Id, [Parameter(Mandatory)][string]$Message, [AllowNull()][string]$File = $script:OverridesFile) + return [pscustomobject]@{ PSTypeName = 'Rulebook.Finding'; Rule = 'change'; Severity = 'error'; File = $File; Id = $(if ($Id) { $Id } else { $null }); Message = $Message } +} + +function Get-SelectorKey { + # A selector as a set: distinct values, sorted ordinally, joined. ['strict', 'ci'] and ['ci', 'strict'] are equal. + param([AllowNull()][AllowEmptyCollection()][string[]]$Values) + $set = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($value in @($Values)) { if ($null -ne $value) { [void]$set.Add($value) } } + return (@($set) -join "`u{1F}") +} + +function Format-Selector { + param([AllowNull()][AllowEmptyCollection()][string[]]$Values) + return (@($Values) -join ', ') +} + +function Format-EntryText { + # 'None (levels: *, stages: ci)', for the remove-miss message. + param([Parameter(Mandatory)]$Entry) + return '{0} (levels: {1}, stages: {2})' -f $Entry.Action, (Format-Selector $Entry.Levels), (Format-Selector $Entry.Stages) +} + +function Format-ChangeSide { + # An effective action with its provenance: 'Warning (level:recommended)', 'None (override, "Legacy tables")'; + # the format of the effective diff (Format-DiffSide in Rulebook.Generate). + param($Action, $Source, $Detail) + if ($null -eq $Source) { return '(absent)' } + $actionText = if ($null -ne $Action) { $Action } else { 'unknown' } + $inner = if ([string]::IsNullOrEmpty($Detail)) { $Source } else { '{0}, "{1}"' -f $Source, $Detail } + return '{0} ({1})' -f $actionText, $inner +} + +function Test-Wildcard { + param([AllowNull()][AllowEmptyCollection()][string[]]$Values) + $list = @($Values) + return $list.Count -eq 1 -and $list[0] -ceq '*' +} + +function Get-ExpandedSelector { + # '*' expands to every slug in settings order; a list of slugs keeps its order. + param([AllowNull()][AllowEmptyCollection()][string[]]$Values, [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Slugs) + if (Test-Wildcard $Values) { return , [string[]]$Slugs } + return , [string[]]@($Values) +} + +function ConvertTo-OverrideEntry { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an object; changes no state')] + param([string]$Id, [string]$Action, [string[]]$Levels, [string[]]$Stages, [AllowNull()][string]$Justification) + return [pscustomobject]@{ + PSTypeName = 'Rulebook.OverrideEntry' + Id = $Id + Action = $Action + Levels = [string[]]@($Levels) + Stages = [string[]]@($Stages) + Justification = $(if ([string]::IsNullOrEmpty($Justification)) { $null } else { $Justification }) + } +} + +function Assert-Selector { + param([Parameter(Mandatory)][string]$Name, [AllowNull()][AllowEmptyCollection()][string[]]$Values) + $list = @($Values | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($list.Count -eq 0) { throw (New-ValidationException "$Name is empty; use a slug from the settings or [`"*`"]") } + if ($list.Count -gt 1 -and $list -contains '*') { throw (New-ValidationException "$Name mixes '*' with other values; use [`"*`"] alone or a list of slugs") } +} + +function ConvertFrom-JsonElement { + # A System.Text.Json element as PowerShell values; strings stay strings (ConvertFrom-Json would turn an ISO date + # in a justification into a [DateTime]). + param([Parameter(Mandatory)][System.Text.Json.JsonElement]$Element) + switch ($Element.ValueKind) { + 'Object' { + $map = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + foreach ($property in $Element.EnumerateObject()) { $map[$property.Name] = ConvertFrom-JsonElement $property.Value } + return , $map + } + 'Array' { + $list = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Element.EnumerateArray()) { $list.Add((ConvertFrom-JsonElement $item)) } + return , $list.ToArray() + } + 'String' { return $Element.GetString() } + 'Number' { return $Element.GetDouble() } + 'True' { return $true } + 'False' { return $false } + default { return $null } + } +} + +function Get-RowNote { + param([Parameter(Mandatory)]$Row, [bool]$JustificationOnly) + if (-not $Row.Changed) { + if ($JustificationOnly) { return 'justification updated' } + return 'unchanged' + } + # Listed before, unlisted after while the id still has an action: the endpoint drops it because the action now + # equals the analyzer default (an override to the default, or a removed entry falling back to it). + if ($Row.ListedBefore -and -not $Row.ListedAfter -and $null -ne $Row.After) { + return 'now unlisted in {0}: {1} equals the analyzer default' -f $Row.Endpoint, $Row.After + } + return '' +} + +function Get-ItemSentence { + param([Parameter(Mandatory)]$Item) + $where = 'levels {0}, stages {1}' -f (Format-Selector $Item.Levels), (Format-Selector $Item.Stages) + $previous = $Item.Entry.Previous + if ($Item.Op -ceq 'remove') { + $was = if ($null -ne $previous) { " (it was $($previous.Action))" } else { '' } + return "Removes the override entry for $($Item.Id) with $where$was." + } + switch ($Item.Entry.Change) { + 'added' { return "Sets $($Item.Id) to $($Item.Action) for $where (adds an entry)." } + 'unchanged' { return "Leaves $($Item.Id) at $($Item.Action) for $where (the entry is unchanged)." } + default { + if ($null -ne $previous -and $previous.Action -ceq $Item.Action) { + return "Updates the justification of the $($Item.Id) entry for $where (the action stays $($Item.Action))." + } + return "Sets $($Item.Id) to $($Item.Action) for $where (replaces the entry that was $($previous.Action))." + } + } +} + +function Get-ItemNote { + # The notes under an item's table: the ids that become unlisted, and how many matching endpoints stay the same. + param([Parameter(Mandatory)]$Item) + $notes = [System.Collections.Generic.List[string]]::new() + foreach ($row in @($Item.Rows | Where-Object { $_.Note -like 'now unlisted in *' })) { $notes.Add("$($row.Id) $($row.Note).") } + $unchanged = @($Item.Rows | Where-Object { -not $_.Changed }).Count + $total = @($Item.Rows).Count + if ($unchanged -gt 0 -and $unchanged -lt $total) { $notes.Add("$unchanged of $total matching endpoints are unchanged.") } + return , $notes.ToArray() +} + +function Get-ItemBlock { + # One item for the body and the summary: the sentence, the table and its notes. + param([Parameter(Mandatory)]$Item) + $text = [System.Text.StringBuilder]::new() + [void]$text.AppendLine((Get-ItemSentence -Item $Item)).AppendLine() + [void]$text.Append((ConvertTo-ChangeTable -Rows $Item.Rows)).AppendLine() + $notes = Get-ItemNote -Item $Item + if ($notes.Count -gt 0) { + foreach ($note in $notes) { [void]$text.AppendLine("- $note") } + [void]$text.AppendLine() + } + return $text.ToString().Replace("`r`n", "`n") +} + +function Get-JustificationParagraph { + param([Parameter(Mandatory)]$Plan) + $items = @($Plan.Items) + if ($items.Count -eq 1 -and -not [string]::IsNullOrEmpty($items[0].Justification)) { return "Justification: $($items[0].Justification)" } + $note = if ($null -ne $Plan.ChangeSet -and $Plan.ChangeSet.Contains('note')) { [string]$Plan.ChangeSet['note'] } else { '' } + if (-not [string]::IsNullOrEmpty($note)) { return $note } + return 'No justification given.' +} + +#endregion + +#region overrides.json + +function Read-OverridesFile { + <# + .SYNOPSIS + Reads overrides.json: { Schema, Rules (a list of { Id, Action, Levels, Stages, Justification }), Path, Exists }. + .DESCRIPTION + A missing file is { Exists $false } with the schema URL and no rules. Strings are read as they are (a justification + that looks like a date stays text). Throws when the file is not JSON or has no rules array. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'The file is overrides.json')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][string]$Path) + $full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + $rules = [System.Collections.Generic.List[object]]::new() + $file = [pscustomobject]@{ PSTypeName = 'Rulebook.OverridesFile'; Schema = $script:OverridesSchemaUrl; Rules = $rules; Path = $full; Exists = $false } + if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { return $file } + $file.Exists = $true + try { + $document = [System.Text.Json.JsonDocument]::Parse([System.IO.File]::ReadAllText($full, $script:Utf8NoBom)) + } catch { + throw "$($script:OverridesFile) is not valid JSON: $($_.Exception.InnerException.Message)" + } + try { + $json = ConvertFrom-JsonElement $document.RootElement + } finally { + $document.Dispose() + } + if ($json -isnot [System.Collections.IDictionary] -or -not $json.Contains('rules') -or $json['rules'] -isnot [array]) { throw "$($script:OverridesFile) has no rules array" } + if ($json.Contains('$schema') -and $json['$schema'] -is [string]) { $file.Schema = $json['$schema'] } + foreach ($rule in $json['rules']) { + if ($rule -isnot [System.Collections.IDictionary]) { throw "$($script:OverridesFile) has an entry that is not an object" } + $value = { param([string]$Key) if ($rule.Contains($Key)) { $rule[$Key] } else { $null } } + $rules.Add((ConvertTo-OverrideEntry -Id ([string](& $value 'id')) -Action ([string](& $value 'action')) -Levels ([string[]]@(& $value 'levels')) -Stages ([string[]]@(& $value 'stages')) -Justification ([string](& $value 'justification')))) + } + return $file +} + +function ConvertTo-OverridesJson { + <# + .SYNOPSIS + The text of overrides.json in the template layout: $schema, then one entry per line ("rules": [] when empty). + .DESCRIPTION + Each entry is { "id", "action", "levels", "stages", "justification" } in that order on one line; justification is + left out when empty. Two-space indent, LF line ends and one trailing LF, the JSON string escaping of + ConvertTo-JsonString. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'The file is overrides.json')] + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)]$File) + $schema = if ([string]::IsNullOrEmpty($File.Schema)) { $script:OverridesSchemaUrl } else { $File.Schema } + $array = { param([string[]]$Values) '[' + (@($Values | ForEach-Object { ConvertTo-JsonString $_ }) -join ', ') + ']' } + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('{') + $lines.Add(' "$schema": ' + (ConvertTo-JsonString $schema) + ',') + $entries = @($File.Rules) + if ($entries.Count -eq 0) { + $lines.Add(' "rules": []') + } else { + $lines.Add(' "rules": [') + for ($i = 0; $i -lt $entries.Count; $i++) { + $entry = $entries[$i] + $item = '{ "id": ' + (ConvertTo-JsonString $entry.Id) + ', "action": ' + (ConvertTo-JsonString $entry.Action) + ', "levels": ' + (& $array $entry.Levels) + ', "stages": ' + (& $array $entry.Stages) + if (-not [string]::IsNullOrEmpty($entry.Justification)) { $item += ', "justification": ' + (ConvertTo-JsonString $entry.Justification) } + $lines.Add(' ' + $item + ' }' + $(if ($i -lt $entries.Count - 1) { ',' } else { '' })) + } + $lines.Add(' ]') + } + $lines.Add('}') + return ($lines -join "`n") + "`n" +} + +function Write-OverridesFile { + <# + .SYNOPSIS + Writes overrides.json when the bytes differ: { File, Change (created, modified) } or nothing. + .DESCRIPTION + With -WhatIf nothing is written and the change is returned. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'The file is overrides.json')] + [CmdletBinding(SupportsShouldProcess)] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$File, [string]$Name = $script:OverridesFile) + $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + $bytes = $script:Utf8NoBom.GetBytes((ConvertTo-OverridesJson -File $File)) + $exists = Test-Path -LiteralPath $Path -PathType Leaf + if ($exists -and [System.Linq.Enumerable]::SequenceEqual([byte[]][System.IO.File]::ReadAllBytes($Path), [byte[]]$bytes)) { return } + $change = if ($exists) { 'modified' } else { 'created' } + if ($PSCmdlet.ShouldProcess($Name, "Write ($change)")) { + $parent = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $parent -PathType Container)) { [void][System.IO.Directory]::CreateDirectory($parent) } + [System.IO.File]::WriteAllBytes($Path, $bytes) + } elseif (-not $WhatIfPreference) { + return + } + return [pscustomobject]@{ PSTypeName = 'Rulebook.FileChange'; File = $Name; Change = $change } +} + +#endregion + +#region Entry operations + +function Set-RulebookOverride { + <# + .SYNOPSIS + Sets the override of -Id for a level and stage selection in an overrides file object: { Entry, Change, Previous }. + .DESCRIPTION + An entry with the same id and the same level and stage sets (ordinal, order and duplicates ignored) gets the new + action and justification in place, its position and selector order kept; an empty justification drops the key. + Change is replaced, or unchanged when action and justification are the same already; otherwise the entry is + appended (added). Previous is a copy of the entry before (or $null). Throws (Data['Stage'] = 'validation') on an + action outside the five and on an empty selector or one that mixes '*' with slugs. Changes the object passed in; + writes no file. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Changes the object passed in; writes no file')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)]$File, + [Parameter(Mandatory)][string]$Id, + [Parameter(Mandatory)][string]$Action, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Levels, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Stages, + [AllowNull()][AllowEmptyString()][string]$Justification + ) + if ($Action -cnotin $script:Actions) { throw (New-ValidationException "action '$Action': use Error, Warning, Info, Hidden or None") } + Assert-Selector -Name 'levels' -Values $Levels + Assert-Selector -Name 'stages' -Values $Stages + if ([string]::IsNullOrEmpty($Justification)) { $Justification = $null } + $levelKey = Get-SelectorKey $Levels + $stageKey = Get-SelectorKey $Stages + $index = -1 + for ($i = 0; $i -lt $File.Rules.Count; $i++) { + $entry = $File.Rules[$i] + if ($entry.Id -ceq $Id -and (Get-SelectorKey $entry.Levels) -ceq $levelKey -and (Get-SelectorKey $entry.Stages) -ceq $stageKey) { $index = $i } + } + if ($index -lt 0) { + $new = ConvertTo-OverrideEntry -Id $Id -Action $Action -Levels $Levels -Stages $Stages -Justification $Justification + $File.Rules.Add($new) + return [pscustomobject]@{ Entry = $new; Change = 'added'; Previous = $null } + } + $old = $File.Rules[$index] + $previous = ConvertTo-OverrideEntry -Id $old.Id -Action $old.Action -Levels $old.Levels -Stages $old.Stages -Justification $old.Justification + if ($old.Action -ceq $Action -and [string]$old.Justification -ceq [string]$Justification) { + return [pscustomobject]@{ Entry = $old; Change = 'unchanged'; Previous = $previous } + } + $new = ConvertTo-OverrideEntry -Id $old.Id -Action $Action -Levels $old.Levels -Stages $old.Stages -Justification $Justification + $File.Rules[$index] = $new + return [pscustomobject]@{ Entry = $new; Change = 'replaced'; Previous = $previous } +} + +function Remove-RulebookOverride { + <# + .SYNOPSIS + Removes the override entry of -Id with these level and stage sets from an overrides file object: { Entry, Change }. + .DESCRIPTION + Selector sets compare as in Set-RulebookOverride; every entry with the same id and sets goes. Change is removed. + No such entry throws (Data['Stage'] = 'validation') with the entries that exist for the id. Changes the object + passed in; writes no file. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Changes the object passed in; writes no file')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)]$File, + [Parameter(Mandatory)][string]$Id, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Levels, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Stages + ) + Assert-Selector -Name 'levels' -Values $Levels + Assert-Selector -Name 'stages' -Values $Stages + $levelKey = Get-SelectorKey $Levels + $stageKey = Get-SelectorKey $Stages + $removed = $null + for ($i = $File.Rules.Count - 1; $i -ge 0; $i--) { + $entry = $File.Rules[$i] + if ($entry.Id -ceq $Id -and (Get-SelectorKey $entry.Levels) -ceq $levelKey -and (Get-SelectorKey $entry.Stages) -ceq $stageKey) { + if ($null -eq $removed) { $removed = $entry } + $File.Rules.RemoveAt($i) + } + } + if ($null -ne $removed) { return [pscustomobject]@{ Entry = $removed; Change = 'removed' } } + $existing = @($File.Rules | Where-Object { $_.Id -ceq $Id }) + $message = "$($script:OverridesFile) has no entry for $Id" + if ($existing.Count -gt 0) { + $message = "$($script:OverridesFile) has no entry for $Id with levels [$(Format-Selector $Levels)] and stages [$(Format-Selector $Stages)]; existing entries for $($Id): $((@($existing | ForEach-Object { Format-EntryText $_ })) -join ', ')" + } + throw (New-ValidationException $message) +} + +#endregion + +#region Change set + +function ConvertTo-RulebookChangeSet { + <# + .SYNOPSIS + A one-item change set from the ChangeRule form: { Note, Changes = @({ op, id, action?, levels, stages, justification? }) }. + .DESCRIPTION + -Action Remove (any case) becomes op remove without an action; a known action is written in its canonical case. + -Levels and -Stages are '*' or slugs. Validation is Test-RulebookChangeSet. + #> + [CmdletBinding()] + [OutputType([System.Collections.IDictionary])] + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$RuleId, + [Parameter(Mandatory)][AllowEmptyString()][string]$Action, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Levels, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Stages, + [AllowNull()][AllowEmptyString()][string]$Justification, + [AllowNull()][AllowEmptyString()][string]$Note + ) + $change = [ordered]@{} + if ($Action.Trim() -ieq 'Remove') { + $change['op'] = 'remove' + $change['id'] = $RuleId.Trim() + } else { + $change['op'] = 'set' + $change['id'] = $RuleId.Trim() + $canonical = @($script:Actions | Where-Object { $_ -ieq $Action.Trim() }) + $change['action'] = if ($canonical.Count -eq 1) { $canonical[0] } else { $Action } + } + $change['levels'] = [string[]]@($Levels | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + $change['stages'] = [string[]]@($Stages | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + if ($change['op'] -ceq 'set' -and -not [string]::IsNullOrWhiteSpace($Justification)) { $change['justification'] = $Justification.Trim() } + $set = [ordered]@{} + if (-not [string]::IsNullOrWhiteSpace($Note)) { $set['note'] = $Note.Trim() } + $set['changes'] = @(, $change) + return $set +} + +function Test-RulebookChangeSet { + <# + .SYNOPSIS + Checks a change set against the settings and the catalog: findings { Rule 'change', Severity 'error', File, Id, Message }. + .DESCRIPTION + changes is not empty; op is set or remove (release is reserved for WP15); the id matches ^[A-Z]{2,3}\d{4}i?$ and + is in the catalog; a set has one of the five actions; levels and stages are ["*"] or slugs of the settings (the + wording of C10); no two changes have the same op, id, level set and stage set. An empty list means valid. + #> + [CmdletBinding()] + [OutputType([object[]])] + param([Parameter(Mandatory)][System.Collections.IDictionary]$ChangeSet, [Parameter(Mandatory)]$Inputs) + $findings = [System.Collections.Generic.List[object]]::new() + [object[]]$changes = @(if ($ChangeSet.Contains('changes')) { $ChangeSet['changes'] | Where-Object { $null -ne $_ } }) + if ($changes.Count -eq 0) { + $findings.Add((New-ChangeFinding -Message 'The change set has no changes.')) + return $findings.ToArray() + } + $levelSlugs = [string[]]@($Inputs.Levels | ForEach-Object { $_.Slug }) + $stageSlugs = [string[]]@($Inputs.Stages | ForEach-Object { $_.Slug }) + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + for ($index = 0; $index -lt $changes.Count; $index++) { + $change = $changes[$index] + $value = { param([string]$Key) if ($change -is [System.Collections.IDictionary] -and $change.Contains($Key)) { $change[$Key] } else { $null } } + $op = [string](& $value 'op') + $id = [string](& $value 'id') + $label = "change $index" + if ($op -ceq 'release') { + $findings.Add((New-ChangeFinding -Id $id -Message "$label`: op 'release' is reserved; it arrives with WP15")) + continue + } + if ($op -cnotin 'set', 'remove') { + $findings.Add((New-ChangeFinding -Id $id -Message "$label`: op '$op' is not supported; use set or remove")) + continue + } + if ($id -cnotmatch $script:IdPattern) { + $findings.Add((New-ChangeFinding -Id $id -Message "'$id' is not a diagnostic id: two or three capital letters, four digits and an optional i, for example LC0015")) + } elseif (-not $Inputs.Catalog.ContainsKey($id)) { + $findings.Add((New-ChangeFinding -Id $id -Message "$id is not in catalog/diagnostics.json")) + } + if ($op -ceq 'set') { + $action = [string](& $value 'action') + if ($action -cnotin $script:Actions) { $findings.Add((New-ChangeFinding -Id $id -Message "action '$action': use Error, Warning, Info, Hidden or None")) } + } + foreach ($kind in @(@{ Name = 'levels'; Single = 'level'; Slugs = $levelSlugs }, @{ Name = 'stages'; Single = 'stage'; Slugs = $stageSlugs })) { + $values = @(& $value $kind.Name | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) + if ($values.Count -eq 0) { + $findings.Add((New-ChangeFinding -Id $id -Message "$label has no $($kind.Name); use a slug from the settings or [`"*`"]")) + } elseif ($values.Count -gt 1 -and $values -contains '*') { + $findings.Add((New-ChangeFinding -Id $id -Message "$label mixes '*' with other values in $($kind.Name); use [`"*`"] alone or a list of slugs")) + } elseif (-not (Test-Wildcard $values)) { + foreach ($slug in $values) { + if ($slug -cnotin $kind.Slugs) { $findings.Add((New-ChangeFinding -Id $id -Message "$label names unknown $($kind.Single) '$slug'; use a slug from the settings or [`"*`"]")) } + } + } + } + $key = '{0}|{1}|{2}|{3}' -f $op, $id, (Get-SelectorKey ([string[]]@(& $value 'levels'))), (Get-SelectorKey ([string[]]@(& $value 'stages'))) + if (-not $seen.Add($key)) { $findings.Add((New-ChangeFinding -Id $id -Message "$label repeats an earlier change (same op, id, levels and stages)")) } + } + return $findings.ToArray() +} + +function Invoke-RulebookChangeSet { + <# + .SYNOPSIS + Applies a change set to a candidate copy of the repository and plans the change: a Rulebook.ChangePlan. + .DESCRIPTION + Never writes into -RepositoryRoot. Reads the inputs, checks the change set (Test-RulebookChangeSet) and applies + every operation to overrides.json in memory; a finding or a remove without a matching entry stops there with + Valid $false and Failure validation, before anything is copied. Otherwise the repository is copied to + /candidate, overrides.json is written there (unless every entry is unchanged), and every matching + endpoint of every item gets a row: the effective action before and after with provenance, Changed and a Note + (unchanged, justification updated, or now unlisted). NoOp is true when no endpoint changes and overrides.json does + not change (D48); a no-op stops before the regeneration with Changes empty. Otherwise the candidate is regenerated + and validated (Findings, Valid) and Changes lists every file that differs from the repository { File, Change, + Bytes }. HeadSha is the checked-out commit (Publish-RulebookChange refuses a base branch that moved since). + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)][string]$RepositoryRoot, + [Parameter(Mandatory)][System.Collections.IDictionary]$ChangeSet, + [string]$WorkPath, + [System.DateTimeOffset]$Now = [System.DateTimeOffset]::UtcNow + ) + $root = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath + if ([string]::IsNullOrEmpty($WorkPath)) { $WorkPath = Get-DefaultWorkPath -Prefix 'rulebook-change' } + $WorkPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($WorkPath) + $plan = [pscustomobject]@{ + PSTypeName = 'Rulebook.ChangePlan' + Root = $root + CandidatePath = $null + HeadSha = $null + Now = $Now + ChangeSet = $ChangeSet + Items = @() + OverridesChange = $null + Changes = @() + Findings = @() + Valid = $false + Failure = $null + NoOp = $false + Title = $null + } + if (Get-Command git -ErrorAction SilentlyContinue) { + $head = & git -C $root rev-parse HEAD 2>$null + if ($LASTEXITCODE -eq 0 -and ([string]$head).Trim() -match '^[0-9a-f]{40}$') { $plan.HeadSha = ([string]$head).Trim() } + } + + # 1. The inputs and the change set. + try { + $before = Read-RulebookInputs -RepositoryRoot $root + if (-not $before.SettingsPresent) { throw 'Settings missing: .github/Rulebook-Settings.json' } + } catch { + $plan.Findings = @(New-ChangeFinding -File $null -Message "The rulebook cannot be read: $($_.Exception.Message)") + $plan.Failure = 'validation' + return $plan + } + $findings = @(Test-RulebookChangeSet -ChangeSet $ChangeSet -Inputs $before) + if ($findings.Count -gt 0) { + $plan.Findings = $findings + $plan.Failure = 'validation' + return $plan + } + + # 2. The operations, in memory. + $levelSlugs = [string[]]@($before.Levels | ForEach-Object { $_.Slug }) + $stageSlugs = [string[]]@($before.Stages | ForEach-Object { $_.Slug }) + try { + $file = Read-OverridesFile -Path (Join-Path $root $script:OverridesFile) + } catch { + $plan.Findings = @(New-ChangeFinding -Message $_.Exception.Message) + $plan.Failure = 'validation' + return $plan + } + $items = [System.Collections.Generic.List[object]]::new() + foreach ($change in @($ChangeSet['changes'])) { + $item = [pscustomobject]@{ + Op = [string]$change['op'] + Id = [string]$change['id'] + Action = $(if ($change.Contains('action')) { [string]$change['action'] } else { $null }) + Levels = [string[]]@($change['levels']) + Stages = [string[]]@($change['stages']) + Justification = $(if ($change.Contains('justification')) { [string]$change['justification'] } else { $null }) + Entry = $null + Rows = @() + ChangedEndpoints = @() + NoOp = $false + Notes = @() + } + try { + if ($item.Op -ceq 'remove') { + $result = Remove-RulebookOverride -File $file -Id $item.Id -Levels $item.Levels -Stages $item.Stages + $item.Entry = [pscustomobject]@{ Change = 'removed'; Previous = $result.Entry } + } else { + $result = Set-RulebookOverride -File $file -Id $item.Id -Action $item.Action -Levels $item.Levels -Stages $item.Stages -Justification $item.Justification + $item.Entry = [pscustomobject]@{ Change = $result.Change; Previous = $result.Previous } + } + } catch { + $plan.Findings = @(New-ChangeFinding -Id $item.Id -Message $_.Exception.Message) + $plan.Failure = 'validation' + return $plan + } + $items.Add($item) + } + $plan.Items = $items.ToArray() + $plan.Title = Get-RulebookChangeTitle -Plan $plan + + # 3. The candidate. + $candidate = Join-Path $WorkPath 'candidate' + if (Test-Path -LiteralPath $candidate) { Remove-Item -LiteralPath $candidate -Recurse -Force } + Copy-UpdateTree -Source $root -Destination $candidate + $plan.CandidatePath = $candidate + if (@($items | Where-Object { $_.Entry.Change -cne 'unchanged' }).Count -gt 0) { + $plan.OverridesChange = Write-OverridesFile -Path (Join-Path $candidate $script:OverridesFile) -File $file -WhatIf:$false -Confirm:$false + } + $after = Read-RulebookInputs -RepositoryRoot $candidate + + # 4. Rows per item and endpoint. + foreach ($item in $items) { + $justificationOnly = $item.Entry.Change -ceq 'replaced' -and $null -ne $item.Entry.Previous -and $item.Entry.Previous.Action -ceq $item.Action + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($level in (Get-ExpandedSelector $item.Levels $levelSlugs)) { + foreach ($stage in (Get-ExpandedSelector $item.Stages $stageSlugs)) { + $b = Get-EffectiveAction -Inputs $before -Id $item.Id -Level $level -Stage $stage + $a = Get-EffectiveAction -Inputs $after -Id $item.Id -Level $level -Stage $stage + $row = [pscustomobject]@{ + PSTypeName = 'Rulebook.ChangeRow' + Endpoint = "$level.$stage" + File = 'rulesets/' + (Get-EndpointFileName -Level $level -Stage $stage) + Id = $item.Id + Before = $b.Action + After = $a.Action + BeforeSource = $b.Source + AfterSource = $a.Source + BeforeDetail = $b.Detail + AfterDetail = $a.Detail + ListedBefore = [bool]$b.Listed + ListedAfter = [bool]$a.Listed + Changed = ($b.Action -cne $a.Action) -or ([bool]$b.Listed -ne [bool]$a.Listed) + Note = $null + } + $row.Note = Get-RowNote -Row $row -JustificationOnly $justificationOnly + $rows.Add($row) + } + } + $item.Rows = $rows.ToArray() + $item.ChangedEndpoints = [string[]]@($rows | Where-Object Changed | ForEach-Object Endpoint) + $item.NoOp = $item.ChangedEndpoints.Count -eq 0 -and $item.Entry.Change -ceq 'unchanged' + $item.Notes = Get-ItemNote -Item $item + } + $plan.NoOp = @($items | Where-Object { $_.ChangedEndpoints.Count -gt 0 }).Count -eq 0 -and $null -eq $plan.OverridesChange + if ($plan.NoOp) { + $plan.Valid = $true + return $plan + } + + # 5. Regenerate and validate the candidate. + $all = [System.Collections.Generic.List[object]]::new() + try { + $null = Update-RulebookEndpoints -RepositoryRoot $candidate -WhatIf:$false -Confirm:$false + } catch { + $all.Add((New-ChangeFinding -File $null -Message "The changed rulebook cannot be regenerated: $($_.Exception.Message)")) + } + foreach ($finding in @(Test-Rulebook -RepositoryRoot $candidate)) { $all.Add($finding) } + $plan.Findings = $all.ToArray() + $plan.Valid = @($all | Where-Object Severity -EQ 'error').Count -eq 0 + if (-not $plan.Valid) { $plan.Failure = 'validation' } + + # 6. The files that differ from the repository. + [string[]]$rootFiles = @(Get-TreeFile -Root $root) + [string[]]$candidateFiles = @(Get-TreeFile -Root $candidate) + $rootSet = [System.Collections.Generic.HashSet[string]]::new($rootFiles, [System.StringComparer]::Ordinal) + $candidateSet = [System.Collections.Generic.HashSet[string]]::new($candidateFiles, [System.StringComparer]::Ordinal) + $union = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($path in @($rootFiles) + @($candidateFiles)) { [void]$union.Add($path) } + $changes = foreach ($path in $union) { + $change = $null + if (-not $rootSet.Contains($path)) { $change = 'created' } + elseif (-not $candidateSet.Contains($path)) { $change = 'deleted' } + elseif ((Get-ComparableContent -Root $root -Path $path) -cne (Get-ComparableContent -Root $candidate -Path $path)) { $change = 'modified' } + if ($null -eq $change) { continue } + $bytes = if ($change -eq 'deleted') { $null } else { [System.IO.File]::ReadAllBytes((Join-Path $candidate $path)) } + [pscustomobject]@{ File = $path; Change = $change; Bytes = $bytes } + } + $plan.Changes = @($changes) + return $plan +} + +#endregion + +#region Rendering + +function Get-RulebookChangeTitle { + <# + .SYNOPSIS + The commit and pull request title of a change plan. + .DESCRIPTION + One item: 'Change LC0015 to None (levels: strict, stages: ci)' or 'Remove override for LC0015 (levels: strict, + stages: ci)'. Several items (WP15): 'Rulebook change: changes'. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)]$Plan) + $items = @($Plan.Items) + if ($items.Count -ne 1) { return "Rulebook change: $($items.Count) changes" } + $item = $items[0] + $where = '(levels: {0}, stages: {1})' -f (Format-Selector $item.Levels), (Format-Selector $item.Stages) + if ($item.Op -ceq 'remove') { return "Remove override for $($item.Id) $where" } + return "Change $($item.Id) to $($item.Action) $where" +} + +function ConvertTo-ChangeTable { + <# + .SYNOPSIS + | Endpoint | Before | After | Note |: one row per matching endpoint, the effective action with its provenance. + .DESCRIPTION + A side reads 'Warning (level:recommended)' or 'None (override, "Legacy tables")', the provenance format of the + effective diff. Free text goes through Format-TableCell. + #> + [CmdletBinding()] + [OutputType([string])] + param([AllowNull()][AllowEmptyCollection()][object[]]$Rows) + $text = [System.Text.StringBuilder]::new() + [void]$text.AppendLine('| Endpoint | Before | After | Note |').AppendLine('|---|---|---|---|') + foreach ($row in @($Rows)) { + $before = Format-ChangeSide $row.Before $row.BeforeSource $row.BeforeDetail + $after = Format-ChangeSide $row.After $row.AfterSource $row.AfterDetail + [void]$text.AppendLine(('| {0} | {1} | {2} | {3} |' -f (Format-TableCell $row.Endpoint), (Format-TableCell $before), (Format-TableCell $after), (Format-TableCell $row.Note))) + } + return $text.ToString().Replace("`r`n", "`n") +} + +function ConvertTo-ChangePullRequestBody { + <# + .SYNOPSIS + The pull request body of a change: the justification, then per item one sentence, its table and its notes. + .DESCRIPTION + The paragraph is 'Justification: ' (one item with a justification), the note of the change set, or + 'No justification given.'. Above -Limit (default 60000 characters, GitHub allows 65536) the item tables are left + out from the end with one italic line; only when that is not enough is the body cut at a line boundary. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)]$Plan, [int]$Limit = $script:BodyLimit) + $intro = (Get-JustificationParagraph -Plan $Plan) + "`n`n" + $items = @($Plan.Items) + [string[]]$blocks = @($items | ForEach-Object { Get-ItemBlock -Item $_ }) + [string[]]$short = @($items | ForEach-Object { (Get-ItemSentence -Item $_) + "`n`n" }) + $build = { + param([int]$Full, [string]$Dropped) + $parts = for ($i = 0; $i -lt $blocks.Count; $i++) { if ($i -lt $Full) { $blocks[$i] } else { $short[$i] } } + $text = $intro + (@($parts) -join '') + $text = $text.Replace("`r`n", "`n").TrimEnd("`n") + "`n" + if ($Dropped) { $text += "`n$Dropped`n" } + return $text + } + $body = & $build $blocks.Count $null + if ($body.Length -le $Limit) { return $body } + for ($count = $blocks.Count - 1; $count -ge 0; $count--) { + $dropped = "_$($blocks.Count - $count) of $($blocks.Count) change tables were left out to keep this body below the GitHub limit; the job summary of the change run has them all._" + $body = & $build $count $dropped + if ($body.Length -le $Limit) { return $body } + } + $tail = "`n_The body was cut at a line boundary to stay below the GitHub limit; the job summary of the change run has the full tables._`n" + $cut = $body.LastIndexOf("`n", [math]::Max(0, $Limit - $tail.Length - 1), [System.StringComparison]::Ordinal) + if ($cut -lt 0) { $cut = 0 } + return $body.Substring(0, $cut + 1) + $tail +} + +function ConvertTo-ChangeSummary { + <# + .SYNOPSIS + The job summary of a change: '## Rule change', the message, the result, the item tables, validation errors and + the effective diff. + .DESCRIPTION + The effective diff ('## Effective diff', one table per endpoint) is the git-based diff of the pushed commit + against the cloned head (-Result.Diff, or -Result.DiffNote when it could not be computed); it appears only here, + never in the pull request body (decision 14). + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)]$Plan, $Result, [AllowNull()][AllowEmptyString()][string]$Message) + $text = [System.Text.StringBuilder]::new() + [void]$text.AppendLine('## Rule change').AppendLine() + if ($Message) { [void]$text.AppendLine((ConvertTo-SingleLine $Message)).AppendLine() } + if ($null -ne $Result) { + $line = switch ([string]$Result.Result) { + 'pull-request' { "Pull request: $($Result.PullRequestUrl) (branch ``$($Result.Branch)``)" + $(if ($Result.Fallback) { '; the direct commit was refused' } else { '' }) } + 'direct-commit' { "Committed to ``$($Result.Branch)`` ($(Get-ShortSha $Result.Sha))" } + default { $null } + } + if ($line) { [void]$text.AppendLine($line).AppendLine() } + } + if (@($Plan.Items).Count -gt 0) { + [void]$text.AppendLine((Get-JustificationParagraph -Plan $Plan)).AppendLine() + foreach ($item in @($Plan.Items)) { [void]$text.Append((Get-ItemBlock -Item $item)) } + } + $errors = @($Plan.Findings | Where-Object Severity -EQ 'error') + if ($errors.Count -gt 0) { + [void]$text.AppendLine('## Validation errors').AppendLine() + [void]$text.AppendLine('| Rule | File | Id | Message |').AppendLine('|---|---|---|---|') + foreach ($finding in $errors) { + $file = if ($finding.File) { '`' + $finding.File + '`' } else { '' } + [void]$text.AppendLine(('| {0} | {1} | {2} | {3} |' -f $finding.Rule, $file, (Format-TableCell $finding.Id), (Format-TableCell $finding.Message))) + } + [void]$text.AppendLine() + } + if ($null -ne $Result) { + $note = if ($Result.PSObject.Properties['DiffNote']) { $Result.DiffNote } else { $null } + $diff = if ($Result.PSObject.Properties['Diff']) { @($Result.Diff) } else { @() } + if ($note) { + [void]$text.AppendLine('## Effective diff').AppendLine().AppendLine($note).AppendLine() + } elseif ($diff.Count -gt 0) { + [void]$text.AppendLine('## Effective diff').AppendLine() + # Get-EffectiveDiffBlock returns its array with a comma; @() around it would nest it. + $blocks = Get-EffectiveDiffBlock -Diff $diff + [void]$text.Append(($blocks -join '')) + } + } + return $text.ToString().Replace("`r`n", "`n") +} + +#endregion + +#region Publishing + +function Publish-RulebookChange { + <# + .SYNOPSIS + Applies a valid change plan to a fresh clone and opens the pull request (or pushes the direct commit). + .DESCRIPTION + Refuses an invalid plan and a no-op plan. Clones -RemoteUrl (default /) at + -BaseBranch; a plan that recorded the head of its checkout (HeadSha) refuses a base branch that moved since (stage + push, Data['Reason'] = 'base-moved'). Writes the plan's changes, commits with the title (Get-RulebookChangeTitle) + and pushes / (-Now), or -BaseBranch with -DirectCommit, falling back to the new + branch when the push is refused (D47). Diff is the effective diff of the commit against the cloned head. Opens + the pull request with -Labels and the body of ConvertTo-ChangePullRequestBody. Returns { Result (pull-request, + direct-commit, no-changes), PullRequestUrl, Number, Branch, Sha, Fallback, Diff, DiffNote, Body, Title }. A + failure throws with Data['Stage']: push for the clone, commit and push; pull-request for the opening, naming the + pushed branch and its tree link. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)]$Plan, + [Parameter(Mandatory)][string]$Repository, + [string]$RepositoryRoot, + [string]$RemoteUrl, + [AllowNull()][AllowEmptyString()][string]$Token, + [Parameter(Mandatory)][string]$BaseBranch, + [Parameter(Mandatory)][string]$BranchPrefix, + [switch]$DirectCommit, + [AllowNull()][AllowEmptyString()][string]$Actor, + [AllowNull()][AllowEmptyCollection()][string[]]$Labels, + [string]$WorkPath, + [AllowNull()][AllowEmptyString()][string]$ApiUrl, + [System.DateTimeOffset]$Now = [System.DateTimeOffset]::UtcNow + ) + if (-not $Plan.Valid -or $Plan.Failure) { throw 'The change plan does not validate; nothing is pushed.' } + if ($Plan.NoOp) { throw 'The change is a no-op; nothing is pushed.' } + if ([string]::IsNullOrEmpty($WorkPath)) { $WorkPath = Get-DefaultWorkPath -Prefix 'rulebook-change' } + $server = if ($env:GITHUB_SERVER_URL) { $env:GITHUB_SERVER_URL.TrimEnd('/') } else { 'https://github.com' } + if ([string]::IsNullOrEmpty($RemoteUrl)) { $RemoteUrl = "$server/$Repository" } + $title = Get-RulebookChangeTitle -Plan $Plan + $branch = '{0}/{1}' -f $BranchPrefix.TrimEnd('/'), $Now.UtcDateTime.ToString('yyMMddHHmmss', [System.Globalization.CultureInfo]::InvariantCulture) + + # The rulebook may sit in a folder of a bigger repository; the plan's paths are relative to that folder. + $prefix = '' + $gitRoot = if ($RepositoryRoot) { $RepositoryRoot } else { $Plan.Root } + if ($gitRoot -and (Get-Command git -ErrorAction SilentlyContinue)) { + $show = & git -C $gitRoot rev-parse --show-prefix 2>$null + if ($LASTEXITCODE -eq 0 -and $show) { $prefix = ([string]$show).Trim() } + } + try { + $clone = New-GitHubClone -RemoteUrl $RemoteUrl -Branch $BaseBranch -Path (Join-Path $WorkPath 'clone') -Token $Token -Actor $Actor + $planned = [string]$Plan.HeadSha + if ($planned -and $planned -cne $clone.BaseSha) { + $moved = [System.InvalidOperationException]::new("The base branch moved since the change was planned ($BaseBranch $(Get-ShortSha $planned) is now $(Get-ShortSha $clone.BaseSha)); nothing was pushed. Run the workflow again.") + $moved.Data['Reason'] = 'base-moved' + throw $moved + } + $rulebookRoot = if ($prefix) { Join-Path $clone.Path $prefix.TrimEnd('/') } else { $clone.Path } + foreach ($change in $Plan.Changes) { + $target = Join-Path $rulebookRoot $change.File + if ($change.Change -ceq 'deleted') { + if (Test-Path -LiteralPath $target -PathType Leaf) { Remove-Item -LiteralPath $target -Force } + } else { + $parent = Split-Path -Parent $target + if (-not (Test-Path -LiteralPath $parent -PathType Container)) { [void][System.IO.Directory]::CreateDirectory($parent) } + [System.IO.File]::WriteAllBytes($target, [byte[]]$change.Bytes) + } + } + $pushed = Publish-GitHubChange -Clone $clone -Message $title -NewBranch $branch -DirectCommit:$DirectCommit + } catch { + $exception = [System.InvalidOperationException]::new($_.Exception.Message, $_.Exception) + $exception.Data['Stage'] = 'push' + if ($_.Exception.Data['Reason']) { $exception.Data['Reason'] = $_.Exception.Data['Reason'] } + throw $exception + } + $result = [pscustomobject]@{ Result = $null; PullRequestUrl = $null; Number = $null; Branch = $pushed.Branch; Sha = $pushed.Sha; Fallback = [bool]$pushed.Fallback; Diff = @(); DiffNote = $null; Body = $null; Title = $title } + if (-not $pushed.Pushed) { + $result.Result = 'no-changes' + return $result + } + try { + $result.Diff = @(Compare-RulebookEndpoints -RepositoryRoot $rulebookRoot -Ref $clone.BaseSha) + } catch { + $result.DiffNote = "The effective diff could not be computed: $($_.Exception.Message)" + } + if ($pushed.Direct) { + $result.Result = 'direct-commit' + return $result + } + # The branch is pushed from here on: a failure names it, so the pull request can be opened by hand. + try { + $body = ConvertTo-ChangePullRequestBody -Plan $Plan + $result.Body = $body + $pull = New-GitHubPullRequest -Repository $Repository -Token $Token -Title $title -Body $body -Head $pushed.Branch -Base $BaseBranch -Labels $Labels -ApiUrl $ApiUrl -ServerUrl $server + } catch { + $segments = @(foreach ($part in @($Repository.Split('/')) + @('tree') + @($pushed.Branch.Split('/'))) { [System.Uri]::EscapeDataString($part) }) + $link = "$server/$($segments -join '/')" + $message = "Branch $($pushed.Branch) was pushed. $($_.Exception.Message)" + if (-not $message.Contains($link)) { $message += " Open the pull request by hand: $link" } + $exception = [System.InvalidOperationException]::new($message, $_.Exception) + $exception.Data['Stage'] = 'pull-request' + $exception.Data['Branch'] = $pushed.Branch + throw $exception + } + $result.Result = 'pull-request' + $result.PullRequestUrl = $pull.Url + $result.Number = $pull.Number + return $result +} + +#endregion + +Export-ModuleMember -Function @( + 'ConvertTo-ChangePullRequestBody' + 'ConvertTo-ChangeSummary' + 'ConvertTo-ChangeTable' + 'ConvertTo-OverridesJson' + 'ConvertTo-RulebookChangeSet' + 'Get-RulebookChangeTitle' + 'Invoke-RulebookChangeSet' + 'Publish-RulebookChange' + 'Read-OverridesFile' + 'Remove-RulebookOverride' + 'Set-RulebookOverride' + 'Test-RulebookChangeSet' + 'Write-OverridesFile' +) From c29624d369bbf7dc384f70af4606fbf62e1cf968 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Thu, 8 Oct 2026 21:54:36 +0200 Subject: [PATCH 2/8] Add the ChangeRule action, the template workflow and the CI job ChangeRule.ps1 builds a one-item change set from the form, plans it, and only then checks the token, so an invalid id or a no-op answers without the secret; a real change exchanges and masks the token and lands per commitOptions.createPullRequest (D47). The template ChangeRule.yaml has the five inputs, no directCommit input, a read-only workflow token and choice lists laid out so the update's rewrite (D30) reproduces it with the shipped settings. The fixture workflows rename the input id to ruleId. The CI job changerule-action proves the token failure, the no-op and the invalid id on a copy of valid-minimal. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 63 ++++++ actions/ChangeRule/ChangeRule.ps1 | 205 ++++++++++++++++++ actions/ChangeRule/action.yaml | 100 +++++++++ template/.github/workflows/ChangeRule.yaml | 93 ++++++++ .../.github/workflows/ChangeRule.yaml | 2 +- .../v1/.github/workflows/ChangeRule.yaml | 2 +- .../v2/.github/workflows/ChangeRule.yaml | 2 +- 7 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 actions/ChangeRule/ChangeRule.ps1 create mode 100644 actions/ChangeRule/action.yaml create mode 100644 template/.github/workflows/ChangeRule.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a87f3a0..04f4736 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,6 +260,69 @@ jobs: Write-Host "::error::CheckForUpdates outcomes: v1 updatesAvailable '$($env:UNCHANGED)' (expected false), v2 '$($env:MOVED)' (expected true), update without token '$($env:NOTOKEN)' (expected failure/token)" exit 1 + # The change workflow on a copy of valid-minimal: no token, nothing pushed. The token guard runs after the local + # plan, so a valid change fails on the token while a no-op and an invalid id need none. + changerule-action: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Prepare the fixture + id: fixture + shell: pwsh + run: | + . ./tests/Helpers/RepoFixture.ps1 + $org = New-FixtureRepo -Name 'valid-minimal' -Destination (Join-Path $env:RUNNER_TEMP 'change-org') + Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "org=$org" + + - name: Change AA0001 to None without the token (must fail) + id: notoken + continue-on-error: true + uses: ./actions/ChangeRule + with: + repositoryRoot: ${{ steps.fixture.outputs.org }} + ruleId: AA0001 + action: None + levels: '*' + stages: '*' + + # valid-minimal already has AA0072 Info for every level and stage with the justification House style. + - name: No-op change + id: noop + uses: ./actions/ChangeRule + with: + repositoryRoot: ${{ steps.fixture.outputs.org }} + ruleId: AA0072 + action: Info + levels: '*' + stages: '*' + justification: House style + + - name: Invalid id (must fail) + id: invalid + continue-on-error: true + uses: ./actions/ChangeRule + with: + repositoryRoot: ${{ steps.fixture.outputs.org }} + ruleId: LC9999 + action: Warning + + # The outputs name the outcome, so a crash or another error does not pass. + - name: Require the three outcomes + if: steps.notoken.outcome != 'failure' || steps.notoken.outputs.failure != 'token' || steps.noop.outputs.noop != 'true' || steps.invalid.outcome != 'failure' || steps.invalid.outputs.failure != 'validation' + shell: pwsh + env: + NOTOKEN: ${{ steps.notoken.outcome }}/${{ steps.notoken.outputs.failure }} + NOOP: ${{ steps.noop.outputs.noop }} + INVALID: ${{ steps.invalid.outcome }}/${{ steps.invalid.outputs.failure }} + run: | + Write-Host "::error::ChangeRule outcomes: without token '$($env:NOTOKEN)' (expected failure/token), no-op '$($env:NOOP)' (expected true), invalid id '$($env:INVALID)' (expected failure/validation)" + exit 1 + # The scan against the real packages on nuget.org (the unit suites use stub packages). Every run is a dry run: no # token, nothing pushed. nuget.org moves, so the checks are lower bounds and the log prints the numbers. scan-action: diff --git a/actions/ChangeRule/ChangeRule.ps1 b/actions/ChangeRule/ChangeRule.ps1 new file mode 100644 index 0000000..02b8f7c --- /dev/null +++ b/actions/ChangeRule/ChangeRule.ps1 @@ -0,0 +1,205 @@ +#requires -Version 7.4 +<# +.SYNOPSIS +Entry script of the ChangeRule action: set or remove one override entry for a level and stage selection and land it +as a pull request (or a direct commit). +.DESCRIPTION +1. Reads the settings: the secret name (ghTokenWorkflowSecretName) and the labels (commitOptions.pullRequestLabels). +2. Builds a one-item change set (ConvertTo-RulebookChangeSet; -Action Remove deletes the matching entry) and plans it + on a candidate copy (Invoke-RulebookChangeSet; the repository itself is never written). A finding (rule id, + action, selectors, a remove without a matching entry) is one error annotation each, failure validation; a + candidate that does not validate gives one error annotation per finding, "The changed rulebook would not validate:". +3. A no-op (no endpoint changes, overrides.json would not change, D48) is one notice, result no-op, noop true and exit + code 0; it needs no token. +4. The token guard: an invalid ghTokenWorkflowSecretName or an empty -Token is failure token; the token is exchanged + (Get-GitHubAccessToken) and masked before anything else runs. +5. Publish-RulebookChange (or -PublishCommand) on change-rule//: pull-request, or + direct-commit with -DirectCommit (the workflow derives it from commitOptions.createPullRequest, D47; a refused + push falls back to the pull request). Failures are push or pull-request with the token hint. +6. The job summary (ConvertTo-ChangeSummary, capped at -SummaryLimit) and GITHUB_OUTPUT: result (pull-request, + direct-commit, no-op), noop, changedEndpoints (comma separated), pullRequestUrl, branch and failure (validation, + token, push, pull-request, error; empty on success). +Returns { ExitCode, Result, NoOp, Failure, Plan, Publish, Annotations, Summary, Outputs } and never calls exit, so tests +run it in-process; action.yaml exits with ExitCode. -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. +#> +[CmdletBinding()] +param( + [string]$RepositoryRoot = '.', + [AllowEmptyString()][string]$RuleId, + [AllowEmptyString()][string]$Action, + [AllowEmptyString()][string]$Levels = '*', + [AllowEmptyString()][string]$Stages = '*', + [AllowEmptyString()][string]$Justification, + [AllowEmptyString()][string]$Token, + [switch]$DirectCommit, + [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' }), + [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-RulebookChange 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 +$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.Edit.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force + +$docsUrl = 'https://github.com/ALCops/rulebook/blob/main/docs/ghtokenworkflow.md' + +$ownWork = [string]::IsNullOrEmpty($WorkPath) +if ($ownWork) { + $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } + $WorkPath = Join-Path $tempRoot ('rulebook-change-' + [guid]::NewGuid().ToString('n').Substring(0, 8)) +} +$WorkPath = Resolve-ActionPath $WorkPath +$ctx = New-ActionContext -Title 'ChangeRule' + +# 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' +$split = { param([string]$Value) [string[]]@($Value.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) } + +try { + # 1. Settings: the secret name and the labels. + $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' + $settings = $null + if (Test-Path -LiteralPath $settingsFile -PathType Leaf) { + try { $settings = Get-Content -LiteralPath $settingsFile -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop } catch { $settings = $null } + } + $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 } + $labels = [string[]]@(& $setting 'commitOptions', 'pullRequestLabels' | Where-Object { $_ -is [string] -and $_ -ne '' }) + + # 2. The change set and the plan, on a candidate copy. + $changeSet = ConvertTo-RulebookChangeSet -RuleId ([string]$RuleId) -Action ([string]$Action) -Levels (& $split ([string]$Levels)) -Stages (& $split ([string]$Stages)) -Justification $Justification + $plan = Invoke-RulebookChangeSet -RepositoryRoot $root -ChangeSet $changeSet -WorkPath (Join-Path $WorkPath 'plan') -Now $Now + if ($plan.Failure -or -not $plan.Valid) { + $prefix = if ($null -ne $plan.CandidatePath) { 'The changed rulebook would not validate: ' } else { '' } + foreach ($finding in @($plan.Findings | Where-Object Severity -EQ 'error')) { + $message = if ($finding.Id -and $finding.Rule -cne 'change') { "$($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 -Context $ctx -File $file -Title $(if ($finding.Rule -ceq 'change') { 'ChangeRule' } else { $finding.Rule }) -Message "$prefix$message" + } + Add-Failure -Context $ctx -Kind 'validation' + $summaryMessage = if ($prefix) { 'The changed rulebook does not validate; nothing was pushed.' } else { 'The change is not valid; nothing was written.' } + $failed = $true + } elseif ($plan.NoOp) { + # 3. Nothing would change (D48). + $item = @($plan.Items)[0] + $endpoints = @($item.Rows | ForEach-Object Endpoint) -join ', ' + $summaryMessage = "No change: $($item.Id) is already $($item.Action) on every matching endpoint ($endpoints); overrides.json was not written" + Add-Annotation -Context $ctx -Command notice -Message $summaryMessage + $result = 'no-op' + } else { + # 4. The token guard, after the local plan: a validation error or a no-op needs no secret. + if (-not [string]::IsNullOrEmpty($name) -and -not $nameValid) { + Add-Failure -Context $ctx -Kind '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 -Context $ctx -Kind 'token' + throw "The $secretName secret is needed to change a rule. Read $docsUrl" + } + if ([string]::IsNullOrEmpty($Repository)) { throw 'The change needs the repository (GITHUB_REPOSITORY) as owner/name.' } + if ([string]::IsNullOrEmpty($BaseBranch)) { throw 'The change needs baseBranch.' } + try { + $access = Get-GitHubAccessToken -Token $Token -Repository $Repository -ApiUrl $ApiUrl + } catch { + Add-Failure -Context $ctx -Kind '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)" + + # 5. Publish. + try { + $publishParameters = @{ + Plan = $plan; RepositoryRoot = $root; Repository = $Repository; RemoteUrl = $RemoteUrl; Token = $access.Token; BaseBranch = $BaseBranch + BranchPrefix = "change-rule/$($plan.Items[0].Id)"; DirectCommit = [bool]$DirectCommit; Actor = $Actor; Labels = $labels + WorkPath = (Join-Path $WorkPath 'publish'); ApiUrl = $ApiUrl; Now = $Now + } + $publish = if ($null -ne $PublishCommand) { & $PublishCommand @publishParameters } else { Publish-RulebookChange @publishParameters } + } catch { + $stage = [string]$_.Exception.Data['Stage'] + if ($stage -cnotin 'push', 'pull-request') { $stage = 'push' } + Add-Failure -Context $ctx -Kind $stage + # The base branch moved between plan and publish: nothing is wrong with the token; run the workflow again. + if ([string]$_.Exception.Data['Reason'] -ceq 'base-moved') { throw $_.Exception.Message } + $what = if ($stage -eq 'pull-request') { 'Failed to create the pull request for the rule change' } else { 'Failed to push the rule change' } + 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) { + 'direct-commit' { $summaryMessage = "Rule change committed to $($publish.Branch) ($(Get-ShortSha $publish.Sha))" } + 'no-changes' { $summaryMessage = 'No changes to commit' } + default { $summaryMessage = "Pull request: $($publish.PullRequestUrl)" + $(if ($publish.Fallback) { ' (the direct commit was refused)' } else { '' }) } + } + Add-Annotation -Context $ctx -Command notice -Message $summaryMessage + } +} catch { + Add-Annotation -Context $ctx -Message $_.Exception.Message + Add-Failure -Context $ctx -Kind 'error' + $summaryMessage = $_.Exception.Message + $failed = $true +} finally { + if ($ownWork -and (Test-Path -LiteralPath $WorkPath)) { Remove-Item -LiteralPath $WorkPath -Recurse -Force -ErrorAction SilentlyContinue } +} + +# 6. Summary and outputs. +$summary = "## Rule change`n`n$(ConvertTo-SingleLine $summaryMessage)`n`n" +if ($null -ne $plan) { + try { + $summary = ConvertTo-ChangeSummary -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]::Round($SummaryLimit / 1KB)) KiB; the full tables are in $where." +Write-Text -Path $SummaryPath -Text $summary +$changed = if ($null -ne $plan -and -not $failed) { @($plan.Items | ForEach-Object { $_.ChangedEndpoints } | Select-Object -Unique) -join ',' } else { '' } +$outputs = [ordered]@{ + result = $(if ($failed -or -not $result) { '' } else { $result }) + noop = $(if (-not $failed -and $result -eq 'no-op') { 'true' } else { 'false' }) + changedEndpoints = $changed + pullRequestUrl = $(if ($null -ne $publish -and $publish.PullRequestUrl) { $publish.PullRequestUrl } else { '' }) + branch = $(if ($null -ne $publish -and $publish.Branch) { $publish.Branch } else { '' }) + failure = $ctx.Failure +} +Write-ActionOutput -Outputs $outputs +[pscustomobject]@{ + ExitCode = $(if ($failed) { 1 } else { 0 }) + Result = $outputs.result + NoOp = $outputs.noop -eq 'true' + Failure = $ctx.Failure + Plan = $plan + Publish = $publish + Annotations = $ctx.Annotations.ToArray() + Summary = $summary + Outputs = $outputs +} diff --git a/actions/ChangeRule/action.yaml b/actions/ChangeRule/action.yaml new file mode 100644 index 0000000..d5fe16b --- /dev/null +++ b/actions/ChangeRule/action.yaml @@ -0,0 +1,100 @@ +name: Rulebook ChangeRule +description: Sets or removes one override entry of an organization rulebook repository for a level and stage selection, regenerates and validates the endpoints, and lands the change as a pull request (or a direct commit). +author: ALCops + +inputs: + ruleId: + description: The diagnostic id, for example LC0015. Checked against ^[A-Z]{2,3}\d{4}i?$ and the catalog. + required: true + action: + description: Error, Warning, Info, Hidden or None sets the entry; Remove deletes the entry with these selectors. + required: true + levels: + description: "A level slug from the settings, or '*' for every level." + required: false + default: '*' + stages: + description: "A stage slug from the settings, or '*' for every stage." + required: false + default: '*' + justification: + description: Why the organization changes the rule; written into the entry. Optional. + required: false + default: '' + token: + description: The GHTOKENWORKFLOW value (a personal access token or GitHub App JSON). Not needed for a change that fails validation or changes nothing. + required: false + default: '' + directCommit: + description: "'true' pushes to baseBranch instead of opening a pull request (falls back to a pull request when the push is refused)." + required: false + default: 'false' + baseBranch: + description: The branch to change. + 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 change commit. + required: false + default: ${{ github.actor }} + +outputs: + result: + description: "pull-request, direct-commit or no-op; empty on failure." + value: ${{ steps.change.outputs.result }} + noop: + description: "'true' when nothing would change (no endpoint and not overrides.json); nothing was written." + value: ${{ steps.change.outputs.noop }} + changedEndpoints: + description: The endpoints whose effective action changes, comma separated (for example strict.ci). + value: ${{ steps.change.outputs.changedEndpoints }} + pullRequestUrl: + description: The URL of the pull request. + value: ${{ steps.change.outputs.pullRequestUrl }} + branch: + description: The pushed branch (change-rule//) or the base branch of a direct commit. + value: ${{ steps.change.outputs.branch }} + failure: + description: "Why the step failed, empty on success: validation, token, push, pull-request or error." + value: ${{ steps.change.outputs.failure }} + +runs: + using: composite + steps: + - name: Change the rule + id: change + shell: pwsh + # Inputs reach the script through the environment, never interpolated into the script (script injection). + env: + INPUT_RULEID: ${{ inputs.ruleId }} + INPUT_ACTION: ${{ inputs.action }} + INPUT_LEVELS: ${{ inputs.levels }} + INPUT_STAGES: ${{ inputs.stages }} + INPUT_JUSTIFICATION: ${{ inputs.justification }} + INPUT_TOKEN: ${{ inputs.token }} + INPUT_DIRECTCOMMIT: ${{ inputs.directCommit }} + INPUT_BASEBRANCH: ${{ inputs.baseBranch }} + INPUT_REPOSITORYROOT: ${{ inputs.repositoryRoot }} + INPUT_ACTOR: ${{ inputs.actor }} + GITHUB_TOKEN: ${{ github.token }} + run: | + $parameters = @{ + RepositoryRoot = $env:INPUT_REPOSITORYROOT + RuleId = $env:INPUT_RULEID + Action = $env:INPUT_ACTION + Levels = $env:INPUT_LEVELS + Stages = $env:INPUT_STAGES + Justification = $env:INPUT_JUSTIFICATION + Token = $env:INPUT_TOKEN + DirectCommit = $env:INPUT_DIRECTCOMMIT -eq 'true' + BaseBranch = $env:INPUT_BASEBRANCH + Actor = $env:INPUT_ACTOR + } + # The result object is the last pipeline output; anything a module emits before it is ignored. + $result = @(& (Join-Path $env:GITHUB_ACTION_PATH 'ChangeRule.ps1') @parameters)[-1] + if ($null -eq $result -or $null -eq $result.PSObject.Properties['ExitCode']) { Write-Host '::error::ChangeRule.ps1 returned no result'; exit 1 } + exit $result.ExitCode diff --git a/template/.github/workflows/ChangeRule.yaml b/template/.github/workflows/ChangeRule.yaml new file mode 100644 index 0000000..1993280 --- /dev/null +++ b/template/.github/workflows/ChangeRule.yaml @@ -0,0 +1,93 @@ +name: Change Rule + +on: + workflow_dispatch: + inputs: + ruleId: + description: 'Diagnostic id, for example LC0015.' + type: string + required: true + action: + description: 'The new action; Remove deletes the matching override entry.' + type: choice + options: + - Error + - Warning + - Info + - Hidden + - None + - Remove + # The level and stage lists are rewritten from the settings by every update (D30). + levels: + description: 'Level slug, or * for every level. The list follows the settings after the next update.' + type: choice + default: '*' + options: + - '*' + - essential + - recommended + - strict + - complete + stages: + description: 'Stage slug, or * for every stage. The list follows the settings after the next update.' + type: choice + default: '*' + options: + - '*' + - default + - ci + - vnext + justification: + description: 'Why the rule changes (optional); written into the override entry.' + type: string + required: false + default: '' + +# The workflow token stays read-only: the change is pushed with the token from the GHTOKENWORKFLOW secret, so the +# pull request it opens runs the Validate workflow (a pull request opened with GITHUB_TOKEN would not). +permissions: + contents: read + +concurrency: + group: change-rule-${{ github.ref }} + cancel-in-progress: false + +jobs: + change: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # No credentials in .git/config: the action reads the checkout and clones again with the secret's token to push. + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + # The secret name comes from ghTokenWorkflowSecretName; where the change lands follows + # commitOptions.createPullRequest on every run (D47). + - name: Read the settings + id: settings + shell: pwsh + run: | + $settings = Get-Content -LiteralPath .github/Rulebook-Settings.json -Raw | ConvertFrom-Json -AsHashtable + $name = 'GHTOKENWORKFLOW' + if ($settings.Contains('ghTokenWorkflowSecretName') -and $settings['ghTokenWorkflowSecretName']) { $name = [string]$settings['ghTokenWorkflowSecretName'] } + if ($name -cnotmatch '^[A-Za-z_][A-Za-z0-9_]*$' -or $name -imatch '^GITHUB_') { Write-Host "::error::ghTokenWorkflowSecretName '$name' is not a valid secret name"; exit 1 } + $createPullRequest = $true + $options = $settings['commitOptions'] + if ($options -is [System.Collections.IDictionary] -and $options.Contains('createPullRequest')) { $createPullRequest = [bool]$options['createPullRequest'] } + $directCommit = -not $createPullRequest + $lines = "secretName=$name", "directCommit=$($directCommit.ToString().ToLowerInvariant())" + Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value $lines + Write-Host "Secret: $name, direct commit: $directCommit" + + - name: Change the rule + uses: ALCops/rulebook-engine/actions/ChangeRule@main + with: + token: ${{ secrets[steps.settings.outputs.secretName] }} + ruleId: ${{ inputs.ruleId }} + action: ${{ inputs.action }} + levels: ${{ inputs.levels }} + stages: ${{ inputs.stages }} + justification: ${{ inputs.justification }} + directCommit: ${{ steps.settings.outputs.directCommit }} diff --git a/tests/fixtures/repos/update-org/.github/workflows/ChangeRule.yaml b/tests/fixtures/repos/update-org/.github/workflows/ChangeRule.yaml index d5cfce9..a78d634 100644 --- a/tests/fixtures/repos/update-org/.github/workflows/ChangeRule.yaml +++ b/tests/fixtures/repos/update-org/.github/workflows/ChangeRule.yaml @@ -3,7 +3,7 @@ name: Change Rule on: workflow_dispatch: inputs: - id: + ruleId: description: 'Diagnostic id, for example LC0001.' required: true action: diff --git a/tests/fixtures/templates/v1/.github/workflows/ChangeRule.yaml b/tests/fixtures/templates/v1/.github/workflows/ChangeRule.yaml index eb0d14e..18b7141 100644 --- a/tests/fixtures/templates/v1/.github/workflows/ChangeRule.yaml +++ b/tests/fixtures/templates/v1/.github/workflows/ChangeRule.yaml @@ -3,7 +3,7 @@ name: Change Rule on: workflow_dispatch: inputs: - id: + ruleId: description: 'Diagnostic id, for example LC0001.' required: true action: diff --git a/tests/fixtures/templates/v2/.github/workflows/ChangeRule.yaml b/tests/fixtures/templates/v2/.github/workflows/ChangeRule.yaml index b63a703..41860f4 100644 --- a/tests/fixtures/templates/v2/.github/workflows/ChangeRule.yaml +++ b/tests/fixtures/templates/v2/.github/workflows/ChangeRule.yaml @@ -3,7 +3,7 @@ name: Change Rule on: workflow_dispatch: inputs: - id: + ruleId: description: 'Diagnostic id, for example LC0001.' required: true action: From 24455eae7d61608cbf10194ffff1f523f076d75c Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Thu, 8 Oct 2026 21:54:37 +0200 Subject: [PATCH 3/8] Test Rulebook.Edit, the ChangeRule action and the shipped workflow rewrite Rulebook.Edit.Tests covers the overrides I/O, set, replace and remove, every change set check, the acceptance criteria of #11 on valid-minimal and the publish against a bare repository. ChangeRule.Action.Tests covers action.yaml, the template workflow with its settings step run in-process, and the entry script including a bare remote. The Update suite checks the rewrite of the shipped ChangeRule.yaml; the Template suite counts 12 hand-written files. Co-Authored-By: Claude Fable 5.1 --- tests/ChangeRule.Action.Tests.ps1 | 348 ++++++++++++++++++++++++ tests/Rulebook.Edit.Tests.ps1 | 436 ++++++++++++++++++++++++++++++ tests/Rulebook.Template.Tests.ps1 | 3 +- tests/Rulebook.Update.Tests.ps1 | 9 + 4 files changed, 795 insertions(+), 1 deletion(-) create mode 100644 tests/ChangeRule.Action.Tests.ps1 create mode 100644 tests/Rulebook.Edit.Tests.ps1 diff --git a/tests/ChangeRule.Action.Tests.ps1 b/tests/ChangeRule.Action.Tests.ps1 new file mode 100644 index 0000000..d7378b9 --- /dev/null +++ b/tests/ChangeRule.Action.Tests.ps1 @@ -0,0 +1,348 @@ +# Action suite for WP09 (#11): actions/ChangeRule/action.yaml, its entry script ChangeRule.ps1 run in-process, and the +# template workflow template/.github/workflows/ChangeRule.yaml. The ci.yml job changerule-action runs the action +# itself on valid-minimal; the module is covered by tests/Rulebook.Edit.Tests.ps1. + +BeforeDiscovery { + $script:gitMissing = $null -eq (Get-Command git -ErrorAction SilentlyContinue) +} + +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + $script:actionDir = Join-Path $script:repoRoot 'actions' 'ChangeRule' + $script:entry = Join-Path $script:actionDir 'ChangeRule.ps1' + $script:minimal = Join-Path $PSScriptRoot 'fixtures' 'repos' 'valid-minimal' + . (Join-Path $PSScriptRoot 'Helpers' 'RepoFixture.ps1') + + # On GitHub Actions these point at the real job; the tests must not write into them. + $script:saved = @{ Output = $env:GITHUB_OUTPUT; Summary = $env:GITHUB_STEP_SUMMARY; Repository = $env:GITHUB_REPOSITORY; Token = $env:GITHUB_TOKEN } + $env:GITHUB_OUTPUT = $null + $env:GITHUB_STEP_SUMMARY = $null + $env:GITHUB_TOKEN = $null + $script:now = [System.DateTimeOffset]::new(2026, 10, 8, 9, 15, 30, [System.TimeSpan]::Zero) + + function Get-TestFolder { + return Join-Path $TestDrive ([guid]::NewGuid().ToString('n').Substring(0, 12)) + } + + function Copy-Minimal { + return New-FixtureRepo -Name 'valid-minimal' -Destination (Get-TestFolder) + } + + function Invoke-Entry { + # Runs ChangeRule.ps1 in-process; returns the result object, the console lines, the summary and the outputs. + param([hashtable]$Parameters) + $folder = Get-TestFolder + if (-not $Parameters.ContainsKey('SummaryPath')) { $Parameters.SummaryPath = "$folder.summary.md" } + if (-not $Parameters.ContainsKey('WorkPath')) { $Parameters.WorkPath = Join-Path $folder 'work' } + if (-not $Parameters.ContainsKey('Repository')) { $Parameters.Repository = 'Contoso/rulebook' } + if (-not $Parameters.ContainsKey('BaseBranch')) { $Parameters.BaseBranch = 'main' } + if (-not $Parameters.ContainsKey('Now')) { $Parameters.Now = $script:now } + # No request leaves the test: an unmocked GitHub call fails fast. + if (-not $Parameters.ContainsKey('ApiUrl')) { $Parameters.ApiUrl = 'http://127.0.0.1:9' } + $outputFile = "$folder.output.txt" + $env:GITHUB_OUTPUT = $outputFile + try { + $output = @(& $script:entry @Parameters 6>&1 3>$null) + } finally { + $env:GITHUB_OUTPUT = $null + } + return [pscustomobject]@{ + Result = $output | Where-Object { $_ -isnot [System.Management.Automation.InformationRecord] } | Select-Object -Last 1 + Lines = @($output | Where-Object { $_ -is [System.Management.Automation.InformationRecord] } | ForEach-Object { [string]$_.MessageData }) + Summary = if (Test-Path -LiteralPath $Parameters.SummaryPath) { Get-Content -LiteralPath $Parameters.SummaryPath -Raw } else { '' } + Output = if (Test-Path -LiteralPath $outputFile) { Get-Content -LiteralPath $outputFile -Raw } else { '' } + } + } +} + +AfterAll { + $env:GITHUB_OUTPUT = $script:saved.Output + $env:GITHUB_STEP_SUMMARY = $script:saved.Summary + $env:GITHUB_REPOSITORY = $script:saved.Repository + $env:GITHUB_TOKEN = $script:saved.Token + Remove-Module Rulebook.Edit, Rulebook.Update, Rulebook.GitHub, Rulebook.Template, Rulebook.Validate, Rulebook.Generate, Rulebook.Action -ErrorAction SilentlyContinue +} + +Describe 'actions/ChangeRule/action.yaml' { + BeforeAll { + $script:yaml = Get-Content -LiteralPath (Join-Path $actionDir 'action.yaml') -Raw + } + + It 'is a composite action' { + $yaml | Should-MatchString '(?m)^ using: composite$' + } + + It 'requires ruleId and action' { + $yaml | Should-MatchString '(?ms)^ ruleId:\n[^\n]*\n required: true$' + $yaml | Should-MatchString '(?ms)^ action:\n[^\n]*\n required: true$' + } + + It 'declares input with default ' -ForEach @( + @{ Name = 'levels'; Default = "'*'" } + @{ Name = 'stages'; Default = "'*'" } + @{ Name = 'justification'; Default = "''" } + @{ Name = 'token'; Default = "''" } + @{ Name = 'directCommit'; Default = "'false'" } + @{ Name = 'baseBranch'; Default = '${{ github.ref_name }}' } + @{ Name = 'repositoryRoot'; Default = "'.'" } + @{ Name = 'actor'; Default = '${{ github.actor }}' } + ) { + $yaml | Should-MatchString ("(?ms)^ {0}:\n.*?^ default: {1}$" -f $Name, [regex]::Escape($Default)) + } + + It 'declares the output ' -ForEach @( + @{ Name = 'result' } + @{ Name = 'noop' } + @{ Name = 'changedEndpoints' } + @{ Name = 'pullRequestUrl' } + @{ Name = 'branch' } + @{ Name = 'failure' } + ) { + $yaml | Should-MatchString ("(?m)^ {0}:\n.*\n value: \$\{{\{{ steps\.change\.outputs\.{0} \}}\}}$" -f $Name) + } + + It 'passes inputs through env and never interpolates them into run' { + $run = [regex]::Match($yaml, '(?ms)^ run: \|\n(.*)').Groups[1].Value + $run | Should-NotMatchString '\$\{\{' + foreach ($name in 'RULEID', 'ACTION', 'LEVELS', 'STAGES', 'JUSTIFICATION', 'TOKEN', 'DIRECTCOMMIT', 'BASEBRANCH', 'REPOSITORYROOT', 'ACTOR') { + $yaml | Should-MatchString "(?m)^ INPUT_$($name): \`$\{\{ inputs\.\w+ \}\}$" + } + $yaml | Should-MatchString '(?m)^ GITHUB_TOKEN: \$\{\{ github\.token \}\}$' + $run | Should-MatchString 'GITHUB_ACTION_PATH' + $run | Should-MatchString 'exit \$result\.ExitCode' + } +} + +Describe 'template/.github/workflows/ChangeRule.yaml' { + BeforeAll { + $script:workflow = Get-Content -LiteralPath (Join-Path $repoRoot 'template' '.github' 'workflows' 'ChangeRule.yaml') -Raw + $script:settingsStep = [regex]::Match($workflow, '(?ms)^ run: \|\n(.*?)\n\n').Groups[1].Value + + function Invoke-SettingsStep { + # Runs the settings step in-process on a folder with -Settings as .github/Rulebook-Settings.json. + param([string]$Settings) + $script = Join-Path $TestDrive 'settings-step.ps1' + [System.IO.File]::WriteAllText($script, ($settingsStep -replace '(?m)^ ', '')) + $root = Get-TestFolder + Write-FixtureText -Path (Join-Path $root '.github' 'Rulebook-Settings.json') -Text $Settings + $output = Join-Path $root 'settings-output.txt' + $saved = $env:GITHUB_OUTPUT + try { + $env:GITHUB_OUTPUT = $output + Push-Location -LiteralPath $root + try { $null = & $script 6>$null } finally { Pop-Location } + } finally { + $env:GITHUB_OUTPUT = $saved + } + return @(Get-Content -LiteralPath $output) + } + } + + It 'is named Change Rule and runs only on workflow_dispatch' { + $workflow | Should-MatchString '(?m)^name: Change Rule$' + $workflow | Should-MatchString '(?m)^on:\n workflow_dispatch:\n' + $workflow | Should-NotMatchString '(?m)^ (push|pull_request|schedule):' + } + + It 'has the five inputs with the exact option lists' { + $workflow | Should-MatchString "(?ms)^ ruleId:\n description: 'Diagnostic id, for example LC0015\.'\n type: string\n required: true$" + $workflow | Should-MatchString "(?ms)^ action:\n description: 'The new action; Remove deletes the matching override entry\.'\n type: choice\n options:\n - Error\n - Warning\n - Info\n - Hidden\n - None\n - Remove\n" + $workflow | Should-MatchString "(?ms)^ levels:\n[^\n]*\n type: choice\n default: '\*'\n options:\n - '\*'\n - essential\n - recommended\n - strict\n - complete\n" + $workflow | Should-MatchString "(?ms)^ stages:\n[^\n]*\n type: choice\n default: '\*'\n options:\n - '\*'\n - default\n - ci\n - vnext\n" + $workflow | Should-MatchString "(?ms)^ justification:\n[^\n]*\n type: string\n required: false\n default: ''$" + $workflow | Should-NotMatchString '(?m)^ directCommit:' + } + + It 'keeps the workflow token at contents read and runs one change per ref at a time' { + $workflow | Should-MatchString '(?m)^permissions:\n contents: read\n\n' + $workflow | Should-MatchString '(?m)^concurrency:\n group: change-rule-\$\{\{ github\.ref \}\}\n cancel-in-progress: false$' + $workflow | Should-MatchString '(?m)^ persist-credentials: false$' + } + + It 'looks the secret up by the name from the settings and calls the engine action at main' { + $workflow | Should-MatchString '(?m)^ uses: ALCops/rulebook-engine/actions/ChangeRule@main$' + $workflow | Should-MatchString '(?m)^ token: \$\{\{ secrets\[steps\.settings\.outputs\.secretName\] \}\}$' + $workflow | Should-MatchString '(?m)^ directCommit: \$\{\{ steps\.settings\.outputs\.directCommit \}\}$' + foreach ($name in 'ruleId', 'action', 'levels', 'stages', 'justification') { + $workflow | Should-MatchString "(?m)^ $($name): \`$\{\{ inputs\.$name \}\}$" + } + } + + It 'never interpolates an expression into run' { + $settingsStep | Should-MatchString 'ghTokenWorkflowSecretName' + $settingsStep | Should-NotMatchString '\$\{\{' + } + + It 'derives directCommit from commitOptions.createPullRequest: ' -ForEach @( + @{ Name = 'false gives a direct commit'; Settings = '{ "ghTokenWorkflowSecretName": "RULEBOOK_TOKEN", "commitOptions": { "createPullRequest": false } }'; Expected = @('secretName=RULEBOOK_TOKEN', 'directCommit=true') } + @{ Name = 'true gives a pull request'; Settings = '{ "commitOptions": { "createPullRequest": true } }'; Expected = @('secretName=GHTOKENWORKFLOW', 'directCommit=false') } + @{ Name = 'absent gives a pull request'; Settings = '{ "templateUrl": "x" }'; Expected = @('secretName=GHTOKENWORKFLOW', 'directCommit=false') } + ) { + Invoke-SettingsStep -Settings $Settings | Should-BeCollection $Expected + } +} + +Describe 'ChangeRule.ps1' { + It 'annotates an invalid id and fails validation before any file is touched (AC5)' { + $root = Copy-Minimal + $run = Invoke-Entry @{ RepositoryRoot = $root; WorkspaceRoot = $root; RuleId = 'LC9999'; Action = 'Warning' } + $run.Result.ExitCode | Should-Be 1 + $run.Result.Failure | Should-Be 'validation' + @($run.Result.Annotations) | Should-BeCollection @('::error file=overrides.json,title=ChangeRule::LC9999 is not in catalog/diagnostics.json') + $run.Output | Should-Be "result=`nnoop=false`nchangedEndpoints=`npullRequestUrl=`nbranch=`nfailure=validation`n" + } + + It 'lists the existing entries when Remove finds no matching entry' { + $root = Copy-Minimal + $run = Invoke-Entry @{ RepositoryRoot = $root; WorkspaceRoot = $root; RuleId = 'LC0029'; Action = 'Remove'; Levels = 'strict'; Stages = 'ci' } + $run.Result.Failure | Should-Be 'validation' + $run.Result.Annotations[0] | Should-Be '::error file=overrides.json,title=ChangeRule::overrides.json has no entry for LC0029 with levels [strict] and stages [ci]; existing entries for LC0029: None (levels: recommended, stages: ci)' + } + + It 'reports a no-op with a notice and exit code 0, without a token (AC6)' { + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'LC0029'; Action = 'None'; Levels = 'recommended'; Stages = 'ci'; Justification = 'Backlog DEV-1234' } + $run.Result.ExitCode | Should-Be 0 + $run.Result.NoOp | Should-BeTrue + @($run.Result.Annotations) | Should-BeCollection @('::notice title=ChangeRule::No change: LC0029 is already None on every matching endpoint (recommended.ci); overrides.json was not written') + $run.Output | Should-Be "result=no-op`nnoop=true`nchangedEndpoints=`npullRequestUrl=`nbranch=`nfailure=`n" + $run.Summary | Should-MatchString '(?m)^\| recommended\.ci \| None \(override, "Backlog DEV-1234"\) \| None \(override, "Backlog DEV-1234"\) \| unchanged \|$' + } + + It 'needs the token for a real change, after the plan' { + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'AA0001'; Action = 'None' } + $run.Result.ExitCode | Should-Be 1 + $run.Result.Failure | Should-Be 'token' + @($run.Result.Annotations) | Should-BeCollection @('::error title=ChangeRule::The GHTOKENWORKFLOW secret is needed to change a rule. Read https://github.com/ALCops/rulebook/blob/main/docs/ghtokenworkflow.md') + $run.Result.Plan.Valid | Should-BeTrue + } + + It 'fails validation on an invalid ghTokenWorkflowSecretName (the settings schema, before the token guard)' { + $root = Copy-Minimal + Edit-FixtureJson -Path (Join-Path $root '.github' 'Rulebook-Settings.json') -Script { $_.ghTokenWorkflowSecretName = 'github_token' } + $run = Invoke-Entry @{ RepositoryRoot = $root; WorkspaceRoot = $root; RuleId = 'AA0001'; Action = 'None'; Token = 'ghp_x' } + $run.Result.Failure | Should-Be 'validation' + @($run.Result.Annotations | Where-Object { $_ -like '::error file=.github/Rulebook-Settings.json,title=C5::The changed rulebook would not validate:*' }).Count | Should-BeGreaterThan 0 + } + + It 'masks the write token before any other output' { + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'AA0001'; Action = 'None'; Token = 'ghp_secret_value'; RemoteUrl = (Join-Path (Get-TestFolder) 'never.git') } + $run.Lines[0] | Should-Be '::add-mask::ghp_secret_value' + @($run.Lines | Where-Object { $_ -like '*ghp_secret_value*' }) | Should-BeCollection @('::add-mask::ghp_secret_value') + } + + It 'passes the plan to the publish seam and writes outputs and the summary with the effective diff' { + # A hashtable from this scope: the seam runs in a child scope of ChangeRule.ps1 and sees it. + $seen = @{} + $publish = { + param($BranchPrefix, $DirectCommit, $Labels, [Parameter(ValueFromRemainingArguments)][object[]]$Rest) + $null = $Rest + $seen['BranchPrefix'] = $BranchPrefix + $seen['Labels'] = $Labels + $seen['DirectCommit'] = $DirectCommit + $diff = @([pscustomobject]@{ Endpoint = 'strict.ci'; File = 'rulesets/strict.ci.ruleset.json'; Id = 'LC0015'; Before = 'Warning'; After = 'None'; BeforeSource = 'level:strict'; AfterSource = 'override'; BeforeDetail = $null; AfterDetail = $null; ListedBefore = $true; ListedAfter = $true; Change = 'action' }) + [pscustomobject]@{ Result = 'pull-request'; PullRequestUrl = 'https://github.com/Contoso/rulebook/pull/7'; Number = 7; Branch = 'change-rule/LC0015/261008091530'; Sha = 'a' * 40; Fallback = $false; Diff = $diff; DiffNote = $null; Body = 'body'; Title = 't' } + } + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'LC0015'; Action = 'None'; Levels = 'strict'; Stages = 'ci'; Token = 'ghp_x'; PublishCommand = $publish } + $run.Result.ExitCode | Should-Be 0 + $seen.BranchPrefix | Should-Be 'change-rule/LC0015' + $seen.Labels | Should-BeCollection @('rulebook') + $seen.DirectCommit | Should-BeFalse + $run.Result.Annotations[-1] | Should-Be '::notice title=ChangeRule::Pull request: https://github.com/Contoso/rulebook/pull/7' + $run.Output | Should-Be "result=pull-request`nnoop=false`nchangedEndpoints=strict.ci`npullRequestUrl=https://github.com/Contoso/rulebook/pull/7`nbranch=change-rule/LC0015/261008091530`nfailure=`n" + $run.Summary | Should-MatchString '(?m)^## Effective diff\n\n### `strict\.ci` \(`rulesets/strict\.ci\.ruleset\.json`\)$' + } + + It 'maps a pull request failure of the seam to failure pull-request with the token hint' { + $publish = { + param([Parameter(ValueFromRemainingArguments)][object[]]$Ignored) + $null = $Ignored + $exception = [System.InvalidOperationException]::new('Branch change-rule/LC0015/261008091530 was pushed. Could not create the pull request (HTTP 422).') + $exception.Data['Stage'] = 'pull-request' + throw $exception + } + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'LC0015'; Action = 'None'; Levels = 'strict'; Stages = 'ci'; Token = 'ghp_x'; PublishCommand = $publish } + $run.Result.Failure | Should-Be 'pull-request' + $run.Result.Annotations[-1] | Should-BeLikeString '::error title=ChangeRule::Failed to create the pull request for the rule change. Make sure that the token in the secret GHTOKENWORKFLOW*Branch change-rule/LC0015/261008091530 was pushed*' + } + + It 'reports a base branch that moved without the token hint' { + $publish = { + param([Parameter(ValueFromRemainingArguments)][object[]]$Ignored) + $null = $Ignored + $exception = [System.InvalidOperationException]::new('The base branch moved since the change was planned (main aaaaaaa is now bbbbbbb); nothing was pushed. Run the workflow again.') + $exception.Data['Stage'] = 'push' + $exception.Data['Reason'] = 'base-moved' + throw $exception + } + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'LC0015'; Action = 'None'; Levels = 'strict'; Stages = 'ci'; Token = 'ghp_x'; PublishCommand = $publish } + $run.Result.Failure | Should-Be 'push' + $run.Result.Annotations[-1] | Should-Be '::error title=ChangeRule::The base branch moved since the change was planned (main aaaaaaa is now bbbbbbb); nothing was pushed. Run the workflow again.' + } + + It 'reports failure push with the token hint when the clone fails' { + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'LC0015'; Action = 'None'; Levels = 'strict'; Stages = 'ci'; Token = 'ghp_x'; RemoteUrl = (Join-Path (Get-TestFolder) 'missing.git') } + $run.Result.Failure | Should-Be 'push' + $run.Result.Annotations[-1] | Should-BeLikeString "::error title=ChangeRule::Failed to push the rule change. Make sure that the token in the secret GHTOKENWORKFLOW is not expired*Could not clone branch 'main'*" + } + + It 'leaves a work folder the caller passed in place' { + $work = Join-Path (Get-TestFolder) 'work' + $null = New-Item -ItemType Directory -Path $work + Write-FixtureText -Path (Join-Path $work 'keep.txt') -Text 'mine' + $null = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'AA0001'; Action = 'None'; WorkPath = $work } + Test-Path -LiteralPath (Join-Path $work 'keep.txt') -PathType Leaf | Should-BeTrue + } + + It 'removes the work folder it names itself' { + $runnerTemp = Get-TestFolder + $null = New-Item -ItemType Directory -Path $runnerTemp + $saved = $env:RUNNER_TEMP + $env:RUNNER_TEMP = $runnerTemp + try { + $null = & $script:entry -RepositoryRoot (Copy-Minimal) -RuleId 'AA0001' -Action 'None' -SummaryPath "$(Get-TestFolder).summary.md" 6>$null + } finally { + $env:RUNNER_TEMP = $saved + } + @(Get-ChildItem -LiteralPath $runnerTemp -Force) | Should-BeCollection @() + } + + Context 'against a bare remote' -Skip:$gitMissing { + BeforeAll { + function New-Origin { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper; writes only to TestDrive')] + param([switch]$Reject) + $bare = New-BareFixtureRepo -Source $minimal -Destination (Join-Path (Get-TestFolder) 'origin.git') + if ($Reject) { Add-RejectPushHook -BarePath $bare -Branch 'main' } + return $bare + } + } + + It 'pushes a direct commit to the base branch' { + $bare = New-Origin + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'LC0015'; Action = 'None'; Levels = 'strict'; Stages = 'ci'; Token = 'ghp_x'; DirectCommit = $true; RemoteUrl = $bare } + $run.Result.ExitCode | Should-Be 0 + $run.Result.Result | Should-Be 'direct-commit' + $sha = (& git -C $bare rev-parse refs/heads/main).Trim() + $run.Result.Annotations[-1] | Should-Be "::notice title=ChangeRule::Rule change committed to main ($($sha.Substring(0, 7)))" + (& git -C $bare log -1 --format=%s main) | Should-Be 'Change LC0015 to None (levels: strict, stages: ci)' + } + + It 'pushes the change branch and names it when the pull request cannot be opened' { + $bare = New-Origin + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'LC0015'; Action = 'None'; Levels = 'strict'; Stages = 'ci'; Token = 'ghp_x'; RemoteUrl = $bare } + $run.Result.Failure | Should-Be 'pull-request' + (& git -C $bare rev-parse --verify --quiet refs/heads/change-rule/LC0015/261008091530) | Should-NotBeNull + $run.Result.Annotations[-1] | Should-BeLikeString '*Branch change-rule/LC0015/261008091530 was pushed*' + } + + It 'falls back to the change branch when the direct push is refused' { + $bare = New-Origin -Reject + $originSha = (& git -C $bare rev-parse refs/heads/main).Trim() + $run = Invoke-Entry @{ RepositoryRoot = (Copy-Minimal); RuleId = 'LC0015'; Action = 'None'; Levels = 'strict'; Stages = 'ci'; Token = 'ghp_x'; DirectCommit = $true; RemoteUrl = $bare } + (& git -C $bare rev-parse refs/heads/main).Trim() | Should-Be $originSha + (& git -C $bare rev-parse --verify --quiet refs/heads/change-rule/LC0015/261008091530) | Should-NotBeNull + # The pull request call goes nowhere in the test; the branch is named for opening it by hand. + $run.Result.Failure | Should-Be 'pull-request' + } + } +} diff --git a/tests/Rulebook.Edit.Tests.ps1 b/tests/Rulebook.Edit.Tests.ps1 new file mode 100644 index 0000000..64df49c --- /dev/null +++ b/tests/Rulebook.Edit.Tests.ps1 @@ -0,0 +1,436 @@ +# Edit suite for WP09 (#11): modules/Rulebook.Edit on copies of tests/fixtures/repos/valid-minimal in TestDrive +# (docs/reference/change-mechanics.md). The GitHub API is mocked at Invoke-GitHubApi; the git side of +# Publish-RulebookChange runs against a bare repository. + +BeforeDiscovery { + $script:gitMissing = $null -eq (Get-Command git -ErrorAction SilentlyContinue) +} + +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + . (Join-Path $PSScriptRoot 'Helpers' 'RepoFixture.ps1') + Import-Module (Join-Path $repoRoot 'modules' 'Rulebook.Generate.psd1') -Force + Import-Module (Join-Path $repoRoot 'modules' 'Rulebook.GitHub.psd1') -Force + Import-Module (Join-Path $repoRoot 'modules' 'Rulebook.Edit.psd1') -Force + $script:minimal = Join-Path $PSScriptRoot 'fixtures' 'repos' 'valid-minimal' + $script:utf8 = [System.Text.UTF8Encoding]::new($false) + $script:savedApiUrl = $env:GITHUB_API_URL + $script:savedServerUrl = $env:GITHUB_SERVER_URL + $env:GITHUB_API_URL = $null + $env:GITHUB_SERVER_URL = $null + + function Get-TestFolder { + return Join-Path $TestDrive ([guid]::NewGuid().ToString('n').Substring(0, 12)) + } + + function Copy-Minimal { + return New-FixtureRepo -Name 'valid-minimal' -Destination (Get-TestFolder) + } + + function Get-TreeHash { + # '=' for every file under Root, to prove a folder was not touched. + param([string]$Root) + return @(Get-ChildItem -LiteralPath $Root -Recurse -File -Force | Sort-Object FullName | ForEach-Object { + '{0}={1}' -f [System.IO.Path]::GetRelativePath($Root, $_.FullName).Replace('\', '/'), (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + }) + } + + function Invoke-Change { + # Plans one form change on Root: Invoke-RulebookChangeSet with a work folder in TestDrive. + param([string]$Root, [string]$RuleId, [string]$Action, [string[]]$Levels = @('*'), [string[]]$Stages = @('*'), [string]$Justification) + $set = ConvertTo-RulebookChangeSet -RuleId $RuleId -Action $Action -Levels $Levels -Stages $Stages -Justification $Justification + return Invoke-RulebookChangeSet -RepositoryRoot $Root -ChangeSet $set -WorkPath (Get-TestFolder) -Now ([System.DateTimeOffset]::new(2026, 10, 8, 9, 0, 0, [System.TimeSpan]::Zero)) + } + + function Get-EmptyFile { + return Read-OverridesFile -Path (Join-Path (Get-TestFolder) 'overrides.json') + } +} + +AfterAll { + $env:GITHUB_API_URL = $script:savedApiUrl + $env:GITHUB_SERVER_URL = $script:savedServerUrl + Remove-Module Rulebook.Edit, Rulebook.Update, Rulebook.Template, Rulebook.GitHub, Rulebook.Validate, Rulebook.Generate, Rulebook.Action -ErrorAction SilentlyContinue +} + +Describe 'overrides.json I/O' { + It 'round-trips byte for byte' -ForEach @( + @{ Name = 'template/overrides.json'; Path = (Join-Path (Split-Path -Parent $PSScriptRoot) 'template' 'overrides.json') } + @{ Name = 'valid-minimal/overrides.json'; Path = (Join-Path $PSScriptRoot 'fixtures' 'repos' 'valid-minimal' 'overrides.json') } + ) { + $file = Read-OverridesFile -Path $Path + $file.Exists | Should-BeTrue + ConvertTo-OverridesJson -File $file | Should-Be ([System.IO.File]::ReadAllText($Path, $utf8)) + } + + It 'reads a missing file as empty, with the schema URL' { + $file = Get-EmptyFile + $file.Exists | Should-BeFalse + $file.Rules.Count | Should-Be 0 + $file.Schema | Should-Be 'https://raw.githubusercontent.com/ALCops/rulebook-engine/v1/schemas/rulebook-overrides.schema.json' + } + + It 'keeps a justification that looks like a date as text' { + $path = Join-Path (Get-TestFolder) 'overrides.json' + Write-FixtureText -Path $path -Text "{`n `"rules`": [`n { `"id`": `"LC0015`", `"action`": `"None`", `"levels`": [`"*`"], `"stages`": [`"ci`"], `"justification`": `"2026-10-03T10:00:00`" }`n ]`n}" + (Read-OverridesFile -Path $path).Rules[0].Justification | Should-Be '2026-10-03T10:00:00' + } + + It 'writes only on a byte change, and -WhatIf reports without writing' { + $root = Copy-Minimal + $path = Join-Path $root 'overrides.json' + $file = Read-OverridesFile -Path $path + Write-OverridesFile -Path $path -File $file | Should-BeNull + $null = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' + $before = [System.IO.File]::ReadAllText($path) + $planned = Write-OverridesFile -Path $path -File $file -WhatIf + $planned.Change | Should-Be 'modified' + [System.IO.File]::ReadAllText($path) | Should-Be $before + (Write-OverridesFile -Path $path -File $file).Change | Should-Be 'modified' + [System.IO.File]::ReadAllText($path) | Should-Be (ConvertTo-OverridesJson -File $file) + } +} + +Describe 'Set-RulebookOverride and Remove-RulebookOverride' { + It 'sets an entry on an empty rules array' { + $file = Get-EmptyFile + $result = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' -Justification 'Legacy tables' + $result.Change | Should-Be 'added' + ConvertTo-OverridesJson -File $file | Should-Be "{`n `"`$schema`": `"https://raw.githubusercontent.com/ALCops/rulebook-engine/v1/schemas/rulebook-overrides.schema.json`",`n `"rules`": [`n { `"id`": `"LC0015`", `"action`": `"None`", `"levels`": [`"strict`"], `"stages`": [`"ci`"], `"justification`": `"Legacy tables`" }`n ]`n}`n" + } + + It 'replaces action and justification of an entry with the same selectors in place (order-insensitive)' { + $file = Get-EmptyFile + $null = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels 'strict', 'complete' -Stages 'ci' + $null = Set-RulebookOverride -File $file -Id 'AA0001' -Action 'Info' -Levels '*' -Stages '*' + $result = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'Warning' -Levels 'complete', 'strict' -Stages 'ci' -Justification 'Why' + $result.Change | Should-Be 'replaced' + $result.Previous.Action | Should-Be 'None' + @($file.Rules | ForEach-Object Id) | Should-BeCollection @('LC0015', 'AA0001') + $file.Rules[0].Action | Should-Be 'Warning' + $file.Rules[0].Justification | Should-Be 'Why' + # The selector order of the file is kept. + $file.Rules[0].Levels | Should-BeCollection @('strict', 'complete') + } + + It 'drops the justification key when the new one is empty' { + $file = Get-EmptyFile + $null = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' -Justification 'Old' + (Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci').Change | Should-Be 'replaced' + ConvertTo-OverridesJson -File $file | Should-NotMatchString 'justification' + } + + It 'reports unchanged for the same action and justification' { + $file = Get-EmptyFile + $null = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' -Justification 'Same' + (Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' -Justification 'Same').Change | Should-Be 'unchanged' + $file.Rules.Count | Should-Be 1 + } + + It 'appends an entry with different selectors' { + $file = Get-EmptyFile + $null = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' + (Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels '*' -Stages 'ci').Change | Should-Be 'added' + $file.Rules.Count | Should-Be 2 + } + + It 'rejects ' -ForEach @( + @{ Name = 'an action outside the five'; Action = 'Default'; Levels = @('strict'); Message = "*action 'Default'*" } + @{ Name = 'an empty selector'; Action = 'None'; Levels = @(); Message = '*levels is empty*' } + @{ Name = "'*' mixed with slugs"; Action = 'None'; Levels = @('*', 'strict'); Message = "*levels mixes '*'*" } + ) { + $file = Get-EmptyFile + { Set-RulebookOverride -File $file -Id 'LC0015' -Action $Action -Levels $Levels -Stages 'ci' } | Should-Throw -ExceptionMessage $Message + } + + It 'removes the entry and leaves the file otherwise byte-identical (AC4)' { + $root = Copy-Minimal + $path = Join-Path $root 'overrides.json' + $file = Read-OverridesFile -Path $path + $result = Remove-RulebookOverride -File $file -Id 'LC0029' -Levels 'recommended' -Stages 'ci' + $result.Change | Should-Be 'removed' + $result.Entry.Justification | Should-Be 'Backlog DEV-1234' + $null = Write-OverridesFile -Path $path -File $file + $expected = [System.IO.File]::ReadAllText((Join-Path $minimal 'overrides.json')).Replace(",`n { `"id`": `"LC0029`", `"action`": `"None`", `"levels`": [`"recommended`"], `"stages`": [`"ci`"], `"justification`": `"Backlog DEV-1234`" }", '') + [System.IO.File]::ReadAllText($path) | Should-Be $expected + $expected | Should-MatchString '\A\{\n "\$schema": .*\n "rules": \[\n \{ "id": "AA0072", .* \}\n \]\n\}\n\z' + } + + It 'lists the entries of the id when nothing matches the selectors' { + $file = Get-EmptyFile + $null = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'None' -Levels '*' -Stages 'ci' + $null = Set-RulebookOverride -File $file -Id 'LC0015' -Action 'Info' -Levels 'strict' -Stages '*' + $thrown = $null + try { $null = Remove-RulebookOverride -File $file -Id 'LC0015' -Levels 'strict' -Stages 'ci' } catch { $thrown = $_.Exception } + $thrown.Message | Should-Be 'overrides.json has no entry for LC0015 with levels [strict] and stages [ci]; existing entries for LC0015: None (levels: *, stages: ci), Info (levels: strict, stages: *)' + $thrown.Data['Stage'] | Should-Be 'validation' + { Remove-RulebookOverride -File $file -Id 'AA0001' -Levels '*' -Stages '*' } | Should-Throw -ExceptionMessage 'overrides.json has no entry for AA0001' + } +} + +Describe 'ConvertTo-RulebookChangeSet and Test-RulebookChangeSet' { + BeforeAll { + $script:inputs = Read-RulebookInputs -RepositoryRoot $minimal + } + + It 'builds a one-item set; Remove becomes op remove without an action' { + $set = ConvertTo-RulebookChangeSet -RuleId 'LC0015' -Action 'none' -Levels 'strict' -Stages 'ci' -Justification ' Legacy ' + $set['changes'].Count | Should-Be 1 + $set['changes'][0]['op'] | Should-Be 'set' + $set['changes'][0]['action'] | Should-Be 'None' + $set['changes'][0]['justification'] | Should-Be 'Legacy' + $remove = ConvertTo-RulebookChangeSet -RuleId 'LC0015' -Action 'Remove' -Levels '*' -Stages '*' + $remove['changes'][0]['op'] | Should-Be 'remove' + $remove['changes'][0].Contains('action') | Should-BeFalse + } + + It 'accepts a valid set' { + @(Test-RulebookChangeSet -ChangeSet (ConvertTo-RulebookChangeSet -RuleId 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci') -Inputs $inputs) | Should-BeCollection @() + } + + It 'reports ' -ForEach @( + @{ Name = 'a bad id'; Set = @{ changes = @(@{ op = 'set'; id = 'lc15'; action = 'None'; levels = @('*'); stages = @('*') }) }; Message = "'lc15' is not a diagnostic id*" } + @{ Name = 'an id outside the catalog'; Set = @{ changes = @(@{ op = 'set'; id = 'LC9999'; action = 'None'; levels = @('*'); stages = @('*') }) }; Message = 'LC9999 is not in catalog/diagnostics.json' } + @{ Name = 'a bad action'; Set = @{ changes = @(@{ op = 'set'; id = 'LC0015'; action = 'Default'; levels = @('*'); stages = @('*') }) }; Message = "action 'Default': use Error, Warning, Info, Hidden or None" } + @{ Name = 'an unknown slug (C10 wording)'; Set = @{ changes = @(@{ op = 'set'; id = 'LC0015'; action = 'None'; levels = @('house'); stages = @('*') }) }; Message = "change 0 names unknown level 'house'; use a slug from the settings or *" } + @{ Name = 'the reserved release op'; Set = @{ changes = @(@{ op = 'release'; id = 'LC0015'; levels = @('*'); stages = @('*') }) }; Message = "*op 'release' is reserved; it arrives with WP15" } + @{ Name = 'an empty change list'; Set = @{ changes = @() }; Message = 'The change set has no changes.' } + @{ Name = 'a duplicate'; Set = @{ changes = @(@{ op = 'set'; id = 'LC0015'; action = 'None'; levels = @('strict', 'complete'); stages = @('ci') }, @{ op = 'set'; id = 'LC0015'; action = 'Info'; levels = @('complete', 'strict'); stages = @('ci') }) }; Message = 'change 1 repeats an earlier change*' } + ) { + $findings = @(Test-RulebookChangeSet -ChangeSet $Set -Inputs $inputs) + $findings.Count | Should-Be 1 + $findings[0].Rule | Should-Be 'change' + $findings[0].File | Should-Be 'overrides.json' + $findings[0].Message | Should-BeLikeString $Message + } +} + +Describe 'Invoke-RulebookChangeSet' { + It 'regenerates exactly one endpoint for strict/ci (AC1)' { + $root = Copy-Minimal + $before = Get-TreeHash -Root $root + $plan = Invoke-Change -Root $root -RuleId 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' + $plan.Valid | Should-BeTrue + $plan.NoOp | Should-BeFalse + @($plan.Changes | ForEach-Object File) | Should-BeCollection @('overrides.json', 'rulesets/strict.ci.ruleset.json') + $plan.Items[0].Entry.Change | Should-Be 'added' + $plan.Items[0].ChangedEndpoints | Should-BeCollection @('strict.ci') + $row = $plan.Items[0].Rows[0] + $row.Before | Should-Be 'Warning' + $row.BeforeSource | Should-Be 'level:strict' + $row.After | Should-Be 'None' + $row.AfterSource | Should-Be 'override' + $plan.Title | Should-Be 'Change LC0015 to None (levels: strict, stages: ci)' + # The repository itself is never written. + Get-TreeHash -Root $root | Should-BeCollection $before + } + + It 'changes all 12 endpoints with * and * and validates (AC2)' { + $plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC0015' -Action 'Error' + $plan.Valid | Should-BeTrue + $plan.Items[0].Rows.Count | Should-Be 12 + $plan.Items[0].ChangedEndpoints.Count | Should-Be 12 + @($plan.Changes | Where-Object { $_.File -like 'rulesets/*' }).Count | Should-Be 12 + } + + It 'unlists an id set to its analyzer default where the base deviates, and says so (AC3)' { + $plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'AA0001' -Action 'Warning' -Levels 'essential' -Stages 'default' + $row = $plan.Items[0].Rows[0] + $row.Changed | Should-BeTrue + $row.ListedBefore | Should-BeTrue + $row.ListedAfter | Should-BeFalse + $row.Note | Should-Be 'now unlisted in essential.default: Warning equals the analyzer default' + $endpoint = [System.IO.File]::ReadAllText((Join-Path $plan.CandidatePath 'rulesets' 'essential.ruleset.json')) + $endpoint | Should-NotMatchString '"AA0001"' + ConvertTo-ChangePullRequestBody -Plan $plan | Should-MatchString '(?m)^- AA0001 now unlisted in essential\.default: Warning equals the analyzer default\.$' + } + + It 'is a no-op when nothing changes: nothing written, Changes empty (AC6)' { + $plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'AA0072' -Action 'Info' -Justification 'House style' + $plan.NoOp | Should-BeTrue + $plan.Valid | Should-BeTrue + $plan.OverridesChange | Should-BeNull + @($plan.Changes) | Should-BeCollection @() + @($plan.Items[0].Rows | Where-Object Note -NE 'unchanged') | Should-BeCollection @() + [System.IO.File]::ReadAllText((Join-Path $plan.CandidatePath 'overrides.json')) | Should-Be ([System.IO.File]::ReadAllText((Join-Path $minimal 'overrides.json'))) + } + + It 'writes a justification-only edit, rows unchanged with the note, no ruleset change' { + $plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'AA0072' -Action 'Info' -Justification 'Team decision' + $plan.NoOp | Should-BeFalse + $plan.Valid | Should-BeTrue + @($plan.Changes | ForEach-Object File) | Should-BeCollection @('overrides.json') + @($plan.Items[0].Rows | ForEach-Object Note | Select-Object -Unique) | Should-BeCollection @('justification updated') + ConvertTo-ChangePullRequestBody -Plan $plan | Should-MatchString '(?m)^Updates the justification of the AA0072 entry for levels \*, stages \* \(the action stays Info\)\.$' + } + + It 'writes a partial change with the unchanged rows marked' { + $plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'AA0001' -Action 'None' + $plan.NoOp | Should-BeFalse + @($plan.Items[0].Rows | Where-Object { $_.Endpoint -like 'essential.*' } | ForEach-Object Note | Select-Object -Unique) | Should-BeCollection @('unchanged') + $plan.Items[0].ChangedEndpoints.Count | Should-Be 9 + ConvertTo-ChangePullRequestBody -Plan $plan | Should-MatchString '(?m)^- 3 of 12 matching endpoints are unchanged\.$' + } + + It 'expands * in settings order' { + $plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC0015' -Action 'Error' + @($plan.Items[0].Rows | ForEach-Object Endpoint | Select-Object -First 4) | Should-BeCollection @('essential.default', 'essential.ci', 'essential.vnext', 'recommended.default') + } + + It 'stops an invalid id before anything is copied, the repository untouched (AC5)' { + $root = Copy-Minimal + $before = Get-TreeHash -Root $root + $work = Get-TestFolder + $set = ConvertTo-RulebookChangeSet -RuleId 'LC9999' -Action 'Warning' -Levels '*' -Stages '*' + $plan = Invoke-RulebookChangeSet -RepositoryRoot $root -ChangeSet $set -WorkPath $work + $plan.Failure | Should-Be 'validation' + $plan.Valid | Should-BeFalse + $plan.CandidatePath | Should-BeNull + Test-Path -LiteralPath (Join-Path $work 'candidate') | Should-BeFalse + Get-TreeHash -Root $root | Should-BeCollection $before + } + + It 'fails validation on a remove without a matching entry' { + $plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'AA0001' -Action 'Remove' -Levels 'strict' -Stages 'ci' + $plan.Failure | Should-Be 'validation' + $plan.Findings[0].Message | Should-Be 'overrides.json has no entry for AA0001' + } + + It 'removes an entry: never a no-op, the endpoint regenerated' { + $plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC0029' -Action 'Remove' -Levels 'recommended' -Stages 'ci' + $plan.Valid | Should-BeTrue + $plan.NoOp | Should-BeFalse + $plan.Title | Should-Be 'Remove override for LC0029 (levels: recommended, stages: ci)' + @($plan.Changes | ForEach-Object File) | Should-BeCollection @('overrides.json', 'rulesets/recommended.ci.ruleset.json') + } +} + +Describe 'Rendering' { + BeforeAll { + $script:plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' -Justification 'Legacy tables | tracked in issue 42' + } + + It 'renders the table with provenance and escaped free text' { + $table = ConvertTo-ChangeTable -Rows $plan.Items[0].Rows + $table | Should-Be "| Endpoint | Before | After | Note |`n|---|---|---|---|`n| strict.ci | Warning (level:strict) | None (override, `"Legacy tables \| tracked in issue 42`") | |`n" + } + + It 'opens the body with the justification and one sentence per item' { + $body = ConvertTo-ChangePullRequestBody -Plan $plan + $body | Should-MatchString '\AJustification: Legacy tables \| tracked in issue 42\n\nSets LC0015 to None for levels strict, stages ci \(adds an entry\)\.\n\n\| Endpoint \| Before \| After \| Note \|\n' + $body | Should-MatchString '[^\n]\n\z' + } + + It 'says when no justification is given' { + $bare = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' + ConvertTo-ChangePullRequestBody -Plan $bare | Should-MatchString '\ANo justification given\.\n' + } + + It 'leaves the table out above the limit, with a line saying so' { + $wide = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC0015' -Action 'Error' + $body = ConvertTo-ChangePullRequestBody -Plan $wide -Limit 500 + $body | Should-NotMatchString '\| Endpoint \|' + $body | Should-MatchString '_1 of 1 change tables were left out to keep this body below the GitHub limit; the job summary of the change run has them all\._\n\z' + } + + It 'writes the summary with the result, the table and the effective diff' { + $diff = @([pscustomobject]@{ Endpoint = 'strict.ci'; File = 'rulesets/strict.ci.ruleset.json'; Id = 'LC0015'; Before = 'Warning'; After = 'None'; BeforeSource = 'level:strict'; AfterSource = 'override'; BeforeDetail = $null; AfterDetail = 'Legacy'; ListedBefore = $true; ListedAfter = $true; Change = 'action' }) + $result = [pscustomobject]@{ Result = 'pull-request'; PullRequestUrl = 'https://github.com/Contoso/rulebook/pull/7'; Branch = 'change-rule/LC0015/261008090000'; Sha = 'a' * 40; Fallback = $false; Diff = $diff; DiffNote = $null } + $summary = ConvertTo-ChangeSummary -Plan $plan -Result $result -Message 'Pull request: https://github.com/Contoso/rulebook/pull/7' + $summary | Should-MatchString '\A## Rule change\n\nPull request: https://github\.com/Contoso/rulebook/pull/7\n\nPull request: https://github\.com/Contoso/rulebook/pull/7 \(branch `change-rule/LC0015/261008090000`\)\n' + $summary | Should-MatchString '(?m)^\| strict\.ci \| Warning \(level:strict\) \|' + $summary | Should-MatchString '(?m)^## Effective diff\n\n### `strict\.ci` \(`rulesets/strict\.ci\.ruleset\.json`\)$' + ConvertTo-ChangePullRequestBody -Plan $plan | Should-NotMatchString 'Effective diff' + } + + It 'lists validation errors in the summary' { + $invalid = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC9999' -Action 'None' + ConvertTo-ChangeSummary -Plan $invalid -Message 'x' | Should-MatchString '(?m)^\| change \| `overrides\.json` \| LC9999 \| LC9999 is not in catalog/diagnostics\.json \|$' + } +} + +Describe 'Publish-RulebookChange against a bare repository' -Skip:$gitMissing { + BeforeAll { + $script:now = [System.DateTimeOffset]::new(2026, 10, 8, 9, 15, 30, [System.TimeSpan]::Zero) + function New-Origin { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper; writes only to TestDrive')] + param([switch]$Reject) + $bare = New-BareFixtureRepo -Source $minimal -Destination (Join-Path (Get-TestFolder) 'origin.git') + if ($Reject) { Add-RejectPushHook -BarePath $bare -Branch 'main' } + return $bare + } + } + + BeforeEach { + Mock Invoke-GitHubApi -ModuleName Rulebook.GitHub -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/Contoso/rulebook/pulls' } { + [pscustomobject]@{ StatusCode = 201; Body = @{ number = 7; html_url = 'https://github.com/Contoso/rulebook/pull/7' }; Text = ''; Headers = $null; RateLimitRemaining = $null } + } + Mock Invoke-GitHubApi -ModuleName Rulebook.GitHub -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/Contoso/rulebook/issues/7/labels' } { + [pscustomobject]@{ StatusCode = 200; Body = @(); Text = '[]'; Headers = $null; RateLimitRemaining = $null } + } + $script:plan = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC0015' -Action 'None' -Levels 'strict' -Stages 'ci' -Justification 'Legacy tables' + } + + It 'pushes change-rule// and opens the labelled pull request with the body' { + $bare = New-Origin + $originSha = (& git -C $bare rev-parse refs/heads/main).Trim() + $result = Publish-RulebookChange -Plan $plan -Repository 'Contoso/rulebook' -RemoteUrl $bare -Token 'ghs_x' -BaseBranch 'main' -BranchPrefix 'change-rule/LC0015' -Actor 'octocat' -Labels @('rulebook') -WorkPath (Get-TestFolder) -Now $now + $result.Result | Should-Be 'pull-request' + $result.Branch | Should-Be 'change-rule/LC0015/261008091530' + $result.Number | Should-Be 7 + $result.PullRequestUrl | Should-Be 'https://github.com/Contoso/rulebook/pull/7' + (& git -C $bare log -1 --format=%s $result.Branch) | Should-Be 'Change LC0015 to None (levels: strict, stages: ci)' + (& git -C $bare rev-parse refs/heads/main).Trim() | Should-Be $originSha + @(& git -C $bare diff --name-only "main..$($result.Branch)") | Should-BeCollection @('overrides.json', 'rulesets/strict.ci.ruleset.json') + @($result.Diff | ForEach-Object Endpoint) | Should-BeCollection @('strict.ci') + $result.Body | Should-MatchString '\AJustification: Legacy tables\n' + Should-Invoke Invoke-GitHubApi -ModuleName Rulebook.GitHub -Times 1 -Exactly -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/Contoso/rulebook/pulls' -and $Body.title -eq 'Change LC0015 to None (levels: strict, stages: ci)' -and $Body.head -eq 'change-rule/LC0015/261008091530' -and $Body.base -eq 'main' } + Should-Invoke Invoke-GitHubApi -ModuleName Rulebook.GitHub -Times 1 -Exactly -ParameterFilter { $Path -eq 'repos/Contoso/rulebook/issues/7/labels' -and (@($Body.labels) -join ',') -eq 'rulebook' } + } + + It 'pushes a direct commit to the base branch' { + $bare = New-Origin + $result = Publish-RulebookChange -Plan $plan -Repository 'Contoso/rulebook' -RemoteUrl $bare -Token 'ghs_x' -BaseBranch 'main' -BranchPrefix 'change-rule/LC0015' -DirectCommit -WorkPath (Get-TestFolder) -Now $now + $result.Result | Should-Be 'direct-commit' + $result.Branch | Should-Be 'main' + (& git -C $bare rev-parse refs/heads/main).Trim() | Should-Be $result.Sha + Should-Invoke Invoke-GitHubApi -ModuleName Rulebook.GitHub -Times 0 -Exactly + } + + It 'falls back to the pull request when the direct push is refused' { + $bare = New-Origin -Reject + $result = Publish-RulebookChange -Plan $plan -Repository 'Contoso/rulebook' -RemoteUrl $bare -Token 'ghs_x' -BaseBranch 'main' -BranchPrefix 'change-rule/LC0015' -DirectCommit -WorkPath (Get-TestFolder) -Now $now 3>$null + $result.Result | Should-Be 'pull-request' + $result.Fallback | Should-BeTrue + $result.Branch | Should-Be 'change-rule/LC0015/261008091530' + } + + It 'refuses a base branch that moved since the plan' { + $bare = New-Origin + $plan.HeadSha = 'f' * 40 + $thrown = $null + try { $null = Publish-RulebookChange -Plan $plan -Repository 'Contoso/rulebook' -RemoteUrl $bare -Token 'ghs_x' -BaseBranch 'main' -BranchPrefix 'change-rule/LC0015' -WorkPath (Get-TestFolder) -Now $now } catch { $thrown = $_.Exception } + $thrown.Data['Stage'] | Should-Be 'push' + $thrown.Data['Reason'] | Should-Be 'base-moved' + $thrown.Message | Should-BeLikeString 'The base branch moved since the change was planned (main ffffff* is now *); nothing was pushed. Run the workflow again.' + } + + It 'names the pushed branch when the pull request cannot be opened' { + $bare = New-Origin + Mock Invoke-GitHubApi -ModuleName Rulebook.GitHub -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'repos/Contoso/rulebook/pulls' } { + [pscustomobject]@{ StatusCode = 422; Body = @{ message = 'Validation Failed' }; Text = ''; Headers = $null; RateLimitRemaining = $null } + } + $thrown = $null + try { $null = Publish-RulebookChange -Plan $plan -Repository 'Contoso/rulebook' -RemoteUrl $bare -Token 'ghs_x' -BaseBranch 'main' -BranchPrefix 'change-rule/LC0015' -WorkPath (Get-TestFolder) -Now $now } catch { $thrown = $_.Exception } + $thrown.Data['Stage'] | Should-Be 'pull-request' + $thrown.Data['Branch'] | Should-Be 'change-rule/LC0015/261008091530' + $thrown.Message | Should-BeLikeString 'Branch change-rule/LC0015/261008091530 was pushed.*' + } + + It 'refuses an invalid or a no-op plan' { + $invalid = Invoke-Change -Root (Copy-Minimal) -RuleId 'LC9999' -Action 'None' + { Publish-RulebookChange -Plan $invalid -Repository 'Contoso/rulebook' -BaseBranch 'main' -BranchPrefix 'change-rule/LC9999' } | Should-Throw -ExceptionMessage 'The change plan does not validate; nothing is pushed.' + $noop = Invoke-Change -Root (Copy-Minimal) -RuleId 'AA0072' -Action 'Info' -Justification 'House style' + { Publish-RulebookChange -Plan $noop -Repository 'Contoso/rulebook' -BaseBranch 'main' -BranchPrefix 'change-rule/AA0072' } | Should-Throw -ExceptionMessage 'The change is a no-op; nothing is pushed.' + } +} diff --git a/tests/Rulebook.Template.Tests.ps1 b/tests/Rulebook.Template.Tests.ps1 index 932894f..a3e72f3 100644 --- a/tests/Rulebook.Template.Tests.ps1 +++ b/tests/Rulebook.Template.Tests.ps1 @@ -548,6 +548,7 @@ Describe 'Build-Template.ps1' { $script:wrapper = Join-Path $repoRoot 'tools' 'rulebook' 'Build-Template.ps1' $script:handWritten = @( '.github/Rulebook-Settings.json' + '.github/workflows/ChangeRule.yaml' '.github/workflows/Publish.yaml' '.github/workflows/ScanDiagnostics.yaml' '.github/workflows/UpdateRulebookSystemFiles.yaml' @@ -565,7 +566,7 @@ Describe 'Build-Template.ps1' { @(& $wrapper -WhatIf 6>$null) | Should-BeCollection @() } - It 'regenerates template/ byte for byte from the 11 hand-written files' { + It 'regenerates template/ byte for byte from the 12 hand-written files' { $scratch = Get-TestFolder foreach ($file in $handWritten) { $target = Join-Path $scratch $file diff --git a/tests/Rulebook.Update.Tests.ps1 b/tests/Rulebook.Update.Tests.ps1 index b57eba2..a7594d4 100644 --- a/tests/Rulebook.Update.Tests.ps1 +++ b/tests/Rulebook.Update.Tests.ps1 @@ -297,6 +297,15 @@ Describe 'ConvertTo-UpdatedWorkflowText' { $result.Replace(" - house`n", '') | Should-Be $text } + It 'rewrites the shipped ChangeRule.yaml: a no-op with the template settings, house in settings order with it (WP09)' { + $text = [System.IO.File]::ReadAllText((Join-Path $repoRoot 'template' '.github' 'workflows' 'ChangeRule.yaml')).Replace("`r`n", "`n") + $templateSettings = Get-Content -LiteralPath (Join-Path $repoRoot 'template' '.github' 'Rulebook-Settings.json') -Raw | ConvertFrom-Json -AsHashtable + ConvertTo-UpdatedWorkflowText -Text $text -FileName 'ChangeRule.yaml' -Settings $templateSettings -TemplateUrl $url | Should-Be $text + $house = @{ levels = @(@{ name = 'Essential' }, @{ name = 'Recommended' }, @{ name = 'House' }, @{ name = 'Strict' }, @{ name = 'Complete' }); stages = $templateSettings.stages } + $result = ConvertTo-UpdatedWorkflowText -Text $text -FileName 'ChangeRule.yaml' -Settings $house -TemplateUrl $url + $result | Should-Be $text.Replace(" - recommended`n", " - recommended`n - house`n") + } + It 'quotes a slug YAML would read as another type' { $text = [System.IO.File]::ReadAllText((Join-Path $v1 '.github' 'workflows' 'ChangeRule.yaml')) $settings = @{ levels = @(@{ name = 'Yes' }, @{ name = '2026' }); stages = @(@{ name = 'default' }) } From dbccde117d64f49bb664cf9d71a53a5699954104 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Thu, 8 Oct 2026 21:54:37 +0200 Subject: [PATCH 4/8] Document the change mechanics, D47 and D48 docs/reference/change-mechanics.md is the contract of the change set, the replace and no-op rules, the plan, the table and the landing, with a placeholder for the live run. ADR D47 (ChangeRule follows commitOptions.createPullRequest, no directCommit input) and D48 (no-op changes are reported and never written). ARCHITECTURE 7.5 links the contract and gains section 5.8, the naming, template content, effective diff and update references, README, template README and CONTRIBUTING name the action, the module, the 44 template files and the CI job. Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 18 +- README.md | 10 +- docs/ARCHITECTURE.md | 35 ++-- ...itoptions-and-has-no-directcommit-input.md | 10 + ...-changes-are-reported-and-never-written.md | 10 + docs/adr/README.md | 5 +- docs/reference/change-mechanics.md | 181 ++++++++++++++++++ docs/reference/effective-diff.md | 2 + docs/reference/naming.md | 1 + docs/reference/template-content.md | 9 +- docs/reference/update-mechanics.md | 2 +- template/README.md | 11 +- 12 files changed, 259 insertions(+), 35 deletions(-) create mode 100644 docs/adr/0047-changerule-follows-commitoptions-and-has-no-directcommit-input.md create mode 100644 docs/adr/0048-no-op-changes-are-reported-and-never-written.md create mode 100644 docs/reference/change-mechanics.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53a97de..db515fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,28 +22,29 @@ What exists today, and the work package that adds the rest. A folder is created | Path | Content | Added by | |---|---|---| -| `.github/workflows/ci.yml` | PSScriptAnalyzer, the matrix checks V1 to V14 and Pester on every pull request and every push to `main` and to a release branch (`v*`); the Validate action on two fixtures and on `template/`; the Publish action on `template/` without deploying, and the init script against the staged site served over HTTP; the CheckForUpdates action on `tests/fixtures/repos/update-org` against the local templates; the ScanDiagnostics action in dry runs against nuget.org. | WP00, WP03, WP04, WP05, WP06, WP07, WP08 | +| `.github/workflows/ci.yml` | PSScriptAnalyzer, the matrix checks V1 to V14 and Pester on every pull request and every push to `main` and to a release branch (`v*`); the Validate action on two fixtures and on `template/`; the Publish action on `template/` without deploying, and the init script against the staged site served over HTTP; the CheckForUpdates action on `tests/fixtures/repos/update-org` against the local templates; the ScanDiagnostics action in dry runs against nuget.org; the ChangeRule action on a copy of `tests/fixtures/repos/valid-minimal` without a token. | WP00, WP03, WP04, WP05, WP06, WP07, WP08, WP09 | | `.github/workflows/` (deploy) | Copies `template/` into `ALCops/rulebook` and pins action references to `@v1`. | WP13 ([#15](https://github.com/ALCops/rulebook-engine/issues/15)) | | `.github/release.yml` | Maps pull request labels to release-note sections ([D38](docs/adr/0038-release-notes-are-generated-from-pull-request-labels.md)). | WP00 | | `.github/dependabot.yml` | Weekly, grouped updates of the GitHub Actions used by the workflows. | WP00 | | `.github/ISSUE_TEMPLATE/` | Issue forms: work package, task or spin-off. | written | | `PSScriptAnalyzerSettings.psd1` | Analyzer settings: errors and warnings, default rules, justified exclusions only. | WP00 | | `schemas/` | JSON schemas for every file in an organization rulebook repository, draft 2020-12, no `$id`; names and URLs in [docs/reference/naming.md](docs/reference/naming.md). | WP02 ([#4](https://github.com/ALCops/rulebook-engine/issues/4)) | -| `tests/` | `Smoke.Tests.ps1`, `Schemas.Tests.ps1`, `Rulebook.Generate.Tests.ps1`, `Rulebook.Validate.Tests.ps1`, `Validate.Action.Tests.ps1`, `Rulebook.Template.Tests.ps1`, `Rulebook.Publish.Tests.ps1`, `Publish.Action.Tests.ps1`, `Get-RulebookSkeletons.Tests.ps1`, `Rulebook.GitHub.Tests.ps1`, `Rulebook.Update.Tests.ps1`, `CheckForUpdates.Action.Tests.ps1`, `Rulebook.NuGet.Tests.ps1`, `Rulebook.Extract.Tests.ps1`, `Rulebook.Catalog.Tests.ps1`, `Rulebook.Quarantine.Tests.ps1`, `Rulebook.Scan.Tests.ps1`, `ScanDiagnostics.Action.Tests.ps1` and `Rulebook.Action.Tests.ps1` now; later one suite per module and action, with fixtures under `tests/fixtures/`. | WP00, WP02, WP03, WP04, WP05, WP06, WP07, WP08; WP12 ([#14](https://github.com/ALCops/rulebook-engine/issues/14)) and every module work package | +| `tests/` | `Smoke.Tests.ps1`, `Schemas.Tests.ps1`, `Rulebook.Generate.Tests.ps1`, `Rulebook.Validate.Tests.ps1`, `Validate.Action.Tests.ps1`, `Rulebook.Template.Tests.ps1`, `Rulebook.Publish.Tests.ps1`, `Publish.Action.Tests.ps1`, `Get-RulebookSkeletons.Tests.ps1`, `Rulebook.GitHub.Tests.ps1`, `Rulebook.Update.Tests.ps1`, `CheckForUpdates.Action.Tests.ps1`, `Rulebook.NuGet.Tests.ps1`, `Rulebook.Extract.Tests.ps1`, `Rulebook.Catalog.Tests.ps1`, `Rulebook.Quarantine.Tests.ps1`, `Rulebook.Scan.Tests.ps1`, `ScanDiagnostics.Action.Tests.ps1`, `Rulebook.Action.Tests.ps1`, `Rulebook.Edit.Tests.ps1` and `ChangeRule.Action.Tests.ps1` now; later one suite per module and action, with fixtures under `tests/fixtures/`. | WP00, WP02, WP03, WP04, WP05, WP06, WP07, WP08, WP09; WP12 ([#14](https://github.com/ALCops/rulebook-engine/issues/14)) and every module work package | | `tests/fixtures/schemas/` | One file per case: `//.json`, each invalid file a one-change mutation of a valid one. | WP02 | | `tests/fixtures/repos/` | Organization rulebook repositories for the module suites. `valid-minimal` (30-id catalog, four levels, three stages), `stale-endpoints` and `update-org` (WP07) are complete on disk, their `rulesets/` written by `Update-RulebookEndpoints`; every other folder is an overlay holding only the files it changes, copied over `valid-minimal` by `New-FixtureRepo`. | WP03 | | `tests/fixtures/templates/` | `v1` and `v2`: two versions of a mini template for the update suites, with the exact difference list and the derivation of `repos/update-org` in its README. | WP07 | | `tests/fixtures/stub-analyzers/` | C# stubs of the compiler and the twelve cop assemblies and `Build-StubPackage.ps1`, which compiles them with the Roslyn of pwsh into fixture nupkgs (variants tools 18.0.43.1464 and 30.0.42.60748-beta, ALCops 1.3.1, 1.4.0-beta.1 and 1.4.0) for the scan suites; `tests/Helpers/StubFeed.ps1` builds them once per source hash. | WP08 | | `tests/fixtures/matrix/` | `tiny`: a six-id level content in the layout of `docs/rulebook/` (inventory, matrix, resolved cells, levels, stages, twins) for the unit cases of the Template suite. | WP04 | | `tests/Helpers/` | Helpers the suites dot-source in `BeforeAll`: `RepoFixture.ps1` copies a fixture into `TestDrive`, edits its JSON, creates git repositories, bare origins with a push-refusing hook and the synthetic performance rulebook; `StubFeed.ps1` builds NuGet flat containers of the stub analyzer packages. | WP03, WP08 | -| `modules/` | PowerShell modules shared by the actions, each a `.psm1` with a `.psd1` manifest: `Rulebook.Generate`, `Rulebook.Validate`, `Rulebook.Template`, `Rulebook.Publish`, `Rulebook.GitHub`, `Rulebook.Update`, `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine`, `Rulebook.Scan` and `Rulebook.Action` (written). | WP03 to WP09 | +| `modules/` | PowerShell modules shared by the actions, each a `.psm1` with a `.psd1` manifest: `Rulebook.Generate`, `Rulebook.Validate`, `Rulebook.Template`, `Rulebook.Publish`, `Rulebook.GitHub`, `Rulebook.Update`, `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine`, `Rulebook.Scan`, `Rulebook.Action` and `Rulebook.Edit` (written). | WP03 to WP09 | | `actions/` | Composite actions, one folder each with `action.yaml` and its entry script. | WP03 to WP09 | | `actions/Validate/` | `action.yaml` and `Validate.ps1`: checks C1 to C16, annotations, the job summary with the effective diff ([ARCHITECTURE.md](docs/ARCHITECTURE.md) section 5.5). | WP03 | | `actions/Publish/` | `action.yaml` and `Publish.ps1`: stage the endpoints, the rendered skeletons and `index.html`, the Pages preflight, deploy to GitHub Pages, the reachability check ([ARCHITECTURE.md](docs/ARCHITECTURE.md) section 7.2, [docs/reference/publish-targets.md](docs/reference/publish-targets.md)). | WP05 | | `actions/CheckForUpdates/` | `action.yaml` and `CheckForUpdates.ps1`: the template update check (`update` other than `'Y'`) and the update pull request or direct commit (`update: 'Y'`), with the token from `GHTOKENWORKFLOW` ([docs/reference/update-mechanics.md](docs/reference/update-mechanics.md)). | WP07 | | `actions/ScanDiagnostics/` | `action.yaml` and `ScanDiagnostics.ps1`: the diagnostic scan, its living pull request on `scan-diagnostics/` or a direct commit, and the dry run ([docs/reference/scan-mechanics.md](docs/reference/scan-mechanics.md)). | WP08 | +| `actions/ChangeRule/` | `action.yaml` and `ChangeRule.ps1`: set or remove one override entry for a level and stage selection, regenerate and validate, and open the pull request on `change-rule//` or push a direct commit ([docs/reference/change-mechanics.md](docs/reference/change-mechanics.md)). | WP09 | | `scripts/` | User-facing scripts an AL project downloads and runs, self-contained (no engine module, PowerShell 7): `Get-RulebookSkeletons.ps1` downloads the published skeletons of a level into `.rulebook/`, one per stage ([ARCHITECTURE.md](docs/ARCHITECTURE.md) section 6.3). | WP06 ([#8](https://github.com/ALCops/rulebook-engine/issues/8)) | -| `template/` | Source of the template content deployed to `ALCops/rulebook`, 43 files: 11 hand-written files (settings, `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml`, `ScanDiagnostics.yaml`, `README.md`, `skeletons/README.md`, `overrides.json`, three quarantine files) and 32 files generated by `tools/rulebook/Build-Template.ps1` (`base/`, `stages/`, `catalog/`, `skeletons/`, `rulesets/`), see [docs/reference/template-content.md](docs/reference/template-content.md). | WP03, WP04, WP05, WP06, WP07, WP08, WP13 | +| `template/` | Source of the template content deployed to `ALCops/rulebook`, 44 files: 12 hand-written files (settings, `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml`, `ScanDiagnostics.yaml`, `ChangeRule.yaml`, `README.md`, `skeletons/README.md`, `overrides.json`, three quarantine files) and 32 files generated by `tools/rulebook/Build-Template.ps1` (`base/`, `stages/`, `catalog/`, `skeletons/`, `rulesets/`), see [docs/reference/template-content.md](docs/reference/template-content.md). | WP03, WP04, WP05, WP06, WP07, WP08, WP09, WP13 | | `docs/`, `tools/rulebook/` | Architecture, decision records, level content and the scripts that build it and the template content (`Build-Template.ps1`, WP04). | written | ## 2. Conventions @@ -74,7 +75,7 @@ After any change to `docs/rulebook/`, `modules/Rulebook.Template.psm1` or `templ ## 4. CI -`.github/workflows/ci.yml` has five jobs on `ubuntu-latest`, `test`, `validate-action`, `publish-action` and `update-action` with a 15-minute timeout and `scan-action` with 20 minutes. All five run on every pull request and on every push to `main` or a release branch (`v*`). `test` installs PSScriptAnalyzer and Pester 6, runs the analyzer over the whole repository and fails on any finding (after printing the findings table), runs the matrix checks V1 to V14 (`tools/rulebook/Test-Rulebook.ps1`, which exits 1 on a failed check), runs Pester from `tests/`, and uploads `testResults.xml` (NUnit) as the `testResults` artifact, also when a step failed. The workflow token is read-only, and a new push to a pull request cancels its running job; pushes to `main` and release branches always finish. +`.github/workflows/ci.yml` has six jobs on `ubuntu-latest`, `test`, `validate-action`, `publish-action`, `update-action` and `changerule-action` with a 15-minute timeout and `scan-action` with 20 minutes. All six run on every pull request and on every push to `main` or a release branch (`v*`). `test` installs PSScriptAnalyzer and Pester 6, runs the analyzer over the whole repository and fails on any finding (after printing the findings table), runs the matrix checks V1 to V14 (`tools/rulebook/Test-Rulebook.ps1`, which exits 1 on a failed check), runs Pester from `tests/`, and uploads `testResults.xml` (NUnit) as the `testResults` artifact, also when a step failed. The workflow token is read-only, and a new push to a pull request cancels its running job; pushes to `main` and release branches always finish. `validate-action` runs the composite action from the checkout (`uses: ./actions/Validate`) the way an organization workflow does: it must pass on `tests/fixtures/repos/valid-minimal`, pass on `template/` with `failOnWarning` (no warning either), and fail on `tests/fixtures/repos/stale-endpoints`, and the job fails otherwise. Its three steps pass `checkForUpdates: 'false'`, so the fixtures are not compared with `ALCops/rulebook`. On a pull request its `template/` step shows the effective diff of the template endpoints against the base branch. Its step on `stale-endpoints` prints one expected C12 error annotation. @@ -82,6 +83,8 @@ After any change to `docs/rulebook/`, `modules/Rulebook.Template.psm1` or `templ `update-action` runs `uses: ./actions/CheckForUpdates` in check mode on `tests/fixtures/repos/update-org` with the local templates of `tests/fixtures/templates/` (no download, no token): against `v1` it must report `updatesAvailable` `false`, against `v2` `true`, and in update mode without a token it must fail with the `failure` output `token`; a guard step fails the job on any other outcome (it prints one expected error annotation, the missing secret). +`changerule-action` runs `uses: ./actions/ChangeRule` on a copy of `tests/fixtures/repos/valid-minimal` in `RUNNER_TEMP` (no token, nothing pushed): `AA0001` `None` for every level and stage must fail with the `failure` output `token` (the change is valid and changes nine endpoints, so the token guard, which runs after the local plan, fires); `AA0072` `Info` for every level and stage with the justification `House style` must succeed with `noop` `true` (the fixture has exactly that entry); `LC9999` must fail with `validation`. A guard step fails the job on any other outcome (the run prints two expected error annotations). + `scan-action` runs `uses: ./actions/ScanDiagnostics` with `dryRun: 'true'` against the real packages on nuget.org (no token, nothing pushed; the unit suites use stub packages): on `template/` without a policy it must fail with the `failure` output `policy` (one expected error annotation); on a copy of `template/` with a policy it must scan both stable versions, give at least 600 catalog entries a package, write a valid `catalog/scan-state.json` and leave a candidate without validation errors, and it prints the new ids, the refreshed titles and docs links and the ids without a package; on `tests/fixtures/repos/scan-org` (30 catalog ids) it must quarantine at least 500 new ids in `default` and `ci` and leave `quarantine.vnext.json` alone; a second dry run on the scanned template must give `nothing-new` in under 60 s. nuget.org moves, so the checks are lower bounds. The ruleset requires the checks of section 6. @@ -100,7 +103,7 @@ Applied on 2026-10-03 by the WP00 pull request ([#2](https://github.com/ALCops/r | Setting | Value | Command | |---|---|---| -| Ruleset `protect-main` on the default branch | Pull request required, 0 approvals, deletion and force-push blocked, checks `test`, `validate-action`, `publish-action`, `update-action` and `scan-action` from GitHub Actions required, no bypass actors. `validate-action` was added on 2026-10-06 with the WP03 pull request ([#46](https://github.com/ALCops/rulebook-engine/pull/46)) through a PUT of the same JSON. `publish-action` was added the same way on 2026-10-06, after the first CI run of the WP05 pull request ([#59](https://github.com/ALCops/rulebook-engine/pull/59)) had reported the check; the PUT kept the other rules as the GET returned them. `update-action` is added the same way after the first CI run of the WP07 pull request ([#9](https://github.com/ALCops/rulebook-engine/issues/9)) has reported the check, and `scan-action` after the first CI run of the WP08 pull request ([#10](https://github.com/ALCops/rulebook-engine/issues/10)). | `gh api --method POST ... rulesets --input ruleset-engine.json`, later `gh api --method PUT ... rulesets/24420852 --input ruleset-engine.json` | +| Ruleset `protect-main` on the default branch | Pull request required, 0 approvals, deletion and force-push blocked, checks `test`, `validate-action`, `publish-action`, `update-action`, `scan-action` and `changerule-action` from GitHub Actions required, no bypass actors. `validate-action` was added on 2026-10-06 with the WP03 pull request ([#46](https://github.com/ALCops/rulebook-engine/pull/46)) through a PUT of the same JSON. `publish-action` was added the same way on 2026-10-06, after the first CI run of the WP05 pull request ([#59](https://github.com/ALCops/rulebook-engine/pull/59)) had reported the check; the PUT kept the other rules as the GET returned them. `update-action` is added the same way after the first CI run of the WP07 pull request ([#9](https://github.com/ALCops/rulebook-engine/issues/9)) has reported the check, and `scan-action` after the first CI run of the WP08 pull request ([#10](https://github.com/ALCops/rulebook-engine/issues/10)), `changerule-action` after the first CI run of the WP09 pull request ([#11](https://github.com/ALCops/rulebook-engine/issues/11)). | `gh api --method POST ... rulesets --input ruleset-engine.json`, later `gh api --method PUT ... rulesets/24420852 --input ruleset-engine.json` | | Workflow permissions | Read-only `GITHUB_TOKEN` (applied). "Actions may create and approve pull requests" is not applied yet: the repository-level PUT is refused (409) while the organization policy "Allow GitHub Actions to create and approve pull requests" is off. An org admin enables it under Org Settings > Actions > General; then the PUT below applies. | `gh api --method PUT ... actions/permissions/workflow` | | Labels | `dependencies` and `skip-changelog`, next to the defaults (`enhancement`, `bug`, `documentation`). | `gh label create` | @@ -130,7 +133,8 @@ The ruleset, saved as `ruleset-engine.json` outside the repository (`integration { "context": "validate-action", "integration_id": 15368 }, { "context": "publish-action", "integration_id": 15368 }, { "context": "update-action", "integration_id": 15368 }, - { "context": "scan-action", "integration_id": 15368 } ] } } + { "context": "scan-action", "integration_id": 15368 }, + { "context": "changerule-action", "integration_id": 15368 } ] } } ] } ``` diff --git a/README.md b/README.md index 2d46f2a..117adbe 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,15 @@ The engine behind [ALCops/rulebook](https://github.com/ALCops/rulebook): the com | Path | Content | Status | |---|---|---| -| `actions//action.yaml` | Composite actions, each running a PowerShell 7 entry script on `ubuntu-latest`: `Validate` (checks, effective diff, update check), `Publish` (gate, stage, deploy, verify), `CheckForUpdates` (update from the template), `ScanDiagnostics` (the daily diagnostic scan). | `Validate` (WP03), `Publish` (WP05), `CheckForUpdates` (WP07) and `ScanDiagnostics` (WP08) written; `ChangeRule` planned (WP09) | -| `modules/Rulebook.*.psm1` | PowerShell modules shared by the actions, each with a `.psd1` manifest: `Rulebook.Generate` (level chain + stage deltas + twins setting + overrides + quarantine to sparse flat endpoints, effective diff), `Rulebook.Validate` (checks C1 to C16), `Rulebook.Template` (level, stage, twins, catalog and skeleton files of `template/` from `docs/rulebook/`), `Rulebook.Publish` (staging, Pages deploy, reachability), `Rulebook.Update` and `Rulebook.GitHub` (the update and the GitHub plumbing), for the scan `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine` and `Rulebook.Scan`, and `Rulebook.Action` (the helpers every action entry script shares: annotations, the failure kind, the job summary and `GITHUB_OUTPUT`). | written (WP03 to WP09) | -| `template/` | Source of the template content that a deploy workflow copies into `ALCops/rulebook`, pinning action references from `@main` to `@v1`. Its `base/`, `stages/` and `rulesets/` are generated from `docs/rulebook/`. | written (WP03 to WP08), 43 files: settings, the workflows `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml` and `ScanDiagnostics.yaml`, the README files, empty overrides and quarantine files, and the 32 generated files; the other workflows come with their work packages, the deploy with WP13 | +| `actions//action.yaml` | Composite actions, each running a PowerShell 7 entry script on `ubuntu-latest`: `Validate` (checks, effective diff, update check), `Publish` (gate, stage, deploy, verify), `CheckForUpdates` (update from the template), `ScanDiagnostics` (the daily diagnostic scan), `ChangeRule` (one override entry through a form, as a pull request). | `Validate` (WP03), `Publish` (WP05), `CheckForUpdates` (WP07), `ScanDiagnostics` (WP08) and `ChangeRule` (WP09) written | +| `modules/Rulebook.*.psm1` | PowerShell modules shared by the actions, each with a `.psd1` manifest: `Rulebook.Generate` (level chain + stage deltas + twins setting + overrides + quarantine to sparse flat endpoints, effective diff), `Rulebook.Validate` (checks C1 to C16), `Rulebook.Template` (level, stage, twins, catalog and skeleton files of `template/` from `docs/rulebook/`), `Rulebook.Publish` (staging, Pages deploy, reachability), `Rulebook.Update` and `Rulebook.GitHub` (the update and the GitHub plumbing), for the scan `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine` and `Rulebook.Scan`, `Rulebook.Edit` (overrides.json and the change set of the ChangeRule action) and `Rulebook.Action` (the helpers every action entry script shares: annotations, the failure kind, the job summary and `GITHUB_OUTPUT`). | written (WP03 to WP09) | +| `template/` | Source of the template content that a deploy workflow copies into `ALCops/rulebook`, pinning action references from `@main` to `@v1`. Its `base/`, `stages/` and `rulesets/` are generated from `docs/rulebook/`. | written (WP03 to WP09), 44 files: settings, the workflows `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml`, `ScanDiagnostics.yaml` and `ChangeRule.yaml`, the README files, empty overrides and quarantine files, and the 32 generated files; the other workflows come with their work packages, the deploy with WP13 | | `schemas/` | JSON schemas for every file in an organization rulebook repository (ruleset profiles, overrides, quarantine, twins, catalog, scan state, settings), served from the `v1` branch over raw URLs, which go live with WP13 ([#15](https://github.com/ALCops/rulebook-engine/issues/15)) and return 404 until then; the tests use the local files. See [docs/reference/naming.md](docs/reference/naming.md). | written (WP02) | -| `tests/` | Pester 6 suites, one per module and action, with fixtures under `tests/fixtures/`: the schema suite and its fixtures under `tests/fixtures/schemas/`; the Generate, Validate and Validate action suites with organization rulebook fixtures under `tests/fixtures/repos/` and helpers in `tests/Helpers/`. | a suite per module and action (WP00 to WP08); the scan suites build stub analyzer packages from `tests/fixtures/stub-analyzers/` at test time | +| `tests/` | Pester 6 suites, one per module and action, with fixtures under `tests/fixtures/`: the schema suite and its fixtures under `tests/fixtures/schemas/`; the Generate, Validate and Validate action suites with organization rulebook fixtures under `tests/fixtures/repos/` and helpers in `tests/Helpers/`. | a suite per module and action (WP00 to WP09); the scan suites build stub analyzer packages from `tests/fixtures/stub-analyzers/` at test time | | `docs/` | Architecture, decision records (`adr/`), references, and `docs/rulebook/` with the level content (inventory, matrix, composition spec). | written | | `tools/rulebook/` | PowerShell scripts that extract the inventory from the analyzer sources, build the matrix (ladders, stage columns, twin pairs, counts) and verify it (`Extract-Inventory.ps1`, `Build-Matrix.ps1`, `Test-Rulebook.ps1`). They import `Rulebook.Generate` for the diagnostic sort key and build portable paths; CI runs `Test-Rulebook.ps1`. `Build-Template.ps1` regenerates `template/` from `docs/rulebook/`. | written | | `scripts/` | User-facing scripts that an AL project downloads from the engine and runs, self-contained (PowerShell 7, no engine module): `Get-RulebookSkeletons.ps1` reads `/rulebook.json` and downloads the published skeletons of one level into `.rulebook/`, one file per stage. Linked from the index page of every published rulebook. | written (WP06) | -| `.github/workflows/` | `ci.yml`: the job `test` (PSScriptAnalyzer, the matrix checks V1 to V14, Pester) and one job per action, `validate-action`, `publish-action`, `update-action` and `scan-action` (dry runs against nuget.org); the deploy workflow comes with WP13. | CI written (WP00); deploy planned (WP13) | +| `.github/workflows/` | `ci.yml`: the job `test` (PSScriptAnalyzer, the matrix checks V1 to V14, Pester) and one job per action, `validate-action`, `publish-action`, `update-action`, `scan-action` (dry runs against nuget.org) and `changerule-action`; the deploy workflow comes with WP13. | CI written (WP00); deploy planned (WP13) | | `CONTRIBUTING.md` | Conventions, running the checks locally, CI, branches, repository settings and pull request rules. | written | ## 2. Relation to the template diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7bfbde9..b073eff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -97,7 +97,7 @@ Everything an org repo contains after "Use this template". The **class** column | `.github/workflows/Publish.yaml` | On push to the default branch and on demand: validate, refuse stale endpoints, deploy `rulesets/`, the rendered skeletons and `index.html` to the configured target, verify reachability. Never commits (D42). | system | | `.github/workflows/UpdateRulebookSystemFiles.yaml` | Manual, or scheduled when `update.schedule` is set (the update writes the `schedule:` trigger): pull the new template version into a PR (or a direct commit) with the regenerated endpoints and skeletons, after validating the result (section 7.3). | system | | `.github/workflows/ScanDiagnostics.yaml` | Daily (the `schedule:` comes from `scan.schedule`, shipped `17 4 * * *`) and manual: scan the newest stable and prerelease versions of the two analyzer packages on NuGet, record new ids and changed defaults in the catalog, quarantine new ids by the policy, release the ids a level file adopted, regenerate the endpoints and keep one living pull request on `scan-diagnostics/`, or push a direct commit (section 7.4). | system | -| `.github/workflows/ChangeRule.yaml` | Manual form: one override entry, regenerate, open a PR. | system | +| `.github/workflows/ChangeRule.yaml` | Manual form: set or remove one override entry, regenerate, open a PR or push a direct commit per `commitOptions.createPullRequest` (section 7.5). | system | | `.github/workflows/ApplyRulebookChange.yaml` | On an issue with the `rulebook-change` label: gate on collaborator association, apply the change set, regenerate, open a PR or commit (section 7.6). | system | | `.github/ISSUE_TEMPLATE/rulebook-change.yml`, `config.yml` | The issue form the dashboard prefills; blank issues stay enabled. | system | | `.github/Rulebook-Settings.json` | Template URL and sha, base URL, publish target, quarantine policy, twins setting, the ordered `levels` (name, `basedOn`, description) and `stages` (name, description). Carries the settings schema URL in `$schema`. | settings (kept, `$schema` refreshed) | @@ -265,7 +265,7 @@ A rule is `id` (`^[A-Z]{2,3}[0-9]{4}i?$`), `action` (`Error`, `Warning`, `Info`, Common to all four: entries in inventory order with the matrix row justification; the level and stage files carry the delta profile URL in `$schema`; UTF-8 without BOM, LF; a file is written only when its bytes differ, files of the folder that no input produces are deleted first; one `{ File, Path, Change }` object per change (`created`, `modified`, `deleted`); `-WhatIf` writes nothing and returns the same list. The functions throw, as engine tools, on matrix input they cannot use (rows out of inventory order, an id without resolved cells, a level that is not a slug, an unresolved `basedOn` or a cycle, no `default` stage, a stage without a matrix column, a value that is not an action). -`tools/rulebook/Build-Template.ps1 [-RulebookDir] [-TemplateDir] [-WhatIf]` runs the four functions and then `Update-RulebookEndpoints` on `template/`, prints one line per change or `template: current`, and returns the change objects. `tests/Rulebook.Template.Tests.ps1` regenerates `template/` from its 11 hand-written files and compares the bytes, so a matrix change without a template regeneration fails CI. +`tools/rulebook/Build-Template.ps1 [-RulebookDir] [-TemplateDir] [-WhatIf]` runs the four functions and then `Update-RulebookEndpoints` on `template/`, prints one line per change or `template: current`, and returns the change objects. `tests/Rulebook.Template.Tests.ps1` regenerates `template/` from its 12 hand-written files and compares the bytes, so a matrix change without a template regeneration fails CI. ### 5.7 Scan modules @@ -279,6 +279,13 @@ The scan of section 7.4 (WP08) is five modules, each a `.psm1` with a `.psd1` ma | `Rulebook.Quarantine` | `Get-QuarantinePolicy`, `Read-QuarantineFile`, `Write-QuarantineFile`, `New-QuarantineJustification`, `Add-QuarantineEntry`, `Invoke-QuarantineHousekeeping`, `Update-QuarantineFromScan` | The policy (no default, D14), the quarantine files in the template layout, new ids into the policy stages, housekeeping by the rule of C13. | | `Rulebook.Scan` | `Get-RulebookScanPlan`, `Get-ScanTitle`, `ConvertTo-ScanPullRequestBody`, `ConvertTo-ScanSummary`, `Publish-RulebookScan` | The run of section 7.4 on a candidate tree, its title and body, and the living pull request (D45). | +### 5.8 Edit and action helper modules + +| Module | Functions | Role | +|---|---|---| +| `Rulebook.Edit` (WP09) | `Read-OverridesFile`, `ConvertTo-OverridesJson`, `Write-OverridesFile`, `Set-RulebookOverride`, `Remove-RulebookOverride`, `ConvertTo-RulebookChangeSet`, `Test-RulebookChangeSet`, `Invoke-RulebookChangeSet`, `Get-RulebookChangeTitle`, `ConvertTo-ChangeTable`, `ConvertTo-ChangePullRequestBody`, `ConvertTo-ChangeSummary`, `Publish-RulebookChange` | `overrides.json` in the template layout, the change set of sections 7.5 and 7.6 (`set` and `remove`; `release` arrives with WP15) applied all or nothing on a candidate copy, the per-endpoint table and the no-op rule (D48), and the pull request or direct commit (D47). Contract: [reference/change-mechanics.md](reference/change-mechanics.md). | +| `Rulebook.Action` (#58) | `Format-AnnotationText`, `ConvertTo-SingleLine`, `Format-TableCell`, `New-ActionContext`, `Add-Annotation`, `Add-Failure`, `Write-Text`, `Resolve-ActionPath`, `Write-ActionOutput`, `Limit-SummaryText` | The helpers every action entry script shares: workflow command escaping and annotations, the failure kind, the job summary and `GITHUB_OUTPUT`. No engine imports. | + ## 6. Endpoints and skeletons ### 6.1 URL scheme @@ -450,17 +457,17 @@ flowchart LR ### 7.5 Change rule (R10) -Trigger: `workflow_dispatch` with a form. +Trigger: `workflow_dispatch` with a form (implemented with WP09). | Input | Type | Values | |---|---|---| -| `ruleId` | string | `AA0001`, `LC0029`, ... validated against the catalog | +| `ruleId` | string | `AA0001`, `LC0029`, ... checked against `^[A-Z]{2,3}\d{4}i?$` and the catalog | | `action` | choice | Error, Warning, Info, Hidden, None, Remove | -| `levels` | choice | the level slugs from the settings and `*`; the choice list is rewritten by the update workflow (D30) | -| `stages` | choice | the stage slugs from the settings and `*`; same mechanism | -| `justification` | string | stored with the override entry | +| `levels` | choice | one level slug from the settings or `*`; the choice list is rewritten by the update workflow (D30) | +| `stages` | choice | one stage slug from the settings or `*`; same mechanism | +| `justification` | string | optional (D37); stored with the override entry | -The action writes or removes one entry in `overrides.json`, regenerates `rulesets/`, runs validation, and opens a PR whose body shows the effective change per endpoint (before and after). Changing the shipped level content itself is done in the engine, in the matrix, and reaches orgs through the update workflow; an org edits its own level and stage files by hand. The same action is callable from a future web UI or VS Code extension because its inputs are plain strings. +The workflow builds a one-item change set and calls the `ChangeRule` action (`Rulebook.Edit`): it writes or removes one entry in `overrides.json` on a candidate copy, regenerates `rulesets/`, validates, and lands the result per `commitOptions.createPullRequest` as a pull request on `change-rule//` or a direct commit (D47; there is no `directCommit` input). The pull request body shows the effective change per endpoint with provenance. A change that alters no endpoint and not `overrides.json` is reported and never written (D48). Changing the shipped level content itself is done in the engine, in the matrix, and reaches orgs through the update workflow; an org edits its own level and stage files by hand. Mechanics, messages and tests: [reference/change-mechanics.md](reference/change-mechanics.md). ### 7.6 Apply change set (R12) @@ -479,7 +486,7 @@ flowchart LR apply -->|direct commit| commit[commit, comment table, close] ``` -A change set is `{ version, note?, changes[] }` with `set` (write an override entry), `remove` (delete one) and `release` (remove an id from the quarantine of the listed stages). It is applied all or nothing, regenerated once, and lands per `commitOptions.createPullRequest` as one PR or one commit (D33). The gate is collaborator association (D34); justification is optional (D37). ChangeRule (section 7.5) builds a one-item change set and calls the same module function. Details, schema and the comment protocol are in [dashboard.md](dashboard.md) sections 6 to 8. +A change set is `{ version, note?, changes[] }` with `set` (write an override entry), `remove` (delete one) and `release` (remove an id from the quarantine of the listed stages). It is applied all or nothing, regenerated once, and lands per `commitOptions.createPullRequest` as one PR or one commit (D33). The gate is collaborator association (D34); justification is optional (D37). ChangeRule (section 7.5) builds a one-item change set and calls the same module function: `Invoke-RulebookChangeSet` with `set` and `remove` landed with WP09 ([reference/change-mechanics.md](reference/change-mechanics.md)); `release`, the change set schema and the issue parsing arrive with WP15. Details, schema and the comment protocol are in [dashboard.md](dashboard.md) sections 6 to 8. ## 8. Settings @@ -553,7 +560,7 @@ Every target ends with the same reachability check: `GET` each endpoint, skeleto | Endpoint unreachable, invalid JSON, invalid enum, timeout | Whole ruleset discarded, one diagnostic AL1033. `alc` aborts the compile (exit 1, no `.app`; timeout: not observed), whether the endpoint is the root path or the skeleton's include ([spike c](reference/spikes/c-alc-on-ubuntu.md), [spike a](reference/spikes/a-hosts-and-skeleton-include.md)), so on raw `alc` the build fails by itself (AL-Go and BcContainerHelper run the same compiler but were not observed). The VS Code language server continues with compiler defaults and shows AL1033 on `app.json` ([spike e](reference/spikes/e-vscode-refetch.md)), so the editor shows default severities: because the matrix follows the analyzer defaults for most rules (D21) and the endpoint only lists deviations (D22), that is close to the intended ruleset; what is lost is every `None` the level set, every downgrade, and the org's overrides. | Validate before publish, reachability check after publish, and as a backstop treat AL1033 as a failure in pipelines (documented in WP11). One fetch per compile keeps the exposure to one request. | | External rulesets disabled in the consumer | AL0767 when the root path is a URL, AL1033 when a local skeleton includes the URL; `alc` aborts with exit 1 in both cases. | Walkthroughs set `enableExternalRulesets` in every consumer. `alc` defaults to disabled. | | Endpoint committed but stale (inputs changed, not regenerated) | Consumers get yesterday's decision. | Regeneration check in Validate; Publish refuses to deploy stale endpoints and keeps the last good site (D42); every writing workflow regenerates in its pull request. | -| Override selector typo | Silent no-op. | Selector validation; the ChangeRule PR body shows before and after per endpoint. | +| Override selector typo | Silent no-op. | Selector validation (C10, and the ChangeRule action refuses an unknown slug before writing); the ChangeRule PR body shows before and after per endpoint, and a change that alters nothing is reported, not written (D48). | | Update PR overwrites an org edit in a system file | Edit lost. | File classes; org decisions live only in `overrides.json`; docs say which files are system files. | | Scan adds an id the org wanted to see | Rule hidden until adopted. | Policy is explicit per org; the PR lists every new id with its default severity and docs link; adopting it in a level file releases it on the next run. | | Someone pushes to the scan branch by hand | The push is replaced by the next run. | The branch is rebuilt from the base on every run with a lease push (D45): the lease covers the window between `ls-remote` and the push only (a push in that window is rejected, an earlier push is replaced), and a base branch that moved while the scan was planning fails the run before the push; the PR body says not to push to the branch. | @@ -609,7 +616,9 @@ See the open decisions table in [adr/README.md](adr/README.md): O3 engine pinnin | Update PR title | `[@] Update Rulebook System Files from ALCops/rulebook - ` | | | Scan branch | `scan-diagnostics/`, rebuilt by every run (D45) | `scan-diagnostics/main` | | Scan PR title | `Scan diagnostics: (