diff --git a/scripts/docs/Generate-AssetDocs.ps1 b/scripts/docs/Generate-AssetDocs.ps1 index 087c103d7..87e09bb81 100644 --- a/scripts/docs/Generate-AssetDocs.ps1 +++ b/scripts/docs/Generate-AssetDocs.ps1 @@ -188,32 +188,43 @@ function Get-AssetDocKeyword { return @($keywords) } -function Remove-HowToUseSection { +function Get-AssetDocTemplateSectionBody { <# .SYNOPSIS - Removes the "How to use it" section from a human-section tail. + Extracts one named human-section body from the page template. .DESCRIPTION - Non-interactive assets have no interactive usage flow, so the template's - "How to use it" section is dropped when scaffolding a new page for them. - Existing pages are never modified by this function. - - The terminating lookahead matches either the next H2 heading or the end - of the string (\z), so the section is removed even when "How to use it" - is the last H2 on the page rather than being followed by another heading. - .PARAMETER Tail - The human-section tail beginning at the first section heading. + Template bodies use exact begin and end markers keyed by the section + contract's TemplateRegion value. Headings and order are deliberately + excluded from the template and come from Get-AssetDocSectionContract. + .PARAMETER Template + Full template content. + .PARAMETER Region + Named template body region. .OUTPUTS - [string] The tail with the "How to use it" section removed. + [string] The body between the named markers. #> [CmdletBinding()] [OutputType([string])] param( - [Parameter(Mandatory = $true)] - [AllowEmptyString()] - [string]$Tail + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Template, + [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$Region ) - return ($Tail -replace '(?ms)\r?\n## How to use it\b.*?(?=\r?\n## |\z)', '') + $beginMarker = "" + $endMarker = "" + if ([regex]::Matches($Template, [regex]::Escape($beginMarker)).Count -ne 1 -or + [regex]::Matches($Template, [regex]::Escape($endMarker)).Count -ne 1) { + throw "Template section '$Region' must contain exactly one begin marker and one end marker." + } + + $beginIndex = $Template.IndexOf($beginMarker, [System.StringComparison]::Ordinal) + $bodyStart = $beginIndex + $beginMarker.Length + $endIndex = $Template.IndexOf($endMarker, $bodyStart, [System.StringComparison]::Ordinal) + if ($endIndex -lt $bodyStart) { + throw "Template section '$Region' has invalid marker ordering." + } + + return $Template.Substring($bodyStart, $endIndex - $bodyStart).Trim("`r", "`n") } function Get-AssetDocPageRelPath { @@ -259,10 +270,9 @@ function Test-AssetDocScaffoldOrphan { still matches an LF-generated page. .PARAMETER Content Full orphan page content. - .PARAMETER InteractiveTail - Canonical interactive template tail. - .PARAMETER NonInteractiveTail - Canonical non-interactive template tail. + .PARAMETER CanonicalTails + Contract-rendered scaffold tails for supported kind and interactivity + combinations. .OUTPUTS [bool] True only when automatic removal cannot discard authored prose. #> @@ -270,8 +280,7 @@ function Test-AssetDocScaffoldOrphan { [OutputType([bool])] param( [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content, - [Parameter(Mandatory = $true)][AllowEmptyString()][string]$InteractiveTail, - [Parameter(Mandatory = $true)][AllowEmptyString()][string]$NonInteractiveTail + [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$CanonicalTails ) foreach ($region in @('metadata', 'overview')) { @@ -297,8 +306,12 @@ function Test-AssetDocScaffoldOrphan { return $false } - return (Test-DocContentEqual -Left $overview.After -Right $InteractiveTail) -or - (Test-DocContentEqual -Left $overview.After -Right $NonInteractiveTail) + foreach ($tail in $CanonicalTails) { + if (Test-DocContentEqual -Left $overview.After -Right $tail) { + return $true + } + } + return $false } #endregion Pure Helpers @@ -311,9 +324,10 @@ function Get-TemplateHumanTail { Extracts the human-authored section tail from the page template. .PARAMETER TemplatePath Path to the asset documentation template. + .PARAMETER Kind + Asset kind used to resolve section status. .PARAMETER Interactive - Whether the target asset is interactive. When false, the "How to use it" - section is stripped from the returned tail. + Whether the target asset is interactive. .OUTPUTS [string] The template's human-authored tail (from the first section heading onward). @@ -322,18 +336,78 @@ function Get-TemplateHumanTail { [OutputType([string])] param( [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$TemplatePath, + [Parameter(Mandatory = $true)][ValidateSet('agent', 'prompt', 'instruction', 'skill')][string]$Kind, [Parameter(Mandatory = $true)][bool]$Interactive ) $template = Get-Content -LiteralPath $TemplatePath -Raw - $split = Split-AssetDocByMarkers -Content $template -Region 'overview' - $tail = $split.After + $renderedSections = [System.Collections.Generic.List[string]]::new() + foreach ($section in (Get-AssetDocSectionContract)) { + if ($section.GeneratedRegion) { + continue + } + $status = Resolve-AssetDocSectionStatus -Section $section -Kind $Kind -Interactive $Interactive + if ($status -eq 'NotApplicable') { + continue + } + if ([string]::IsNullOrWhiteSpace($section.TemplateRegion)) { + throw "Section '$($section.Name)' has no generated region or template region." + } + $body = Get-AssetDocTemplateSectionBody -Template $template -Region $section.TemplateRegion + $renderedSections.Add("$($section.Heading)`n`n$body") + } - if (-not $Interactive) { - $tail = Remove-HowToUseSection -Tail $tail + if ($renderedSections.Count -eq 0) { + return '' } + return "`n`n$($renderedSections -join "`n`n")`n" +} - return $tail +function New-AssetDocScaffoldSections { + <# + .SYNOPSIS + Renders all applicable new-page sections in contract order. + .PARAMETER Model + Page model from New-AssetPageModel. + .PARAMETER TemplatePath + Path to the asset documentation template. + .PARAMETER GeneratedRegions + Generated region bodies keyed by the contract's GeneratedRegion value. + .OUTPUTS + [string] Applicable headings and bodies in canonical order. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)][PSCustomObject]$Model, + [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string]$TemplatePath, + [Parameter(Mandatory = $true)][hashtable]$GeneratedRegions + ) + + $template = Get-Content -LiteralPath $TemplatePath -Raw + $renderedSections = [System.Collections.Generic.List[string]]::new() + foreach ($section in (Get-AssetDocSectionContract)) { + $status = Resolve-AssetDocSectionStatus -Section $section -Kind $Model.Kind -Interactive $Model.Interactive + if ($status -eq 'NotApplicable') { + continue + } + + $body = if ($section.GeneratedRegion) { + if (-not $GeneratedRegions.ContainsKey($section.GeneratedRegion)) { + throw "No generated content was supplied for section '$($section.Name)'." + } + $GeneratedRegions[$section.GeneratedRegion] + } + elseif ($section.TemplateRegion) { + Get-AssetDocTemplateSectionBody -Template $template -Region $section.TemplateRegion + } + else { + throw "Section '$($section.Name)' has no generated region or template region." + } + $renderedSections.Add("$($section.Heading)`n`n$body") + } + + return $renderedSections -join "`n`n" } function New-AssetDocContent { @@ -407,7 +481,6 @@ function New-AssetDocContent { } else { $msDate = $today - $humanTail = Get-TemplateHumanTail -TemplatePath $TemplatePath -Interactive $Model.Interactive } $descriptionMeta = if ([string]::IsNullOrWhiteSpace($Model.Description)) { @@ -420,7 +493,17 @@ function New-AssetDocContent { $metadataRegion = New-AssetGeneratedRegion -Region 'metadata' -Body (New-AssetMetadataBlock -Kind $Model.Kind -SourcePath $Model.SourceRel -Invocation $Model.Invocation -Interactive $Model.Interactive) $overviewRegion = New-AssetGeneratedRegion -Region 'overview' -Body $overviewBody - $generatedTail = "`n`n$metadataRegion`n`n## What it does`n`n$overviewRegion" + $humanTail + if ($null -ne $existing) { + $overviewSections = @(Get-AssetDocSectionContract | Where-Object GeneratedRegion -EQ 'overview') + if ($overviewSections.Count -ne 1) { + throw "Asset documentation section contract must define exactly one overview region." + } + $generatedTail = "`n`n$metadataRegion`n`n$($overviewSections[0].Heading)`n`n$overviewRegion" + $humanTail + } + else { + $scaffoldSections = New-AssetDocScaffoldSections -Model $Model -TemplatePath $TemplatePath -GeneratedRegions @{ overview = $overviewRegion } + $generatedTail = "`n`n$metadataRegion`n`n$scaffoldSections" + } $frontmatterArgs = @{ Title = $Model.Title @@ -717,8 +800,11 @@ function Invoke-AssetDocsGeneration { [void]$expectedPages.Add($page.DocRel) [void]$expectedPagesIgnoreCase.Add($page.DocRel) } - $interactiveTail = Get-TemplateHumanTail -TemplatePath $TemplatePath -Interactive $true - $nonInteractiveTail = Get-TemplateHumanTail -TemplatePath $TemplatePath -Interactive $false + $canonicalTails = @(foreach ($kind in @('agent', 'prompt', 'instruction', 'skill')) { + foreach ($interactive in @($false, $true)) { + Get-TemplateHumanTail -TemplatePath $TemplatePath -Kind $kind -Interactive $interactive + } + }) | Sort-Object -Unique foreach ($orphanRel in (Get-AssetDocPageRelPath -RepoRoot $RepoRoot)) { if ($expectedPages.Contains($orphanRel)) { continue @@ -732,7 +818,7 @@ function Invoke-AssetDocsGeneration { $orphanFull = Join-Path $RepoRoot $orphanRel $orphanContent = Get-Content -LiteralPath $orphanFull -Raw - if (-not (Test-AssetDocScaffoldOrphan -Content $orphanContent -InteractiveTail $interactiveTail -NonInteractiveTail $nonInteractiveTail)) { + if (-not (Test-AssetDocScaffoldOrphan -Content $orphanContent -CanonicalTails $canonicalTails)) { Write-Warning "Orphaned page $orphanRel contains authored or ambiguous content; preserving it for manual disposition." $needsAttention.Add($orphanRel) continue diff --git a/scripts/docs/Modules/DocsHelpers.psm1 b/scripts/docs/Modules/DocsHelpers.psm1 index b72dc23d9..d5e6e086a 100644 --- a/scripts/docs/Modules/DocsHelpers.psm1 +++ b/scripts/docs/Modules/DocsHelpers.psm1 @@ -523,6 +523,125 @@ function Test-AssetInteractive { } } +function Get-AssetDocSectionContract { + <# + .SYNOPSIS + Returns the ordered asset-documentation section contract. + + .DESCRIPTION + Defines section identity, heading order, content source, and per-kind + status rules in one shared list. A rule may be a direct status or an + interactive/non-interactive status map. Call Resolve-AssetDocSectionStatus + to obtain the canonical Required, Optional, or NotApplicable result. + + .OUTPUTS + [PSCustomObject[]] Ordered section contract entries. + #> + [CmdletBinding()] + [OutputType([PSCustomObject[]])] + param() + + return @( + [PSCustomObject]@{ + Name = 'what-it-does' + Heading = '## What it does' + GeneratedRegion = 'overview' + TemplateRegion = $null + Requirements = [ordered]@{ + agent = 'Required' + prompt = 'Required' + instruction = 'Required' + skill = 'Required' + } + } + [PSCustomObject]@{ + Name = 'when-to-use-it' + Heading = '## When to use it' + GeneratedRegion = $null + TemplateRegion = 'when-to-use-it' + Requirements = [ordered]@{ + agent = 'Required' + prompt = 'Required' + instruction = 'Required' + skill = 'Required' + } + } + [PSCustomObject]@{ + Name = 'how-to-use-it' + Heading = '## How to use it' + GeneratedRegion = $null + TemplateRegion = 'how-to-use-it' + Requirements = [ordered]@{ + agent = [ordered]@{ Interactive = 'Required'; NonInteractive = 'NotApplicable' } + prompt = [ordered]@{ Interactive = 'Required'; NonInteractive = 'NotApplicable' } + instruction = 'NotApplicable' + skill = 'NotApplicable' + } + } + [PSCustomObject]@{ + Name = 'example-usage' + Heading = '## Example usage' + GeneratedRegion = $null + TemplateRegion = 'example-usage' + Requirements = [ordered]@{ + agent = 'Required' + prompt = 'Required' + # Optional: scaffolded for instruction pages but not enforced, + # because an always-on instruction has no invocation to show. + instruction = 'Optional' + skill = 'Required' + } + } + ) +} + +function Resolve-AssetDocSectionStatus { + <# + .SYNOPSIS + Resolves a section contract entry for an asset model. + + .PARAMETER Section + Entry returned by Get-AssetDocSectionContract. + + .PARAMETER Kind + Asset kind used to select the per-kind rule. + + .PARAMETER Interactive + Whether the asset has an interactive usage flow. + + .OUTPUTS + [string] Required, Optional, or NotApplicable. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)][PSCustomObject]$Section, + [Parameter(Mandatory = $true)][ValidateSet('agent', 'prompt', 'instruction', 'skill')][string]$Kind, + [Parameter(Mandatory = $true)][bool]$Interactive + ) + + if ($null -eq $Section.PSObject.Properties['Requirements']) { + throw "Section '$($Section.Name)' does not define Requirements." + } + + $rule = $Section.Requirements[$Kind] + if ($null -eq $rule) { + throw "Section '$($Section.Name)' does not define a requirement for kind '$Kind'." + } + + if ($rule -is [System.Collections.IDictionary]) { + $interactionKey = if ($Interactive) { 'Interactive' } else { 'NonInteractive' } + $rule = $rule[$interactionKey] + } + + $status = [string]$rule + if ($status -notin @('Required', 'Optional', 'NotApplicable')) { + throw "Section '$($Section.Name)' resolves to unsupported status '$status'." + } + + return $status +} + function Format-AssetInvocation { <# .SYNOPSIS @@ -964,6 +1083,7 @@ Export-ModuleMember -Function @( 'Format-MarkdownTable', 'Format-YamlScalar', 'Get-AssetDocMarker', + 'Get-AssetDocSectionContract', 'Get-AssetDocsPath', 'Get-AssetFrontmatter', 'Get-AssetInvocation', @@ -973,6 +1093,7 @@ Export-ModuleMember -Function @( 'New-AssetMetadataBlock', 'New-AssetOverviewBody', 'New-AssetPageModel', + 'Resolve-AssetDocSectionStatus', 'Split-AssetDocByMarkers', 'Test-AssetDocStub', 'Test-AssetInteractive' diff --git a/scripts/docs/templates/asset-doc.template.md b/scripts/docs/templates/asset-doc.template.md index 61a4e04c0..a4ade5db2 100644 --- a/scripts/docs/templates/asset-doc.template.md +++ b/scripts/docs/templates/asset-doc.template.md @@ -1,37 +1,20 @@ --- -title: Asset name -description: Reference documentation for this asset. -sidebar_position: 1 -author: Microsoft -ms.date: 2026-07-02 -ms.topic: reference -keywords: - - kind - - collection - - asset name +title: Asset documentation section bodies +description: Human-authored scaffold bodies used by the asset documentation generator. +ms.date: 2026-08-08 --- - -Asset metadata is generated when the documentation is built. - - -## What it does - - -A summary of what this asset does is generated from its description. - - -## When to use it - + Describe the situations where this asset is the right choice, and when to reach for a different asset instead. + -## How to use it - + Walk through invoking this asset step by step. Remove this section when the asset is not interactive. + -## Example usage - + Provide a concrete example that shows the asset in action, including representative input and the resulting output. + diff --git a/scripts/linting/Validate-AssetDocs.ps1 b/scripts/linting/Validate-AssetDocs.ps1 index 5316ba1fa..25da03d3f 100644 --- a/scripts/linting/Validate-AssetDocs.ps1 +++ b/scripts/linting/Validate-AssetDocs.ps1 @@ -372,7 +372,13 @@ function Test-AssetDocOrphan { function Test-AssetDocStructure { <# .SYNOPSIS - Verifies required headings and generated-region markers on a page. + Verifies required headings, canonical section order, and generated-region + markers on a page. + .DESCRIPTION + Reports a missing Required section, an applicable section that appears out + of the contract's canonical order, and a damaged generated-region marker + pair. Optional sections are only order-checked when the page includes + them, and NotApplicable sections are ignored. .PARAMETER Model Page model from New-AssetPageModel. .PARAMETER Content @@ -389,14 +395,30 @@ function Test-AssetDocStructure { $findings = @() - $required = @('## What it does', '## When to use it', '## Example usage') - if ($Model.Interactive) { - $required += '## How to use it' + $present = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($section in (Get-AssetDocSectionContract)) { + $status = Resolve-AssetDocSectionStatus -Section $section -Kind $Model.Kind -Interactive $Model.Interactive + if ($status -eq 'NotApplicable') { + continue + } + + $heading = $section.Heading + $match = [regex]::Match($Content, '(?m)^' + [regex]::Escape($heading) + '\s*$') + if (-not $match.Success) { + if ($status -eq 'Required') { + $findings += New-AssetDocFinding -Level 'Error' -Category 'Structure' -Path $Model.DocRel -Message "Missing required section '$heading'." + } + continue + } + + $present.Add([PSCustomObject]@{ Heading = $heading; Index = $match.Index }) } - foreach ($heading in $required) { - $pattern = '(?m)^' + [regex]::Escape($heading) + '\s*$' - if ($Content -notmatch $pattern) { - $findings += New-AssetDocFinding -Level 'Error' -Category 'Structure' -Path $Model.DocRel -Message "Missing required section '$heading'." + + # The contract declares canonical order, so a page that carries every heading + # in the wrong sequence must fail rather than pass an existence-only check. + for ($i = 1; $i -lt $present.Count; $i++) { + if ($present[$i].Index -lt $present[$i - 1].Index) { + $findings += New-AssetDocFinding -Level 'Error' -Category 'Structure' -Path $Model.DocRel -Message "Section '$($present[$i].Heading)' must appear after '$($present[$i - 1].Heading)' in canonical contract order." } } diff --git a/scripts/tests/docs/DocsHelpers.Tests.ps1 b/scripts/tests/docs/DocsHelpers.Tests.ps1 index 8a001713f..a9cce8587 100644 --- a/scripts/tests/docs/DocsHelpers.Tests.ps1 +++ b/scripts/tests/docs/DocsHelpers.Tests.ps1 @@ -50,6 +50,7 @@ Describe 'DocsHelpers module contract' -Tag 'Unit' { 'Format-MarkdownTable' 'Format-YamlScalar' 'Get-AssetDocMarker' + 'Get-AssetDocSectionContract' 'Get-AssetDocsPath' 'Get-AssetFrontmatter' 'Get-AssetInvocation' @@ -59,6 +60,7 @@ Describe 'DocsHelpers module contract' -Tag 'Unit' { 'New-AssetMetadataBlock' 'New-AssetOverviewBody' 'New-AssetPageModel' + 'Resolve-AssetDocSectionStatus' 'Split-AssetDocByMarkers' 'Test-AssetDocStub' 'Test-AssetInteractive' @@ -307,6 +309,58 @@ Describe 'Test-AssetInteractive' -Tag 'Unit' { } } +Describe 'Asset documentation section contract' -Tag 'Unit' { + BeforeAll { + $script:sections = @(Get-AssetDocSectionContract) + } + + It 'Declares the canonical heading order once' { + $script:sections.Heading | Should -Be @( + '## What it does' + '## When to use it' + '## How to use it' + '## Example usage' + ) + } + + It 'Resolves How to use it from kind and interactivity' -ForEach @( + @{ Kind = 'agent'; Interactive = $true; Expected = 'Required' } + @{ Kind = 'agent'; Interactive = $false; Expected = 'NotApplicable' } + @{ Kind = 'prompt'; Interactive = $true; Expected = 'Required' } + @{ Kind = 'prompt'; Interactive = $false; Expected = 'NotApplicable' } + @{ Kind = 'instruction'; Interactive = $false; Expected = 'NotApplicable' } + @{ Kind = 'skill'; Interactive = $false; Expected = 'NotApplicable' } + ) { + $section = $script:sections | Where-Object Name -EQ 'how-to-use-it' + + Resolve-AssetDocSectionStatus -Section $section -Kind $Kind -Interactive $Interactive | + Should -Be $Expected + } + + It 'Resolves Example usage from the shared contract' -ForEach @( + @{ Kind = 'agent'; Interactive = $true; Expected = 'Required' } + @{ Kind = 'agent'; Interactive = $false; Expected = 'Required' } + @{ Kind = 'prompt'; Interactive = $true; Expected = 'Required' } + @{ Kind = 'skill'; Interactive = $false; Expected = 'Required' } + @{ Kind = 'instruction'; Interactive = $false; Expected = 'Optional' } + ) { + $section = $script:sections | Where-Object Name -EQ 'example-usage' + + Resolve-AssetDocSectionStatus -Section $section -Kind $Kind -Interactive $Interactive | + Should -Be $Expected + } + + It 'Rejects unsupported resolved statuses' { + $section = [PSCustomObject]@{ + Name = 'invalid-example' + Requirements = @{ instruction = 'Conditional' } + } + + { Resolve-AssetDocSectionStatus -Section $section -Kind 'instruction' -Interactive $false } | + Should -Throw -ExpectedMessage "Section 'invalid-example' resolves to unsupported status 'Conditional'." + } +} + Describe 'Format-AssetInvocation' -Tag 'Unit' { It 'Renders the chat agent picker mechanism' { Format-AssetInvocation -Invocation @{ Mechanism = 'agent-picker'; Token = 'RPI Agent' } | diff --git a/scripts/tests/docs/Generate-AssetDocs.Tests.ps1 b/scripts/tests/docs/Generate-AssetDocs.Tests.ps1 index 9ffe7c844..9112f9dd2 100644 --- a/scripts/tests/docs/Generate-AssetDocs.Tests.ps1 +++ b/scripts/tests/docs/Generate-AssetDocs.Tests.ps1 @@ -148,17 +148,22 @@ Describe 'Invoke-AssetDocsGeneration human sections' -Tag 'Unit' { It 'Scaffolds the How to use it section for an interactive asset' { $content = Get-Content -LiteralPath (Join-Path $script:repo 'docs/reference/agents/hve-core/alpha-agent.md') -Raw - $content | Should -Match '(?m)^## When to use it$' - $content | Should -Match '(?m)^## How to use it$' - $content | Should -Match '(?m)^## Example usage$' + @([regex]::Matches($content, '(?m)^## .+$').Value) | Should -Be @( + '## What it does' + '## When to use it' + '## How to use it' + '## Example usage' + ) } It 'Omits the How to use it section for a delegated subagent' { $content = Get-Content -LiteralPath (Join-Path $script:repo 'docs/reference/agents/hve-core/subagents/nested-sub.md') -Raw - $content | Should -Match '(?m)^## When to use it$' - $content | Should -Not -Match '(?m)^## How to use it$' - $content | Should -Match '(?m)^## Example usage$' + @([regex]::Matches($content, '(?m)^## .+$').Value) | Should -Be @( + '## What it does' + '## When to use it' + '## Example usage' + ) } It 'Omits the How to use it section for passive instructions and skills' { @@ -171,6 +176,10 @@ Describe 'Invoke-AssetDocsGeneration human sections' -Tag 'Unit' { Test-AssetDocStub -Content (Get-Content -LiteralPath (Join-Path $script:repo 'docs/reference/prompts/hve-core/demo.md') -Raw) | Should -BeTrue } + It 'Keeps output headings out of the scaffold body template' { + Get-Content -LiteralPath $script:TemplatePath -Raw | Should -Not -Match '(?m)^## ' + } + It 'Preserves an authored human tail while refreshing the generated regions' { $pageRel = 'docs/reference/agents/hve-core/alpha-agent.md' $page = Join-Path $script:repo $pageRel @@ -342,6 +351,16 @@ Describe 'Invoke-AssetDocsGeneration input validation' -Tag 'Unit' { Should -Throw -ExpectedMessage "Template not found: $missing" } + It 'Throws when an applicable template section body is missing' { + $repo = New-AssetFixtureRepo + $brokenTemplate = Join-Path $TestDrive 'broken-asset-doc.template.md' + $template = Get-Content -LiteralPath $script:TemplatePath -Raw + Set-Content -LiteralPath $brokenTemplate -Value ($template -replace '', '') -Encoding utf8NoBOM -NoNewline + + { Invoke-AssetDocsGeneration -RepoRoot $repo -TemplatePath $brokenTemplate } | + Should -Throw -ExpectedMessage "Template section 'example-usage' must contain exactly one begin marker and one end marker." + } + It 'Produces no pages for a repository with no documentable assets' { $repo = Join-Path $TestDrive 'empty-repo' New-Item -ItemType Directory -Path (Join-Path $repo '.github') -Force | Out-Null diff --git a/scripts/tests/linting/Validate-AssetDocs.Tests.ps1 b/scripts/tests/linting/Validate-AssetDocs.Tests.ps1 index bc489123f..1f4d145f8 100644 --- a/scripts/tests/linting/Validate-AssetDocs.Tests.ps1 +++ b/scripts/tests/linting/Validate-AssetDocs.Tests.ps1 @@ -224,6 +224,44 @@ Describe 'Test-AssetDocStructure' -Tag 'Unit' { ($findings | Where-Object { $_.Category -eq 'Structure' -and $_.Message -match 'Example usage' }) | Should -Not -BeNullOrEmpty } + It 'Accepts an instruction page without the optional Example usage section' { + $script:instrContent | Should -Match '(?m)^## Example usage$' + $withoutExample = $script:instrContent -replace '## Example usage', '## Renamed' + + Test-AssetDocStructure -Model $script:instrModel -Content $withoutExample | Should -BeNullOrEmpty + } + + It 'Flags applicable sections that appear out of canonical contract order' { + # Swap two heading names so every required heading is still present and + # only the sequence drifts from the shared contract. + $reordered = $script:agentContent -replace '## When to use it', '## __swap__' -replace '## Example usage', '## When to use it' -replace '## __swap__', '## Example usage' + $findings = @(Test-AssetDocStructure -Model $script:agentModel -Content $reordered) + + ($findings | Where-Object { $_.Message -match 'Missing required section' }) | Should -BeNullOrEmpty + ($findings | Where-Object { $_.Category -eq 'Structure' -and $_.Message -match 'canonical contract order' }) | Should -Not -BeNullOrEmpty + } + + It 'Order-checks an optional section the page includes' { + $reordered = $script:instrContent -replace '## When to use it', '## __swap__' -replace '## Example usage', '## When to use it' -replace '## __swap__', '## Example usage' + $findings = @(Test-AssetDocStructure -Model $script:instrModel -Content $reordered) + + ($findings | Where-Object { $_.Category -eq 'Structure' -and $_.Message -match 'canonical contract order' }) | Should -Not -BeNullOrEmpty + } + + It 'Derives every required heading from the shared contract' { + $requiredSections = @(Get-AssetDocSectionContract | Where-Object { + (Resolve-AssetDocSectionStatus -Section $_ -Kind $script:instrModel.Kind -Interactive $script:instrModel.Interactive) -eq 'Required' + }) + + foreach ($section in $requiredSections) { + $broken = $script:instrContent -replace [regex]::Escape($section.Heading), '## Renamed' + $findings = @(Test-AssetDocStructure -Model $script:instrModel -Content $broken) + + ($findings | Where-Object { $_.Message -match [regex]::Escape($section.Heading) }) | + Should -Not -BeNullOrEmpty + } + } + It 'Requires the How to use section for interactive assets' { $script:agentModel.Interactive | Should -BeTrue $broken = $script:agentContent -replace '## How to use it', '## Something else'