From 0a775e1c65a16ef461ea789ba8c548fc318c49b8 Mon Sep 17 00:00:00 2001 From: Simon Vedder Date: Thu, 10 Sep 2026 11:17:55 +0200 Subject: [PATCH] Label the permissions in the help, and fail the check on holes The permissions were in the .NOTES block under the heading "Required permissions". The standard label is RequiredPermissions, which is what the generator's table renderer keys on, so the site rendered four paragraphs where the credential rotation pages render a table. Relabelled, with the rest of the block given labels of its own rather than being absorbed into the permissions row: Prerequisites, Writes, License. No permission changed; the wording is the same text under headings the tooling understands. The generator gets the same hardening as the skeleton. An empty examples array rendered an empty "### Example 1", an undocumented parameter rendered an empty table cell, and neither failed anything. Both, and a .NOTES block without RequiredPermissions, now fail -Check. A .NOTES block that opens with prose is left as prose rather than losing its first paragraph to the table. --- AppLifecycleAnalyzer.ps1 | 16 +++--- docs/commands/AppLifecycleAnalyzer.md | 16 +++--- tools/New-CommandReference.ps1 | 77 ++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/AppLifecycleAnalyzer.ps1 b/AppLifecycleAnalyzer.ps1 index 15c6797..740f7fc 100644 --- a/AppLifecycleAnalyzer.ps1 +++ b/AppLifecycleAnalyzer.ps1 @@ -42,23 +42,21 @@ this same session. Read-only is the default on purpose; this is the deliberate opt-in. .NOTES - Requires PowerShell 7 or later and the modules Microsoft.Graph.Authentication and - Microsoft.Graph.Applications. Install them once with: + Prerequisites: PowerShell 7 or later, and the modules Microsoft.Graph.Authentication and + Microsoft.Graph.Applications. Install them once with Install-Module + Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser, or pass + -AutoInstallModules and the script installs what is missing without asking. - Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser - - Or pass -AutoInstallModules and the script installs what is missing without asking. - - Required permissions: the delegated Graph scopes Application.Read.All, Directory.Read.All and + RequiredPermissions: The delegated Graph scopes Application.Read.All, Directory.Read.All and AuditLog.Read.All, which you consent to at sign-in. Sign-in activity comes from AuditLog and needs Entra ID P1 or P2; without it the report still lists every app and its credentials, and the activity column says so instead of guessing. - Nothing here writes. The script reads Graph and writes one HTML file. Every cleanup command in + Writes: Nothing. The script reads Graph and writes one HTML file. Every cleanup command in the report is text for you to copy - the script never runs one, and without -RequestWriteScopes the session it opens cannot run one either. - MIT licensed. https://github.com/simon-vedder/app-lifecycle-analyzer + License: MIT. https://github.com/simon-vedder/app-lifecycle-analyzer .EXAMPLE # Sign in to your home tenant, read, write the report next to you. diff --git a/docs/commands/AppLifecycleAnalyzer.md b/docs/commands/AppLifecycleAnalyzer.md index aac0c37..72280ae 100644 --- a/docs/commands/AppLifecycleAnalyzer.md +++ b/docs/commands/AppLifecycleAnalyzer.md @@ -27,23 +27,21 @@ text for you to copy; the script never runs one. ## Requirements and notes -Requires PowerShell 7 or later and the modules Microsoft.Graph.Authentication and -Microsoft.Graph.Applications. Install them once with: +Prerequisites: PowerShell 7 or later, and the modules Microsoft.Graph.Authentication and +Microsoft.Graph.Applications. Install them once with Install-Module +Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser, or pass +-AutoInstallModules and the script installs what is missing without asking. - Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser - -Or pass -AutoInstallModules and the script installs what is missing without asking. - -Required permissions: the delegated Graph scopes Application.Read.All, Directory.Read.All and +RequiredPermissions: The delegated Graph scopes Application.Read.All, Directory.Read.All and AuditLog.Read.All, which you consent to at sign-in. Sign-in activity comes from AuditLog and needs Entra ID P1 or P2; without it the report still lists every app and its credentials, and the activity column says so instead of guessing. -Nothing here writes. The script reads Graph and writes one HTML file. Every cleanup command in +Writes: Nothing. The script reads Graph and writes one HTML file. Every cleanup command in the report is text for you to copy - the script never runs one, and without -RequestWriteScopes the session it opens cannot run one either. -MIT licensed. https://github.com/simon-vedder/app-lifecycle-analyzer +License: MIT. https://github.com/simon-vedder/app-lifecycle-analyzer ## Parameters diff --git a/tools/New-CommandReference.ps1 b/tools/New-CommandReference.ps1 index 7a8e88a..a6c9c1a 100644 --- a/tools/New-CommandReference.ps1 +++ b/tools/New-CommandReference.ps1 @@ -64,6 +64,46 @@ function Format-Cell { return (($Text -replace '\s*\n\s*', ' ') -replace '\|', '\|').Trim() } +# The .NOTES block is "Label: value" with continuation lines. Rendered as-is it is a grey wall; +# as a table the permissions line is something a reader can find. +$notesDrop = @('Author', 'Version', 'Created', 'LastModified') +$notesRename = @{ RequiredPermissions = 'Permissions' } +function ConvertTo-RequirementsTable { + param([string[]]$Lines) + + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($line in $Lines) { + $start = [regex]::Match($line, '^([A-Za-z][A-Za-z ]*?):\s+(.*)$') + if ($start.Success) { + $rows.Add([pscustomobject]@{ Label = $start.Groups[1].Value.Trim(); Parts = [System.Collections.Generic.List[string]]@($start.Groups[2].Value.Trim()) }) + } + elseif ($line.Trim()) { + # A line before the first label belongs to no row, and a table has nowhere to put it. A + # .NOTES block written as prose is not a label list, so it stays prose rather than losing + # its opening paragraph on the way to the site. + if (-not $rows.Count) { return $null } + $rows[$rows.Count - 1].Parts.Add($line.Trim()) + } + } + if (-not $rows.Count) { return $null } + + $out = [System.Collections.Generic.List[string]]::new() + $out.Add('| | |') + $out.Add('|---|---|') + foreach ($row in $rows) { + if ($row.Label -in $notesDrop) { continue } + $label = if ($notesRename.ContainsKey($row.Label)) { $notesRename[$row.Label] } else { $row.Label } + $out.Add("| **$label** | $(Format-Cell ($row.Parts -join ' ')) |") + } + if ($out.Count -le 2) { return $null } + return $out +} + +# An undocumented parameter and a missing example both render as a hole on the published page - +# an empty table cell, or a code block with nothing in it. Neither breaks the build, so both stay +# broken until somebody reads the site. Collected here and reported at the end instead. +$gaps = [System.Collections.Generic.List[string]]::new() + $item = Get-Item -Path $scriptPath $name = $item.Name $help = Get-Help -Name $item.FullName -Full @@ -94,6 +134,7 @@ $lines.Add('```') $lines.Add('') $notes = Format-HelpText $help.alertSet.alert +if ($notes -notmatch 'RequiredPermissions\s*:') { $gaps.Add("$name`: no RequiredPermissions in .NOTES") } if ($notes) { $lines.Add('## Requirements and notes') $lines.Add('') @@ -114,6 +155,7 @@ if ($parameters.Count) { $default = Format-Cell ([string]$parameter.defaultValue) if ($default -in '', 'None', 'False') { $default = '' } $text = Format-Cell (Format-HelpText $parameter.description) + if (-not $text) { $gaps.Add("$name -$($parameter.name): no description") } $lines.Add("| ``-$($parameter.name)`` | $type | $required | $pipeline | $default | $text |") } } @@ -122,7 +164,8 @@ else { } $lines.Add('') -$examples = @($help.examples.example) +$examples = @($help.examples.example | Where-Object { $_ -and (Format-HelpText $_.code) }) +if (-not $examples.Count) { $gaps.Add("$name`: no examples") } if ($examples.Count) { $lines.Add('## Examples') $lines.Add('') @@ -154,18 +197,48 @@ $page = ($lines -join "`n").TrimEnd() + "`n" $body = [System.Collections.Generic.List[string]]::new() if ($description) { $body.Add($description); $body.Add('') } $started = $false +$inNotes = $false +$noteLines = [System.Collections.Generic.List[string]]::new() foreach ($line in $lines) { if ($line -eq '## Syntax') { $started = $true } if (-not $started) { continue } if ($line -eq '---') { break } - $body.Add(($line -replace '^## Requirements and notes$', '## Requirements')) + if ($line -eq '## Requirements and notes') { + $body.Add('## Requirements'); $body.Add('') + $inNotes = $true; $noteLines.Clear(); continue + } + if ($inNotes) { + if ($line.StartsWith('## ')) { + $table = ConvertTo-RequirementsTable -Lines $noteLines + if ($table) { $table | ForEach-Object { $body.Add($_) } } else { $noteLines | ForEach-Object { $body.Add($_) } } + $body.Add(''); $inNotes = $false; $body.Add($line); continue + } + # Format-HelpText hands the whole .NOTES block back as one multi-line string, so it arrives + # here as a single element. Split it, or every label lands on one line. + foreach ($noteLine in ($line -split "`r?`n")) { $noteLines.Add($noteLine) } + continue + } + $body.Add($line) +} +if ($inNotes) { + $table = ConvertTo-RequirementsTable -Lines $noteLines + if ($table) { $table | ForEach-Object { $body.Add($_) } } else { $noteLines | ForEach-Object { $body.Add($_) } } + $body.Add('') } $fileName = "$($item.BaseName).md" $target = Join-Path $repoRoot 'docs' 'commands' $targetFile = Join-Path $target $fileName +if ($gaps.Count) { + Write-Warning "The published reference would have $($gaps.Count) hole(s):" + $gaps | ForEach-Object { Write-Warning " $_" } +} + if ($Check) { + if ($gaps.Count) { + throw "The help has $($gaps.Count) undocumented spot(s). Fix the comment-based help, run ./tools/New-CommandReference.ps1 and commit the result." + } if (-not (Test-Path -Path $targetFile)) { throw "Missing docs/commands/$fileName. Run ./tools/New-CommandReference.ps1 and commit the result." }