diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index cb99490..8301c9d 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. @@ -37,19 +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. - $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 @@ -57,7 +99,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) } @@ -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" + } +} + +# 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() foreach ($file in (Get-ChildItem -LiteralPath $Docs -Recurse -File -Filter *.md | Sort-Object FullName)) { @@ -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) } } } } 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/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 diff --git a/src/docs/Coding-Standards/PowerShell/Functions.md b/src/docs/Coding-Standards/PowerShell/Functions.md index d6f51cd..e6d1ccd 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 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. @@ -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 — 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. diff --git a/src/docs/Coding-Standards/PowerShell/index.md b/src/docs/Coding-Standards/PowerShell/index.md index 254cf00..86a8b21 100644 --- a/src/docs/Coding-Standards/PowerShell/index.md +++ b/src/docs/Coding-Standards/PowerShell/index.md @@ -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 diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development.md b/src/docs/Ways-of-Working/Spec-Driven-Development.md index 8e1e58c..24134d0 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**, 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 — { #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