Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b1f585d
Add reference-style link, backtick, and image alt-text guidance to th…
MariusStorhaug Jul 5, 2026
cdeb3dd
Add matching-operator and read-only-constant idioms to the PowerShell…
MariusStorhaug Jul 5, 2026
c9a2a3a
Name the F#/N# bold requirement-identifier convention in Spec-Driven …
MariusStorhaug Jul 5, 2026
97e6028
Render requirement identifiers in bold in Spec-Driven Development
MariusStorhaug Jul 5, 2026
e640c27
Clarify read-only versus constant in the PowerShell constants idiom
MariusStorhaug Jul 5, 2026
cf16a05
Align spec template requirement identifiers with the no-period bold c…
MariusStorhaug Jul 5, 2026
da0de18
Adopt FR/NFR identifiers with stable {#id} anchors, BCP 14 language, …
MariusStorhaug Jul 5, 2026
ddbbc41
Recognize explicit attr_list heading ids in the documentation link ch…
MariusStorhaug Jul 5, 2026
3c6490c
Clarify the BCP 14 keyword set is not exhaustive in the spec guide
MariusStorhaug Jul 5, 2026
62e0a16
Validate reference-style link definitions in the documentation link c…
MariusStorhaug Jul 5, 2026
a775139
Add the 'prefer .NET for the actual work' principle to the PowerShell…
MariusStorhaug Jul 5, 2026
96e375c
Use native .NET for path resolution and existence checks in the link …
MariusStorhaug Jul 5, 2026
d889b58
Fully qualify [System.IO.Path]::GetFullPath in the PowerShell standard
MariusStorhaug Jul 5, 2026
033f133
Report the normalized link target in link-checker error messages
MariusStorhaug Jul 5, 2026
c000fbb
Merge branch 'main' into docs/15-style-guide-authoring-gaps
MariusStorhaug Jul 6, 2026
35334b9
Conform the documentation link checker to the PowerShell coding standard
MariusStorhaug Jul 6, 2026
4cdc775
Validate anchor fragments case-sensitively in the link checker
MariusStorhaug Jul 6, 2026
2227499
Support angle-bracketed reference-style destinations in the link checker
MariusStorhaug Jul 6, 2026
351cba4
Merge main and align the PowerShell .NET guidance with #18
MariusStorhaug Jul 6, 2026
a144b19
Require a [Parameter()] attribute and a blank line per parameter, and…
MariusStorhaug Jul 6, 2026
5ca4264
Add comment-based help and full parameter blocks to the link checker'…
MariusStorhaug Jul 6, 2026
000a7f0
Scope comment-based help to public functions and match the Markdown e…
MariusStorhaug Jul 6, 2026
41e1564
Strip single-quoted and parenthesised link titles, not just double-qu…
MariusStorhaug Jul 6, 2026
cf42bf4
Describe what [Parameter()] does without the inaccurate advanced-func…
MariusStorhaug Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 154 additions & 33 deletions .github/scripts/Test-DocumentationLink.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -37,27 +38,75 @@ $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.
$ascii = -join ([char[]] $Heading | Where-Object { [int] $_ -lt 128 })
<#
.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.
<#
.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
foreach ($line in [System.IO.File]::ReadAllLines($Path)) {
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) }
Expand All @@ -66,15 +115,98 @@ 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]
}

$linkPattern = '\[[^\]]*\]\(([^)]+)\)'
function Get-LinkTargetIssue {
<#
.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 '>$', ''
if (-not $t) { return }
if ($t -match '^(https?:|mailto:|tel:|//)') { return }
$path, $frag = $t -split '#', 2
if (-not $path) {
if ($frag -and ($frag -cnotin (Get-CachedSlug $File.FullName))) {
"${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))) {
"${Rel}:${LineNo}: '$t' - target does not exist"
return
}
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"
}
Comment thread
MariusStorhaug marked this conversation as resolved.
}

# Inline links '[text](target)' and reference-style definitions '[label]: target'.
# 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 = '\[[^\]]*\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)'
$refDefPattern = '^\s*\[[^\]]+\]:\s+(<[^>]+>|\S+)'
$broken = [System.Collections.Generic.List[string]]::new()
Comment thread
MariusStorhaug marked this conversation as resolved.

foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md | Sort-Object FullName)) {
Expand All @@ -87,27 +219,16 @@ 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")
}
$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) {
$issue = Get-LinkTargetIssue -Target $matches[1] -File $file -Rel $rel -LineNo $lineNo
if ($issue) { $broken.Add($issue) }
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/docs/Coding-Standards/Markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
MariusStorhaug marked this conversation as resolved.
- **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

Expand Down
2 changes: 2 additions & 0 deletions src/docs/Coding-Standards/PowerShell/Functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
- **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.

Expand Down
2 changes: 2 additions & 0 deletions src/docs/Coding-Standards/PowerShell/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,13 @@ 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.
- **Reuse before you build.** Work down the [reuse order](../Functions.md#reuse-before-you-build) β€” a built-in cmdlet or operator, then an existing function (public or private), then a trusted module (`#Requires -Modules` / `RequiredModules`), then your own code (small logic inline, a larger capability as its own module).
- **PowerShell already *is* .NET; work at that level rather than wrapping it.** Casts, type accelerators (`[datetime]`, `[int]`), the `-split` / `-replace` / `-match` operators, and member methods (`.Trim()`, `.Where()`) all resolve to the base class library β€” using .NET means reaching for BCL types and methods for the computation, not restating everything as `[Namespace.Type]::Method(...)`. Where idiomatic PowerShell already resolves to the same .NET call, leave it; reach for explicit .NET only where it is measurably faster or more precise, and keep cmdlets and the pipeline where you need them for glue or readability.
- **Do the work in .NET when you implement it.** When you write the logic yourself β€” or fix an internal function that is too slow or imprecise on a hot path β€” call the .NET base class library directly instead of a cmdlet pipeline: `[System.IO.File]::ReadAllText($path)` over `Get-Content -Raw`, `[System.IO.Path]::Combine(...)` for paths, `[System.Text.StringBuilder]` for repeated concatenation, `[int]::TryParse(...)` for parsing. .NET methods are faster and their contracts are precise; keep cmdlets where their clarity is worth more than the speed. The next two rules are specific cases.
- **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.
- **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
Expand Down
22 changes: 11 additions & 11 deletions src/docs/Ways-of-Working/Spec-Driven-Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**, 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. Number them 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. 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.
Give each requirement its own heading with a stable, explicit anchor β€” `### FR1 β€” <statement> { #fr1 }` for functional, `### NFR1 β€” <statement> { #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.
Comment thread
MariusStorhaug marked this conversation as resolved.

## Acceptance criteria

Expand Down Expand Up @@ -164,17 +164,17 @@ Copy these skeletons to start a `spec.md` and its `design.md`. Every section is

- <...>

## Requirements
## Functional requirements

### FR1 β€” <what the capability does, behavioral, testable, no technology> { #fr1 }

### Functional
### FR2 β€” <...> { #fr2 }
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.

- **F1.** <what the capability does β€” behavioral, testable, no technology>
- **F2.** <...>
## Non-functional requirements

### Non-functional
### NFR1 β€” <a quality attribute as a measurable condition, latency, availability, redaction, retention, cost> { #nfr1 }

- **N1.** <a quality attribute as a measurable condition β€” latency, availability, redaction, retention, cost>
- **N2.** <...>
### NFR2 β€” <...> { #nfr2 }
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.
Comment thread
MariusStorhaug marked this conversation as resolved.

## Acceptance criteria

Expand Down