From b1f585df900b1d0870b04a4088a09dece587ac25 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 15:17:31 +0200 Subject: [PATCH 01/23] Add reference-style link, backtick, and image alt-text guidance to the Markdown standard --- src/docs/Coding-Standards/Markdown.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/docs/Coding-Standards/Markdown.md b/src/docs/Coding-Standards/Markdown.md index 6f4c041..bb12aad 100644 --- a/src/docs/Coding-Standards/Markdown.md +++ b/src/docs/Coding-Standards/Markdown.md @@ -53,7 +53,10 @@ These rules are disabled or widened so they do not flag valid documentation — - **Use sentence-style headings.** - **Surround headings, lists, and fenced blocks with a blank line** for readability, even though the linter no longer enforces it. - **Prefer relative links** within a repository; use the canonical published URL for cross-repository references. +- **Give a repeated or long link a reference-style definition** (`[text][ref]`, with `[ref]: url` listed below) so the prose stays readable and one edit updates every use. - **Tag every code fence with a language** (` ```bash `, ` ```yaml `) so it is highlighted and converts cleanly when published. +- **Wrap code, commands, filenames, and identifiers in backticks** rather than bold or italic, so they read as code and do not lean on the emphasis the linter now allows freely. +- **Give every image descriptive alt text** — `![what the image shows](diagram.png)` — so it serves screen readers and still says something when the image fails to load; use a relative path for images kept in the repository. ## PowerShell code samples From cdeb3dd59c466392e3ffa1a1523b90431a25afaf Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 15:17:31 +0200 Subject: [PATCH 02/23] Add matching-operator and read-only-constant idioms to the PowerShell standard --- src/docs/Coding-Standards/PowerShell/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/docs/Coding-Standards/PowerShell/index.md b/src/docs/Coding-Standards/PowerShell/index.md index 70318df..c2308cd 100644 --- a/src/docs/Coding-Standards/PowerShell/index.md +++ b/src/docs/Coding-Standards/PowerShell/index.md @@ -57,8 +57,10 @@ Beyond the basics, these language-specific habits keep PowerShell correct and fa - **Single-quote strings unless you need expansion.** Use `'literal'` by default; reserve `"...$var..."` for interpolation or escape sequences, and here-strings (`@'...'@`, `@"..."@`) for multi-line text — literal-versus-interpolated intent then stays obvious. - **Splat calls that carry many parameters.** Build a `@{}` of parameters and splat it (`Get-Thing @params`) instead of a long line of `-Param value` pairs or backtick continuations; it reads better and diffs cleanly. - **Put `$null` on the left of a comparison** — `$null -eq $x`, never `$x -eq $null`. Against a collection the right-hand form *filters* rather than tests. Use `-contains` / `-in` for membership, never `-eq`. +- **Match text with the operator built for it.** Use `-like` for wildcard patterns and `-match` for regular expressions instead of hand-rolled string surgery; both default to case-insensitive, so add the `-c` prefix (`-clike`, `-cmatch`, `-ceq`) when a comparison must be case-sensitive. - **Suppress unwanted output with `$null = ...`** (or `[void]` for method calls), not `| Out-Null` — the pipeline form is markedly slower on hot paths. - **Build collections with a typed list, not `+=` in a loop.** `$a += $x` reallocates the whole array every iteration; use `[System.Collections.Generic.List[T]]` with `.Add()`, and prefer a cmdlet's `-Filter` over piping to `Where-Object` on large sets. +- **Make a genuine constant read-only.** For a value that must not change once set, declare it with `Set-Variable -Name Pi -Value 3.14159 -Option ReadOnly` (or `-Option Constant`), so an accidental reassignment fails loudly instead of quietly winning. - **Keep secrets out of source, and never `Invoke-Expression` untrusted input.** Accept credentials as a `[PSCredential]` parameter with the `[Credential()]` attribute rather than calling `Get-Credential` inside a reusable function, so a caller can pass one they already hold, and take other sensitive values as `[securestring]`. Guard state-changing commands with `ShouldProcess` (see [Functions](Functions.md)); the wider rules live in the [Security](../Security.md) baseline. ## Toolchain From c9a2a3a7cd8af85acd3d0311ccd1c11372554735 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 15:17:31 +0200 Subject: [PATCH 03/23] Name the F#/N# bold requirement-identifier convention in Spec-Driven Development --- src/docs/Ways-of-Working/Spec-Driven-Development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development.md b/src/docs/Ways-of-Working/Spec-Driven-Development.md index 8e1e58c..3b9762e 100644 --- a/src/docs/Ways-of-Working/Spec-Driven-Development.md +++ b/src/docs/Ways-of-Working/Spec-Driven-Development.md @@ -61,9 +61,9 @@ The altitude test: push detail *down* into the design, and push scope *up* into Requirements are testable statements of what must be true — never how it is built. -**Functional** requirements describe what the capability does, as observable behavior. Number them so the design and the tests can trace back to each one. +**Functional** requirements describe what the capability does, as observable behavior. Label each in bold — `F1`, `F2`, `F3` — so the design and the tests can trace back to each one. -**Non-functional** requirements are the quality attributes the capability must hold — performance, security, reliability, availability, compliance, observability, and cost. State each as a measurable condition with a threshold; a non-functional requirement without a number is an opinion. For platform and infrastructure work these are often the point of the change rather than an afterthought — latency, redaction, retention, and blast radius decide whether the thing is fit to run. +**Non-functional** requirements are the quality attributes the capability must hold — performance, security, reliability, availability, compliance, observability, and cost. Label each in bold — `N1`, `N2`, `N3` — and state it as a measurable condition with a threshold; a non-functional requirement without a number is an opinion. For platform and infrastructure work these are often the point of the change rather than an afterthought — latency, redaction, retention, and blast radius decide whether the thing is fit to run. The [acceptance criteria](#acceptance-criteria) verify these requirements, and every requirement has at least one. From 97e602827aaeae2f9fd8d2052f55bc1111b1ca7a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 15:24:50 +0200 Subject: [PATCH 04/23] Render requirement identifiers in bold in Spec-Driven Development --- src/docs/Ways-of-Working/Spec-Driven-Development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development.md b/src/docs/Ways-of-Working/Spec-Driven-Development.md index 3b9762e..5649d00 100644 --- a/src/docs/Ways-of-Working/Spec-Driven-Development.md +++ b/src/docs/Ways-of-Working/Spec-Driven-Development.md @@ -61,9 +61,9 @@ The altitude test: push detail *down* into the design, and push scope *up* into Requirements are testable statements of what must be true — never how it is built. -**Functional** requirements describe what the capability does, as observable behavior. Label each in bold — `F1`, `F2`, `F3` — so the design and the tests can trace back to each one. +**Functional** requirements describe what the capability does, as observable behavior. Label each in bold — **F1**, **F2**, **F3** — so the design and the tests can trace back to each one. -**Non-functional** requirements are the quality attributes the capability must hold — performance, security, reliability, availability, compliance, observability, and cost. Label each in bold — `N1`, `N2`, `N3` — and state it as a measurable condition with a threshold; a non-functional requirement without a number is an opinion. For platform and infrastructure work these are often the point of the change rather than an afterthought — latency, redaction, retention, and blast radius decide whether the thing is fit to run. +**Non-functional** requirements are the quality attributes the capability must hold — performance, security, reliability, availability, compliance, observability, and cost. Label each in bold — **N1**, **N2**, **N3** — and state it as a measurable condition with a threshold; a non-functional requirement without a number is an opinion. For platform and infrastructure work these are often the point of the change rather than an afterthought — latency, redaction, retention, and blast radius decide whether the thing is fit to run. The [acceptance criteria](#acceptance-criteria) verify these requirements, and every requirement has at least one. From e640c27973718060fbd98115d69f1521c64cd1dd Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 15:24:50 +0200 Subject: [PATCH 05/23] Clarify read-only versus constant in the PowerShell constants idiom --- src/docs/Coding-Standards/PowerShell/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/Coding-Standards/PowerShell/index.md b/src/docs/Coding-Standards/PowerShell/index.md index c2308cd..0c7e9f3 100644 --- a/src/docs/Coding-Standards/PowerShell/index.md +++ b/src/docs/Coding-Standards/PowerShell/index.md @@ -60,7 +60,7 @@ Beyond the basics, these language-specific habits keep PowerShell correct and fa - **Match text with the operator built for it.** Use `-like` for wildcard patterns and `-match` for regular expressions instead of hand-rolled string surgery; both default to case-insensitive, so add the `-c` prefix (`-clike`, `-cmatch`, `-ceq`) when a comparison must be case-sensitive. - **Suppress unwanted output with `$null = ...`** (or `[void]` for method calls), not `| Out-Null` — the pipeline form is markedly slower on hot paths. - **Build collections with a typed list, not `+=` in a loop.** `$a += $x` reallocates the whole array every iteration; use `[System.Collections.Generic.List[T]]` with `.Add()`, and prefer a cmdlet's `-Filter` over piping to `Where-Object` on large sets. -- **Make a genuine constant read-only.** For a value that must not change once set, declare it with `Set-Variable -Name Pi -Value 3.14159 -Option ReadOnly` (or `-Option Constant`), so an accidental reassignment fails loudly instead of quietly winning. +- **Guard a value that must not change.** Declare it with `Set-Variable -Name Pi -Value 3.14159 -Option ReadOnly` — or `-Option Constant` for one that can never be reassigned or removed — so an accidental write fails loudly instead of quietly winning. - **Keep secrets out of source, and never `Invoke-Expression` untrusted input.** Accept credentials as a `[PSCredential]` parameter with the `[Credential()]` attribute rather than calling `Get-Credential` inside a reusable function, so a caller can pass one they already hold, and take other sensitive values as `[securestring]`. Guard state-changing commands with `ShouldProcess` (see [Functions](Functions.md)); the wider rules live in the [Security](../Security.md) baseline. ## Toolchain From cf16a05143d653bfbdba124ab842a72a1beaf4dd Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 15:29:19 +0200 Subject: [PATCH 06/23] Align spec template requirement identifiers with the no-period bold convention --- src/docs/Ways-of-Working/Spec-Driven-Development.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development.md b/src/docs/Ways-of-Working/Spec-Driven-Development.md index 5649d00..efdadb9 100644 --- a/src/docs/Ways-of-Working/Spec-Driven-Development.md +++ b/src/docs/Ways-of-Working/Spec-Driven-Development.md @@ -168,13 +168,13 @@ Copy these skeletons to start a `spec.md` and its `design.md`. Every section is ### Functional -- **F1.** -- **F2.** <...> +- **F1** +- **F2** <...> ### Non-functional -- **N1.** -- **N2.** <...> +- **N1** +- **N2** <...> ## Acceptance criteria From da0de18729ed2b608b2cdc8bf334335115bf5352 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 17:35:49 +0200 Subject: [PATCH 07/23] Adopt FR/NFR identifiers with stable {#id} anchors, BCP 14 language, and append-only IDs in the spec guide --- .../Spec-Driven-Development.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development.md b/src/docs/Ways-of-Working/Spec-Driven-Development.md index efdadb9..0186bb9 100644 --- a/src/docs/Ways-of-Working/Spec-Driven-Development.md +++ b/src/docs/Ways-of-Working/Spec-Driven-Development.md @@ -59,13 +59,13 @@ The altitude test: push detail *down* into the design, and push scope *up* into ## Requirements -Requirements are testable statements of what must be true — never how it is built. +Requirements are testable statements of what must be true — never how it is built. Write them with the [BCP 14](https://www.rfc-editor.org/info/bcp14) keywords — **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **MAY** — in uppercase, where they carry their normative meaning ([RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)). -**Functional** requirements describe what the capability does, as observable behavior. Label each in bold — **F1**, **F2**, **F3** — so the design and the tests can trace back to each one. +**Functional** requirements describe what the capability does, as observable behavior. **Non-functional** requirements are the quality attributes the capability must hold — performance, security, reliability, availability, compliance, observability, and cost — each stated as a measurable condition with a threshold; a non-functional requirement without a number is an opinion. For platform and infrastructure work these are often the point of the change rather than an afterthought — latency, redaction, retention, and blast radius decide whether the thing is fit to run. -**Non-functional** requirements are the quality attributes the capability must hold — performance, security, reliability, availability, compliance, observability, and cost. Label each in bold — **N1**, **N2**, **N3** — and state it as a measurable condition with a threshold; a non-functional requirement without a number is an opinion. For platform and infrastructure work these are often the point of the change rather than an afterthought — latency, redaction, retention, and blast radius decide whether the thing is fit to run. +Give each requirement its own heading with a stable, explicit anchor — `### FR1 — { #fr1 }` for functional, `### NFR1 — { #nfr1 }` for non-functional. The anchor is the identifier alone, so the heading can be reworded without breaking a single reference. Identifiers are **append-only**: assign the next unused number, never renumber, and never reuse — a removed requirement simply disappears, and git holds the history. -The [acceptance criteria](#acceptance-criteria) verify these requirements, and every requirement has at least one. +Reference a requirement by its anchor — `[FR1](#fr1)` on the same page, `[FR1](spec.md#fr1)` across pages. The [acceptance criteria](#acceptance-criteria) verify these requirements, and every requirement has at least one. ## Acceptance criteria @@ -164,17 +164,17 @@ Copy these skeletons to start a `spec.md` and its `design.md`. Every section is - <...> -## Requirements +## Functional requirements + +### FR1 — { #fr1 } -### Functional +### FR2 — <...> { #fr2 } -- **F1** -- **F2** <...> +## Non-functional requirements -### Non-functional +### NFR1 — { #nfr1 } -- **N1** -- **N2** <...> +### NFR2 — <...> { #nfr2 } ## Acceptance criteria From ddbbc415dcf1db07adddf4e2a1139e8ce40d7ae7 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 18:52:54 +0200 Subject: [PATCH 08/23] Recognize explicit attr_list heading ids in the documentation link checker --- .github/scripts/Test-DocumentationLink.ps1 | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index cb99490..8d41cb8 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -14,7 +14,8 @@ - A heading anchor ('target.md#section', or a same-page '#section') must match a heading in the target file. Slugs are computed the same way the site's Markdown processor does, including the '_1', '_2' suffixes for duplicate - headings. + headings; an explicit attr_list id ('## Heading { #id }') is recognised as + the heading's anchor. External links (http, https, mailto, tel), absolute paths, links inside fenced code blocks, and links inside inline code spans are ignored on purpose. @@ -49,7 +50,10 @@ function ConvertTo-Slug { function Get-HeadingSlug { param([string]$Path) # The anchor slugs a page exposes, matching the duplicate-slug suffixing - # ('_1', '_2', ...) the Markdown processor applies to repeated headings. + # ('_1', '_2', ...) the Markdown processor applies to repeated headings. A + # heading may also carry an explicit attr_list id ('## Heading { #id }'), + # which the site renderer uses as the anchor verbatim, overriding the text + # slug; recognise those so links to '#id' validate. $slugs = [System.Collections.Generic.List[string]]::new() $seen = @{} $inFence = $false @@ -57,7 +61,14 @@ function Get-HeadingSlug { if ($line -match '^\s*```') { $inFence = -not $inFence; continue } if ($inFence) { continue } if ($line -match '^#{1,6}\s+(.+?)\s*$') { - $base = ConvertTo-Slug $matches[1] + $text = $matches[1] + # An explicit attr_list id ('{ #id }' or '{: #id ... }') wins over + # the text slug, exactly as python-markdown's attr_list assigns it. + if ($text -match '\{\s*:?\s*#([-\w]+)[^}]*\}\s*$') { + $slugs.Add($matches[1]) + continue + } + $base = ConvertTo-Slug $text if (-not $base) { continue } if ($seen.ContainsKey($base)) { $seen[$base]++; $slugs.Add("${base}_$($seen[$base])") } else { $seen[$base] = 0; $slugs.Add($base) } From 3c6490c23234491d06267b5f9aa266a59c9f783f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 19:01:01 +0200 Subject: [PATCH 09/23] Clarify the BCP 14 keyword set is not exhaustive in the spec guide --- src/docs/Ways-of-Working/Spec-Driven-Development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development.md b/src/docs/Ways-of-Working/Spec-Driven-Development.md index 0186bb9..24134d0 100644 --- a/src/docs/Ways-of-Working/Spec-Driven-Development.md +++ b/src/docs/Ways-of-Working/Spec-Driven-Development.md @@ -59,7 +59,7 @@ The altitude test: push detail *down* into the design, and push scope *up* into ## Requirements -Requirements are testable statements of what must be true — never how it is built. Write them with the [BCP 14](https://www.rfc-editor.org/info/bcp14) keywords — **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **MAY** — in uppercase, where they carry their normative meaning ([RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)). +Requirements are testable statements of what must be true — never how it is built. Write them with the [BCP 14](https://www.rfc-editor.org/info/bcp14) keywords — **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **MAY**, and the rest of the set — in uppercase, where they carry their normative meaning ([RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)). **Functional** requirements describe what the capability does, as observable behavior. **Non-functional** requirements are the quality attributes the capability must hold — performance, security, reliability, availability, compliance, observability, and cost — each stated as a measurable condition with a threshold; a non-functional requirement without a number is an opinion. For platform and infrastructure work these are often the point of the change rather than an afterthought — latency, redaction, retention, and blast radius decide whether the thing is fit to run. From 62e0a16faede65eba200136332bc490d112cf175 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 5 Jul 2026 19:01:01 +0200 Subject: [PATCH 10/23] Validate reference-style link definitions in the documentation link checker --- .github/scripts/Test-DocumentationLink.ps1 | 60 ++++++++++++++-------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 8d41cb8..8011196 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -85,7 +85,40 @@ function Get-CachedSlug { return $slugCache[$Path] } +function Test-LinkTarget { + # Validate a single relative link target (inline or reference-style), adding + # a message to $Broken when the file or its heading anchor does not resolve. + param( + [string]$Target, + [System.IO.FileInfo]$File, + [string]$Rel, + [int]$LineNo, + [System.Collections.Generic.List[string]]$Broken + ) + $t = ($Target.Trim() -replace '\s+"[^"]*"$', '') -replace '^<', '' -replace '>$', '' + if (-not $t) { return } + if ($t -match '^(https?:|mailto:|tel:|//)') { return } + $path, $frag = $t -split '#', 2 + if (-not $path) { + if ($frag -and ($frag -notin (Get-CachedSlug $File.FullName))) { + $Broken.Add("${Rel}:${LineNo}: '#$frag' - no heading with that anchor on this page") + } + return + } + if ($path.StartsWith('/')) { return } # absolute site path - not resolvable here + $resolved = [System.IO.Path]::GetFullPath((Join-Path $File.DirectoryName $path)) + if (-not (Test-Path -LiteralPath $resolved)) { + $Broken.Add("${Rel}:${LineNo}: '$Target' - target does not exist") + return + } + if ($frag -and $resolved.EndsWith('.md') -and ($frag -notin (Get-CachedSlug $resolved))) { + $Broken.Add("${Rel}:${LineNo}: '$Target' - no heading '#$frag' in the target file") + } +} + +# Inline links '[text](target)' and reference-style definitions '[label]: target'. $linkPattern = '\[[^\]]*\]\(([^)]+)\)' +$refDefPattern = '^\s*\[[^\]]+\]:\s+(\S+)' $broken = [System.Collections.Generic.List[string]]::new() foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md | Sort-Object FullName)) { @@ -98,27 +131,14 @@ foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md if ($inFence) { continue } # Remove inline code spans so links shown as examples are not validated. $scrubbed = $line -replace '`[^`]*`', '' + $lineNo = $n + 1 foreach ($m in [regex]::Matches($scrubbed, $linkPattern)) { - $target = $m.Groups[1].Value.Trim() -replace '\s+"[^"]*"$', '' # strip optional link title - if (-not $target) { continue } - if ($target -match '^(https?:|mailto:|tel:|//)') { continue } - $lineNo = $n + 1 - $path, $frag = $target -split '#', 2 - if (-not $path) { - if ($frag -and ($frag -notin (Get-CachedSlug $file.FullName))) { - $broken.Add("${rel}:${lineNo}: '#$frag' - no heading with that anchor on this page") - } - continue - } - if ($path.StartsWith('/')) { continue } # absolute site path - not resolvable here - $resolved = [System.IO.Path]::GetFullPath((Join-Path $file.DirectoryName $path)) - if (-not (Test-Path -LiteralPath $resolved)) { - $broken.Add("${rel}:${lineNo}: '$target' - target does not exist") - continue - } - if ($frag -and $resolved.EndsWith('.md') -and ($frag -notin (Get-CachedSlug $resolved))) { - $broken.Add("${rel}:${lineNo}: '$target' - no heading '#$frag' in the target file") - } + Test-LinkTarget -Target $m.Groups[1].Value -File $file -Rel $rel -LineNo $lineNo -Broken $broken + } + # Reference-style link definitions ('[label]: target') carry a relative + # target too; validate it the same way so those links do not slip past CI. + if ($scrubbed -match $refDefPattern) { + Test-LinkTarget -Target $matches[1] -File $file -Rel $rel -LineNo $lineNo -Broken $broken } } } From a7751390e215594ce57f5a5df76a38369b3adcc0 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 01:44:39 +0200 Subject: [PATCH 11/23] Add the 'prefer .NET for the actual work' principle to the PowerShell standard --- src/docs/Coding-Standards/PowerShell/index.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/docs/Coding-Standards/PowerShell/index.md b/src/docs/Coding-Standards/PowerShell/index.md index 0c7e9f3..79ae248 100644 --- a/src/docs/Coding-Standards/PowerShell/index.md +++ b/src/docs/Coding-Standards/PowerShell/index.md @@ -63,6 +63,14 @@ Beyond the basics, these language-specific habits keep PowerShell correct and fa - **Guard a value that must not change.** Declare it with `Set-Variable -Name Pi -Value 3.14159 -Option ReadOnly` — or `-Option Constant` for one that can never be reassigned or removed — so an accidental write fails loudly instead of quietly winning. - **Keep secrets out of source, and never `Invoke-Expression` untrusted input.** Accept credentials as a `[PSCredential]` parameter with the `[Credential()]` attribute rather than calling `Get-Credential` inside a reusable function, so a caller can pass one they already hold, and take other sensitive values as `[securestring]`. Guard state-changing commands with `ShouldProcess` (see [Functions](Functions.md)); the wider rules live in the [Security](../Security.md) baseline. +## Prefer .NET for the actual work + +Cmdlets and the pipeline are for orchestration and glue; reach for the .NET base class library to do the real work when performance or precise behaviour matters — a .NET call is faster than a cmdlet pipeline and its contract is exact. + +- **Call .NET on hot paths.** `[System.IO.File]::ReadAllText($path)` over `Get-Content -Raw`, `[System.Text.StringBuilder]` for heavy string building, and `[System.IO.File]::Exists($path)` / `[System.IO.Directory]::Exists($path)` over `Test-Path` when a plain filesystem check is all you need — the typed-list and `$null =` idioms above are the same instinct. +- **Use .NET for exact parsing and paths.** `[int]::TryParse(...)`, `[datetime]::ParseExact(...)`, and `[System.IO.Path]::Combine(...)` / `GetFullPath(...)` where operator or cmdlet behaviour is looser than you need; pass full paths to .NET calls (see [Scripts](Scripts.md)). +- **Keep cmdlets where clarity wins.** Do not rewrite readable, one-time glue in .NET to save microseconds that do not matter — reach for .NET where the work is hot or the behaviour must be exact. + ## Toolchain The toolchain enforces this standard in CI — it does not define it. The rules above are the source of truth; each tool's configuration is derived from them: From 96e375ce58224aa2617fd5ba99d2554c30fa8214 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 01:44:39 +0200 Subject: [PATCH 12/23] Use native .NET for path resolution and existence checks in the link checker --- .github/scripts/Test-DocumentationLink.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 8011196..52b99f6 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -106,8 +106,8 @@ function Test-LinkTarget { return } if ($path.StartsWith('/')) { return } # absolute site path - not resolvable here - $resolved = [System.IO.Path]::GetFullPath((Join-Path $File.DirectoryName $path)) - if (-not (Test-Path -LiteralPath $resolved)) { + $resolved = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($File.DirectoryName, $path)) + if (-not ([System.IO.File]::Exists($resolved) -or [System.IO.Directory]::Exists($resolved))) { $Broken.Add("${Rel}:${LineNo}: '$Target' - target does not exist") return } From d889b58ade0db898253fd9fee8f149cca47e309e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 01:50:01 +0200 Subject: [PATCH 13/23] Fully qualify [System.IO.Path]::GetFullPath in the PowerShell standard --- src/docs/Coding-Standards/PowerShell/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/Coding-Standards/PowerShell/index.md b/src/docs/Coding-Standards/PowerShell/index.md index 79ae248..6468347 100644 --- a/src/docs/Coding-Standards/PowerShell/index.md +++ b/src/docs/Coding-Standards/PowerShell/index.md @@ -68,7 +68,7 @@ Beyond the basics, these language-specific habits keep PowerShell correct and fa Cmdlets and the pipeline are for orchestration and glue; reach for the .NET base class library to do the real work when performance or precise behaviour matters — a .NET call is faster than a cmdlet pipeline and its contract is exact. - **Call .NET on hot paths.** `[System.IO.File]::ReadAllText($path)` over `Get-Content -Raw`, `[System.Text.StringBuilder]` for heavy string building, and `[System.IO.File]::Exists($path)` / `[System.IO.Directory]::Exists($path)` over `Test-Path` when a plain filesystem check is all you need — the typed-list and `$null =` idioms above are the same instinct. -- **Use .NET for exact parsing and paths.** `[int]::TryParse(...)`, `[datetime]::ParseExact(...)`, and `[System.IO.Path]::Combine(...)` / `GetFullPath(...)` where operator or cmdlet behaviour is looser than you need; pass full paths to .NET calls (see [Scripts](Scripts.md)). +- **Use .NET for exact parsing and paths.** `[int]::TryParse(...)`, `[datetime]::ParseExact(...)`, and `[System.IO.Path]::Combine(...)` / `[System.IO.Path]::GetFullPath(...)` where operator or cmdlet behaviour is looser than you need; pass full paths to .NET calls (see [Scripts](Scripts.md)). - **Keep cmdlets where clarity wins.** Do not rewrite readable, one-time glue in .NET to save microseconds that do not matter — reach for .NET where the work is hot or the behaviour must be exact. ## Toolchain From 033f133d029dbb68686bb12f9ce0b2ebc88b8f52 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 01:50:02 +0200 Subject: [PATCH 14/23] Report the normalized link target in link-checker error messages --- .github/scripts/Test-DocumentationLink.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 52b99f6..12d879e 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -108,11 +108,11 @@ function Test-LinkTarget { if ($path.StartsWith('/')) { return } # absolute site path - not resolvable here $resolved = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($File.DirectoryName, $path)) if (-not ([System.IO.File]::Exists($resolved) -or [System.IO.Directory]::Exists($resolved))) { - $Broken.Add("${Rel}:${LineNo}: '$Target' - target does not exist") + $Broken.Add("${Rel}:${LineNo}: '$t' - target does not exist") return } if ($frag -and $resolved.EndsWith('.md') -and ($frag -notin (Get-CachedSlug $resolved))) { - $Broken.Add("${Rel}:${LineNo}: '$Target' - no heading '#$frag' in the target file") + $Broken.Add("${Rel}:${LineNo}: '$t' - no heading '#$frag' in the target file") } } From 35334b944ddee43b580f3b647f652f85c1e9abc3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 10:37:57 +0200 Subject: [PATCH 15/23] Conform the documentation link checker to the PowerShell coding standard Add the space between type and parameter name, emit the finding from Get-LinkTargetIssue (renamed from Test-LinkTarget) instead of mutating a passed-in list through a read-only Test verb, and drop the non-ASCII filter pipeline in favour of a -replace. --- .github/scripts/Test-DocumentationLink.ps1 | 35 +++++++++++----------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 12d879e..6fd7b80 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -38,17 +38,17 @@ $Root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $Docs = Join-Path $Root 'src/docs' function ConvertTo-Slug { - param([string]$Heading) + param([string] $Heading) # Mirror the site's Markdown TOC slugifier (python-markdown default): drop # non-ASCII, remove punctuation except word characters / whitespace / hyphen, # lowercase, then collapse whitespace and hyphen runs into a single hyphen. - $ascii = -join ([char[]] $Heading | Where-Object { [int] $_ -lt 128 }) + $ascii = $Heading -replace '[^\x00-\x7F]', '' $clean = ($ascii -replace '[^\w\s-]', '').Trim().ToLowerInvariant() return ($clean -replace '[\s-]+', '-') } function Get-HeadingSlug { - param([string]$Path) + param([string] $Path) # The anchor slugs a page exposes, matching the duplicate-slug suffixing # ('_1', '_2', ...) the Markdown processor applies to repeated headings. A # heading may also carry an explicit attr_list id ('## Heading { #id }'), @@ -80,20 +80,19 @@ function Get-HeadingSlug { # Parse each target file's anchors once. $slugCache = @{} function Get-CachedSlug { - param([string]$Path) + param([string] $Path) if (-not $slugCache.ContainsKey($Path)) { $slugCache[$Path] = Get-HeadingSlug $Path } return $slugCache[$Path] } -function Test-LinkTarget { - # Validate a single relative link target (inline or reference-style), adding - # a message to $Broken when the file or its heading anchor does not resolve. +function Get-LinkTargetIssue { + # Return a human-readable problem for a single relative link target (inline + # or reference-style), or nothing when the file and its anchor resolve. param( - [string]$Target, - [System.IO.FileInfo]$File, - [string]$Rel, - [int]$LineNo, - [System.Collections.Generic.List[string]]$Broken + [string] $Target, + [System.IO.FileInfo] $File, + [string] $Rel, + [int] $LineNo ) $t = ($Target.Trim() -replace '\s+"[^"]*"$', '') -replace '^<', '' -replace '>$', '' if (-not $t) { return } @@ -101,18 +100,18 @@ function Test-LinkTarget { $path, $frag = $t -split '#', 2 if (-not $path) { if ($frag -and ($frag -notin (Get-CachedSlug $File.FullName))) { - $Broken.Add("${Rel}:${LineNo}: '#$frag' - no heading with that anchor on this page") + "${Rel}:${LineNo}: '#$frag' - no heading with that anchor on this page" } return } if ($path.StartsWith('/')) { return } # absolute site path - not resolvable here $resolved = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($File.DirectoryName, $path)) if (-not ([System.IO.File]::Exists($resolved) -or [System.IO.Directory]::Exists($resolved))) { - $Broken.Add("${Rel}:${LineNo}: '$t' - target does not exist") + "${Rel}:${LineNo}: '$t' - target does not exist" return } if ($frag -and $resolved.EndsWith('.md') -and ($frag -notin (Get-CachedSlug $resolved))) { - $Broken.Add("${Rel}:${LineNo}: '$t' - no heading '#$frag' in the target file") + "${Rel}:${LineNo}: '$t' - no heading '#$frag' in the target file" } } @@ -133,12 +132,14 @@ foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md $scrubbed = $line -replace '`[^`]*`', '' $lineNo = $n + 1 foreach ($m in [regex]::Matches($scrubbed, $linkPattern)) { - Test-LinkTarget -Target $m.Groups[1].Value -File $file -Rel $rel -LineNo $lineNo -Broken $broken + $issue = Get-LinkTargetIssue -Target $m.Groups[1].Value -File $file -Rel $rel -LineNo $lineNo + if ($issue) { $broken.Add($issue) } } # Reference-style link definitions ('[label]: target') carry a relative # target too; validate it the same way so those links do not slip past CI. if ($scrubbed -match $refDefPattern) { - Test-LinkTarget -Target $matches[1] -File $file -Rel $rel -LineNo $lineNo -Broken $broken + $issue = Get-LinkTargetIssue -Target $matches[1] -File $file -Rel $rel -LineNo $lineNo + if ($issue) { $broken.Add($issue) } } } } From 4cdc77579b2ba451ed0b1673ef2984ae3ede8459 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 10:43:53 +0200 Subject: [PATCH 16/23] Validate anchor fragments case-sensitively in the link checker --- .github/scripts/Test-DocumentationLink.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 6fd7b80..7fb1a7f 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -99,7 +99,7 @@ function Get-LinkTargetIssue { if ($t -match '^(https?:|mailto:|tel:|//)') { return } $path, $frag = $t -split '#', 2 if (-not $path) { - if ($frag -and ($frag -notin (Get-CachedSlug $File.FullName))) { + if ($frag -and ($frag -cnotin (Get-CachedSlug $File.FullName))) { "${Rel}:${LineNo}: '#$frag' - no heading with that anchor on this page" } return @@ -110,7 +110,7 @@ function Get-LinkTargetIssue { "${Rel}:${LineNo}: '$t' - target does not exist" return } - if ($frag -and $resolved.EndsWith('.md') -and ($frag -notin (Get-CachedSlug $resolved))) { + if ($frag -and $resolved.EndsWith('.md') -and ($frag -cnotin (Get-CachedSlug $resolved))) { "${Rel}:${LineNo}: '$t' - no heading '#$frag' in the target file" } } From 22274996d2f4336b415d0bda9976bfdc319e9f2e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 10:48:25 +0200 Subject: [PATCH 17/23] Support angle-bracketed reference-style destinations in the link checker --- .github/scripts/Test-DocumentationLink.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 7fb1a7f..10cf41e 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -116,8 +116,10 @@ function Get-LinkTargetIssue { } # Inline links '[text](target)' and reference-style definitions '[label]: target'. +# The definition destination is either an angle-bracketed path (which may contain +# spaces) or a bare non-whitespace token. $linkPattern = '\[[^\]]*\]\(([^)]+)\)' -$refDefPattern = '^\s*\[[^\]]+\]:\s+(\S+)' +$refDefPattern = '^\s*\[[^\]]+\]:\s+(<[^>]+>|\S+)' $broken = [System.Collections.Generic.List[string]]::new() foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md | Sort-Object FullName)) { From a144b198235296be4d6600f59ec8050298218c7e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 11:29:48 +0200 Subject: [PATCH 18/23] Require a [Parameter()] attribute and a blank line per parameter, and comment-based help on every function --- src/docs/Coding-Standards/PowerShell/Functions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/docs/Coding-Standards/PowerShell/Functions.md b/src/docs/Coding-Standards/PowerShell/Functions.md index d6f51cd..750caf3 100644 --- a/src/docs/Coding-Standards/PowerShell/Functions.md +++ b/src/docs/Coding-Standards/PowerShell/Functions.md @@ -54,7 +54,9 @@ function Get-UserData { ## Parameters - **Type every parameter** and validate at the boundary — `[Parameter(Mandatory)]`, `[ValidateSet(...)]`, `[ValidateNotNullOrEmpty()]` — so bad input is rejected early, not deep in the call stack. +- **Give every parameter a `[Parameter()]` attribute**, even when it carries no arguments — it is what turns the function advanced and where `Mandatory`, `ValueFromPipeline`, and the rest attach. - **Attribute order**, each on its own line: `[Parameter()]`, then validation attributes, then `[ArgumentCompleter()]`, then `[Alias()]`, then the typed declaration. +- **Separate parameters with a blank line**, so each one's inline doc comment, attributes, and typed declaration read as a single block. - **`[switch]` for boolean flags** — never a `[bool]` parameter. - **Name every parameter set** with an intent-revealing name when a function has more than one mode; never `Default` or `__AllParameterSets`. Set `DefaultParameterSetName` to the most common intent. @@ -84,4 +86,4 @@ Send each kind of message to the stream built for it, so a caller can capture, r ## Comment-based help (required) -Every public function carries comment-based help, first inside the body, with sections in this order: `.SYNOPSIS` (one imperative sentence), `.DESCRIPTION`, at least one `.EXAMPLE` per behaviour, then `.INPUTS`, `.OUTPUTS` (matching `[OutputType()]`), `.NOTES`, `.LINK`. Document each parameter with an inline comment above it rather than a `.PARAMETER` block, and let comments explain *why*, not *what*. +Every function carries comment-based help, first inside the body, with sections in this order: `.SYNOPSIS` (one imperative sentence), `.DESCRIPTION`, at least one `.EXAMPLE` per behaviour, then `.INPUTS`, `.OUTPUTS` (matching `[OutputType()]`), `.NOTES`, `.LINK`. Document each parameter with an inline comment above it rather than a `.PARAMETER` block, and let comments explain *why*, not *what*. From 5ca4264183a4b77256a080472d9d32dd8173be59 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 11:29:48 +0200 Subject: [PATCH 19/23] Add comment-based help and full parameter blocks to the link checker's functions --- .github/scripts/Test-DocumentationLink.ps1 | 113 ++++++++++++++++++--- 1 file changed, 99 insertions(+), 14 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 10cf41e..c39b30e 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -38,22 +38,60 @@ $Root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $Docs = Join-Path $Root 'src/docs' function ConvertTo-Slug { - param([string] $Heading) - # Mirror the site's Markdown TOC slugifier (python-markdown default): drop - # non-ASCII, remove punctuation except word characters / whitespace / hyphen, - # lowercase, then collapse whitespace and hyphen runs into a single hyphen. + <# + .SYNOPSIS + Convert a heading to the anchor slug the site's Markdown processor emits. + + .DESCRIPTION + Mirror python-markdown's default TOC slugifier: drop non-ASCII characters, + remove punctuation except word characters, whitespace, and hyphens, + lowercase the result, then collapse whitespace and hyphen runs into a + single hyphen. + + .EXAMPLE + ConvertTo-Slug -Heading 'Prefer .NET for the actual work' + Returns 'prefer-net-for-the-actual-work'. + + .OUTPUTS + [string] + #> + [CmdletBinding()] + param( + # The heading text to slugify. + [Parameter(Mandatory)] + [string] $Heading + ) $ascii = $Heading -replace '[^\x00-\x7F]', '' $clean = ($ascii -replace '[^\w\s-]', '').Trim().ToLowerInvariant() return ($clean -replace '[\s-]+', '-') } function Get-HeadingSlug { - param([string] $Path) - # The anchor slugs a page exposes, matching the duplicate-slug suffixing - # ('_1', '_2', ...) the Markdown processor applies to repeated headings. A - # heading may also carry an explicit attr_list id ('## Heading { #id }'), - # which the site renderer uses as the anchor verbatim, overriding the text - # slug; recognise those so links to '#id' validate. + <# + .SYNOPSIS + Get the anchor slugs a Markdown file exposes. + + .DESCRIPTION + Return each heading's anchor, matching the duplicate-slug suffixing + ('_1', '_2', ...) the Markdown processor applies to repeated headings. A + heading may also carry an explicit attr_list id ('## Heading { #id }'), + which the site renderer uses as the anchor verbatim, overriding the text + slug; those are recognised so links to '#id' validate. Fenced code blocks + are skipped. + + .EXAMPLE + Get-HeadingSlug -Path ./src/docs/index.md + Returns the anchor slugs and explicit ids defined in index.md. + + .OUTPUTS + [System.Collections.Generic.List[string]] + #> + [CmdletBinding()] + param( + # Path to the Markdown file to scan for heading anchors. + [Parameter(Mandatory)] + [string] $Path + ) $slugs = [System.Collections.Generic.List[string]]::new() $seen = @{} $inFence = $false @@ -77,21 +115,68 @@ function Get-HeadingSlug { return $slugs } -# Parse each target file's anchors once. $slugCache = @{} function Get-CachedSlug { - param([string] $Path) + <# + .SYNOPSIS + Get a file's heading slugs, parsing each file only once. + + .DESCRIPTION + Memoise Get-HeadingSlug in the script-scoped $slugCache so a file that is + linked from many places is scanned a single time. + + .EXAMPLE + Get-CachedSlug -Path ./src/docs/index.md + Returns index.md's anchor slugs, reading the file only on the first call. + + .OUTPUTS + [System.Collections.Generic.List[string]] + #> + [CmdletBinding()] + param( + # Path to the Markdown file whose slugs are wanted. + [Parameter(Mandatory)] + [string] $Path + ) if (-not $slugCache.ContainsKey($Path)) { $slugCache[$Path] = Get-HeadingSlug $Path } return $slugCache[$Path] } function Get-LinkTargetIssue { - # Return a human-readable problem for a single relative link target (inline - # or reference-style), or nothing when the file and its anchor resolve. + <# + .SYNOPSIS + Get the problem with a single relative Markdown link target, if any. + + .DESCRIPTION + Validate one inline or reference-style link target: external links, + absolute site paths, and empty targets are ignored; a relative file must + exist; and a '#fragment' must match a heading anchor (case-sensitively) + either in the target file or on the same page. Return a human-readable + message when the target does not resolve, or nothing when it is valid. + + .EXAMPLE + Get-LinkTargetIssue -Target '../reference/bar.md#setup' -File $file -Rel 'docs/foo.md' -LineNo 12 + Returns a message when bar.md or its '#setup' anchor is missing, otherwise nothing. + + .OUTPUTS + [string] + #> + [CmdletBinding()] param( + # The raw link target - a destination and an optional '#fragment'. + [Parameter(Mandatory)] [string] $Target, + + # The Markdown file the link appears in, used to resolve relative paths. + [Parameter(Mandatory)] [System.IO.FileInfo] $File, + + # The file's repository-relative path, for the reported message. + [Parameter(Mandatory)] [string] $Rel, + + # The 1-based line number the link is on, for the reported message. + [Parameter(Mandatory)] [int] $LineNo ) $t = ($Target.Trim() -replace '\s+"[^"]*"$', '') -replace '^<', '' -replace '>$', '' From 000a7f0ba6135e59d712d78b5e326abc7da492b9 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 11:34:48 +0200 Subject: [PATCH 20/23] Scope comment-based help to public functions and match the Markdown extension case-insensitively --- .github/scripts/Test-DocumentationLink.ps1 | 2 +- src/docs/Coding-Standards/PowerShell/Functions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index c39b30e..15e03db 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -195,7 +195,7 @@ function Get-LinkTargetIssue { "${Rel}:${LineNo}: '$t' - target does not exist" return } - if ($frag -and $resolved.EndsWith('.md') -and ($frag -cnotin (Get-CachedSlug $resolved))) { + if ($frag -and $resolved.EndsWith('.md', [System.StringComparison]::OrdinalIgnoreCase) -and ($frag -cnotin (Get-CachedSlug $resolved))) { "${Rel}:${LineNo}: '$t' - no heading '#$frag' in the target file" } } diff --git a/src/docs/Coding-Standards/PowerShell/Functions.md b/src/docs/Coding-Standards/PowerShell/Functions.md index 750caf3..3ef75b0 100644 --- a/src/docs/Coding-Standards/PowerShell/Functions.md +++ b/src/docs/Coding-Standards/PowerShell/Functions.md @@ -86,4 +86,4 @@ Send each kind of message to the stream built for it, so a caller can capture, r ## Comment-based help (required) -Every function carries comment-based help, first inside the body, with sections in this order: `.SYNOPSIS` (one imperative sentence), `.DESCRIPTION`, at least one `.EXAMPLE` per behaviour, then `.INPUTS`, `.OUTPUTS` (matching `[OutputType()]`), `.NOTES`, `.LINK`. Document each parameter with an inline comment above it rather than a `.PARAMETER` block, and let comments explain *why*, not *what*. +Every public function carries comment-based help, first inside the body, with sections in this order: `.SYNOPSIS` (one imperative sentence), `.DESCRIPTION`, at least one `.EXAMPLE` per behaviour, then `.INPUTS`, `.OUTPUTS` (matching `[OutputType()]`), `.NOTES`, `.LINK`. Document each parameter with an inline comment above it rather than a `.PARAMETER` block, and let comments explain *why*, not *what*. From 41e15647ffc5ea9a226469f0d8d552280953dc36 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 11:42:19 +0200 Subject: [PATCH 21/23] Strip single-quoted and parenthesised link titles, not just double-quoted --- .github/scripts/Test-DocumentationLink.ps1 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 15e03db..8301c9d 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -179,7 +179,7 @@ function Get-LinkTargetIssue { [Parameter(Mandatory)] [int] $LineNo ) - $t = ($Target.Trim() -replace '\s+"[^"]*"$', '') -replace '^<', '' -replace '>$', '' + $t = ($Target.Trim() -replace '\s+("[^"]*"|''[^'']*''|\([^)]*\))$', '') -replace '^<', '' -replace '>$', '' if (-not $t) { return } if ($t -match '^(https?:|mailto:|tel:|//)') { return } $path, $frag = $t -split '#', 2 @@ -201,9 +201,11 @@ function Get-LinkTargetIssue { } # Inline links '[text](target)' and reference-style definitions '[label]: target'. -# The definition destination is either an angle-bracketed path (which may contain +# The inline target may carry an optional title ("...", '...', or (...)); the +# nested-paren alternative keeps a parenthesised title from being truncated. The +# definition destination is either an angle-bracketed path (which may contain # spaces) or a bare non-whitespace token. -$linkPattern = '\[[^\]]*\]\(([^)]+)\)' +$linkPattern = '\[[^\]]*\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)' $refDefPattern = '^\s*\[[^\]]+\]:\s+(<[^>]+>|\S+)' $broken = [System.Collections.Generic.List[string]]::new() From cf42bf45ed002a404f62ea344012240c5f8f1009 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 11:45:49 +0200 Subject: [PATCH 22/23] Describe what [Parameter()] does without the inaccurate advanced-function claim --- src/docs/Coding-Standards/PowerShell/Functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/Coding-Standards/PowerShell/Functions.md b/src/docs/Coding-Standards/PowerShell/Functions.md index 3ef75b0..d7a79a7 100644 --- a/src/docs/Coding-Standards/PowerShell/Functions.md +++ b/src/docs/Coding-Standards/PowerShell/Functions.md @@ -54,7 +54,7 @@ function Get-UserData { ## Parameters - **Type every parameter** and validate at the boundary — `[Parameter(Mandatory)]`, `[ValidateSet(...)]`, `[ValidateNotNullOrEmpty()]` — so bad input is rejected early, not deep in the call stack. -- **Give every parameter a `[Parameter()]` attribute**, even when it carries no arguments — it is what turns the function advanced and where `Mandatory`, `ValueFromPipeline`, and the rest attach. +- **Give every parameter a `[Parameter()]` attribute**, even when it carries no arguments — it is where `Mandatory`, `ValueFromPipeline`, and the rest attach, and it keeps every parameter declared the same way. - **Attribute order**, each on its own line: `[Parameter()]`, then validation attributes, then `[ArgumentCompleter()]`, then `[Alias()]`, then the typed declaration. - **Separate parameters with a blank line**, so each one's inline doc comment, attributes, and typed declaration read as a single block. - **`[switch]` for boolean flags** — never a `[bool]` parameter. From 04637dde0a43c2641cff704f7cfc8e11916d551b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 6 Jul 2026 13:01:56 +0200 Subject: [PATCH 23/23] Require comment-based help on every function, private included, and on scripts Documentation exists so a reader or agent can understand what a unit does and how to use it without reading its body, which is as true for an internal helper as for a public command. Broaden the Functions comment-based-help rule from public functions to every function, state in Scripts that a script starts with the same help structure as a function, and reconcile the Documentation standard so its public-surface minimum reads as a floor, not a ceiling. --- src/docs/Coding-Standards/Documentation.md | 2 ++ src/docs/Coding-Standards/PowerShell/Functions.md | 2 +- src/docs/Coding-Standards/PowerShell/Scripts.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/docs/Coding-Standards/Documentation.md b/src/docs/Coding-Standards/Documentation.md index a1d8e9d..b8a8cd5 100644 --- a/src/docs/Coding-Standards/Documentation.md +++ b/src/docs/Coding-Standards/Documentation.md @@ -47,6 +47,8 @@ Every public function, command, module, or API carries documentation at its boun Include, at minimum: what it does, its parameters, what it returns, and at least one example. Examples are worth a paragraph of prose each. +This is a floor, not a ceiling. Internal and private units — helper functions and the scripts that call them — carry the same native-format documentation, so the next maintainer or agent can understand a helper without reading its whole implementation. + ## The README is the front door Every repository has a README that is the single source of truth for what the repository is and does. It is **evergreen** — updated in the same pull request that changes behavior, never as a separate task. A feature that ships without a README update is not done. diff --git a/src/docs/Coding-Standards/PowerShell/Functions.md b/src/docs/Coding-Standards/PowerShell/Functions.md index d7a79a7..e6d1ccd 100644 --- a/src/docs/Coding-Standards/PowerShell/Functions.md +++ b/src/docs/Coding-Standards/PowerShell/Functions.md @@ -86,4 +86,4 @@ Send each kind of message to the stream built for it, so a caller can capture, r ## Comment-based help (required) -Every public function carries comment-based help, first inside the body, with sections in this order: `.SYNOPSIS` (one imperative sentence), `.DESCRIPTION`, at least one `.EXAMPLE` per behaviour, then `.INPUTS`, `.OUTPUTS` (matching `[OutputType()]`), `.NOTES`, `.LINK`. Document each parameter with an inline comment above it rather than a `.PARAMETER` block, and let comments explain *why*, not *what*. +Every function carries comment-based help — including internal and private helpers, not only the public surface. It is what lets a reader or an agent understand what the function does and how to call it without reading its body, and a private helper needs that as much as a public command does. Put it first inside the body, with sections in this order: `.SYNOPSIS` (one imperative sentence), `.DESCRIPTION`, at least one `.EXAMPLE` per behaviour, then `.INPUTS`, `.OUTPUTS` (matching `[OutputType()]`), `.NOTES`, `.LINK`. Document each parameter with an inline comment above it rather than a `.PARAMETER` block, and let comments explain *why*, not *what*. diff --git a/src/docs/Coding-Standards/PowerShell/Scripts.md b/src/docs/Coding-Standards/PowerShell/Scripts.md index 7eec938..98e726a 100644 --- a/src/docs/Coding-Standards/PowerShell/Scripts.md +++ b/src/docs/Coding-Standards/PowerShell/Scripts.md @@ -12,7 +12,7 @@ A script (`.ps1`) is an entry point, not a home for logic. Keep scripts **thin** A script file is laid out top to bottom in this order: 1. **`#Requires`** statements — PowerShell version and module dependencies with minimum versions. -2. **Comment-based help** — `.SYNOPSIS`, `.DESCRIPTION`, and at least one `.EXAMPLE`. +2. **Comment-based help** — the same sections and order as a [function's](Functions.md#comment-based-help-required), only without the enclosing `function` block: `.SYNOPSIS`, `.DESCRIPTION`, at least one `.EXAMPLE`, then `.INPUTS`, `.OUTPUTS`, `.NOTES`, and `.LINK` as they apply. Document each parameter with an inline comment above it, just as a function does. 3. **`[CmdletBinding()]` + `param()`** — typed and validated, mandatory first; add `SupportsShouldProcess` when the script changes state. 4. **`$ErrorActionPreference = 'Stop'`**. 5. **Body** — the thin orchestration.