From 05d4d5d6814b304a21acd2a207939a8b9c69f2d7 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Thu, 25 Jun 2026 18:38:36 -0500 Subject: [PATCH 1/2] test(#21): strengthen contract tests (help/CmdletBinding/OutputType, one-function-per-file, SEB-prefix, approved verbs, no aliases/Write-Host, export integrity) Co-Authored-By: Claude Opus 4.8 --- Tests/Contract/Conventions.Tests.ps1 | 269 +++++++++++++++++++++++++++ Tests/Contract/Exports.Tests.ps1 | 58 ++++++ 2 files changed, 327 insertions(+) create mode 100644 Tests/Contract/Conventions.Tests.ps1 diff --git a/Tests/Contract/Conventions.Tests.ps1 b/Tests/Contract/Conventions.Tests.ps1 new file mode 100644 index 0000000..2efc816 --- /dev/null +++ b/Tests/Contract/Conventions.Tests.ps1 @@ -0,0 +1,269 @@ +#Requires -Module Pester + +# Convention contract: a single layer that guards the CLAUDE.md authoring rules across the +# WHOLE codebase, so convention drift is caught once rather than per function. Each rule below +# corresponds to a section of CLAUDE.md (naming, comment-based help, [CmdletBinding]/[OutputType], +# one-function-per-file, approved verbs, no aliases, no Write-Host in module code). +# +# WHY allow-lists: some rules are already GREEN everywhere and are enforced strictly (no baseline). +# A few rules have *pre-existing* violations that pre-date this contract. Following the build.ps1 +# PSScriptAnalyzer-baseline pattern, those are captured as small DOCUMENTED allow-lists so the +# contract is GREEN now yet still fails on any NEW drift. Shrink the allow-lists as code is fixed. + +BeforeDiscovery { + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + + # -- Shared AST helper --------------------------------------------------------------------- + function script:Get-ContractFunctionInfo { + # Returns one hashtable per function definition found in $Path, with the metadata each + # convention test needs. TopLevel=$true means the function is at file scope (not nested + # inside another function) -- used by the one-function-per-file rule. + param([string]$Path) + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + $topLevel = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) + $all = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) + foreach ($fn in $all) { + $attrs = @() + if ($fn.Body.ParamBlock) { $attrs = @($fn.Body.ParamBlock.Attributes.TypeName.FullName) } + $help = $fn.GetHelpContent() + @{ + Name = $fn.Name + IsTopLevel = ($topLevel -contains $fn) + HasCmdletBind = ($attrs -contains 'CmdletBinding') + HasOutputType = ($attrs -contains 'OutputType') + HasSynopsis = ($help -and -not [string]::IsNullOrWhiteSpace($help.Synopsis)) + HasDescription = ($help -and -not [string]::IsNullOrWhiteSpace($help.Description)) + HasExample = ($help -and $help.Examples -and $help.Examples.Count -gt 0) + } + } + } + + # -- Which functions are actually exported ------------------------------------------------- + # Source of truth = every sub-module manifest's FunctionsToExport (no module import needed at + # discovery time). A Public/ function whose name is in this set is "public/exported". + $script:exportedNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($mm in @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Filter '*.psd1')) { + $md = Import-PowerShellDataFile -Path $mm.FullName + foreach ($fn in [string[]]$md.FunctionsToExport) { if ($fn) { [void]$script:exportedNames.Add($fn) } } + } + + # -- File enumerations --------------------------------------------------------------------- + $publicFiles = @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Filter '*.ps1' | + Where-Object { $_.FullName -match '[\\/]Public[\\/]' }) + $privateFiles = @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Filter '*.ps1' | + Where-Object { $_.FullName -match '[\\/]Private[\\/]' }) + $moduleSourceFiles = @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Include '*.ps1', '*.psm1') + + $rel = { param($p) $p.Replace($repoRoot, '').TrimStart('\', '/').Replace('\', '/') } + + # Per-EXPORTED-function cases (help + CmdletBinding + OutputType apply to the exported function + # in each Public file, NOT to inline/nested helpers). + $script:exportedFunctionCases = foreach ($f in $publicFiles) { + foreach ($info in (Get-ContractFunctionInfo -Path $f.FullName)) { + if (-not $script:exportedNames.Contains($info.Name)) { continue } + @{ + Name = $info.Name + File = (& $rel $f.FullName) + HasCmdletBind = $info.HasCmdletBind + HasOutputType = $info.HasOutputType + HasSynopsis = $info.HasSynopsis + HasDescription= $info.HasDescription + HasExample = $info.HasExample + } + } + } + $script:exportedFunctionCases = @($script:exportedFunctionCases) + + # Per-Public-FILE cases (one-function-per-file rule; counts TOP-LEVEL functions). + $script:publicFileCases = foreach ($f in @($publicFiles + $privateFiles)) { + $infos = @(Get-ContractFunctionInfo -Path $f.FullName) + $topNames = @($infos | Where-Object { $_.IsTopLevel } | ForEach-Object { $_.Name }) + @{ + File = (& $rel $f.FullName) + BaseName = $f.BaseName + TopLevelNames = $topNames + TopLevelCount = $topNames.Count + } + } + $script:publicFileCases = @($script:publicFileCases) + + # Per-function-NAME cases for naming rules (SEB-prefix both ways, approved verbs). Marks each + # function as exported or private so the SEB-prefix rule can be applied in the right direction. + $script:namingCases = @( + foreach ($f in $publicFiles) { + foreach ($info in (Get-ContractFunctionInfo -Path $f.FullName)) { + @{ Name = $info.Name; File = (& $rel $f.FullName); IsExported = $script:exportedNames.Contains($info.Name) } + } + } + foreach ($f in $privateFiles) { + foreach ($info in (Get-ContractFunctionInfo -Path $f.FullName)) { + @{ Name = $info.Name; File = (& $rel $f.FullName); IsExported = $script:exportedNames.Contains($info.Name) } + } + } + ) + + # Per-module-SOURCE-file cases (alias + Write-Host scans over Modules/). + $script:moduleFileCases = foreach ($f in $moduleSourceFiles) { + @{ Path = $f.FullName; File = (& $rel $f.FullName) } + } + $script:moduleFileCases = @($script:moduleFileCases) +} + +BeforeAll { + # -- Allow-lists (documented baselines of known, pre-existing exceptions) ------------------ + # Defined in BeforeAll (run-time scope) because the It bodies that consult them execute there, + # not at discovery time. Following the build.ps1 PSScriptAnalyzer-baseline pattern: the rule is + # GREEN now, yet any NEW violation NOT on the allow-list still fails. Shrink as code is fixed. + + # Public functions that legitimately lack [OutputType()] today (e.g. void/side-effecting + # senders, console writers, throttled-copy starters). NEW public functions must declare + # [OutputType()]; remove names here as each gains one. (CLAUDE.md: OutputType "where the + # return type is known".) + $script:allowNoOutputType = @( + 'Add-SEBMetric', 'Clear-SEBOldMetrics', 'Get-SEBIntegrityReport', 'Get-SEBNodeMetrics', + 'Invoke-SEBRemoteCommand', 'Invoke-SEBVRageRequest', 'Invoke-SEBWithShadowCopy', + 'Remove-SEBSession', 'Send-SEBBackupNotification', 'Send-SEBNotification', + 'Send-SEBRestoreNotification', 'Start-SEBBitsTransfer', 'Start-SEBLogContext', + 'Stop-SEBLogContext', 'Stop-SEBTransfer', 'Test-SEBArchiveIntegrity', 'Test-SEBConnection', + 'Test-SEBManifestIntegrity', 'Test-SEBNodeLoad', 'Wait-SEBNodeLoad', + 'Write-SEBIntegrityReport', 'Write-SEBLog', 'Write-SEBManifest' + ) + + # Private functions that currently carry the SEB prefix. CLAUDE.md says private/internal + # functions do NOT use the SEB prefix; these pre-date the rule's enforcement. NEW private + # functions must be un-prefixed; remove names here as each is renamed. Keyed by function name + # (private function names are unique across the codebase by the one-function-per-file rule). + $script:allowPrivateSebPrefix = @( + 'Get-SEBBackupType', 'Test-SEBPreFlight', 'Convert-SEBLegacyCredential', + 'ConvertFrom-SEBProtectedCredential', 'ConvertTo-SEBProtectedCredential', + 'Get-SEBSecretEntropy', 'Protect-SEBSecret', 'Set-SEBCredentialAcl', + 'Test-SEBProtectedCredentialReadBack', 'Unprotect-SEBSecret', + 'Write-SEBProtectedCredentialFile', 'Resolve-SEBChainArchivePath', 'Get-SEBPlayerCount', + 'Get-SEBFileHash', 'Test-SEBManifestSchema', 'Get-SEBTrendIndicator', 'New-SEBDiscordEmbed', + 'Test-SEBSessionAlive', 'Test-SEBSessionUsable', 'Deploy-SEBRestoredFiles', + 'Start-SEBTorchServer', 'Stop-SEBTorchServer', 'Get-SEBProjectRoot', + 'New-SEBVRageAuthHeaders', 'Test-SEBVSSService' + ) + + # Public-file paths (relative, forward-slash) that define more than one TOP-LEVEL function. + # Test-SEBConfig.ps1 keeps three file-scope validation helpers (Test-GlobalConfig/NodeConfig/ + # InstanceConfig) alongside the exported Test-SEBConfig. NEW files must be one-function-per-file. + $script:allowMultiFunctionFiles = @( + 'Modules/ConfigManager/Public/Test-SEBConfig.ps1' + ) + + # Write-Host sites permitted in module code, keyed "relative/path:line". CLAUDE.md forbids + # Write-Host in modules EXCEPT the logger's own colour-coded console sink (Write-SEBLog IS the + # console-output mechanism, gated by -NoConsole). If the line moves, update the entry. + $script:allowWriteHost = @( + 'Modules/Logger/Public/Write-SEBLog.ps1:165' + ) + + $script:approvedVerbs = [System.Collections.Generic.HashSet[string]]::new( + [string[]](Get-Verb | ForEach-Object Verb), [System.StringComparer]::OrdinalIgnoreCase) + + # Resolve the set of alias NAMES once. The alias rule flags any CommandAst whose command name + # is a known alias (e.g. gci/%/?/select/where/ls/cat). Built dynamically from the live session + # so it tracks PowerShell's own alias table rather than a hand-maintained list. + $script:aliasNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($a in (Get-Alias)) { [void]$script:aliasNames.Add($a.Name) } + + function script:Get-CommandAstsFromFile { + param([string]$Path) + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + return @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)) + } +} + +# -- Rule 1: comment-based help on every PUBLIC function ---------------------------------------- +Describe 'Public functions carry comment-based help' { + It ' has a .SYNOPSIS ()' -ForEach $script:exportedFunctionCases { + $HasSynopsis | Should -BeTrue -Because "CLAUDE.md requires comment-based help; $Name is exported and must declare at least .SYNOPSIS" + } + It ' has a .DESCRIPTION ()' -ForEach $script:exportedFunctionCases { + $HasDescription | Should -BeTrue -Because "$Name is exported and should document .DESCRIPTION (CLAUDE.md comment-based help)" + } + It ' has at least one .EXAMPLE ()' -ForEach $script:exportedFunctionCases { + $HasExample | Should -BeTrue -Because "$Name is exported and should show at least one .EXAMPLE (CLAUDE.md comment-based help)" + } +} + +# -- Rule 2: [CmdletBinding()] (always) and [OutputType()] (where known) on PUBLIC functions ----- +Describe 'Public functions declare [CmdletBinding()]' { + It ' has [CmdletBinding()] ()' -ForEach $script:exportedFunctionCases { + $HasCmdletBind | Should -BeTrue -Because "CLAUDE.md: use [CmdletBinding()] on all functions; $Name is exported" + } +} + +Describe 'Public functions declare [OutputType()] (allow-listed exceptions aside)' { + It ' has [OutputType()] ()' -ForEach $script:exportedFunctionCases { + if ($Name -in $script:allowNoOutputType) { + Set-ItResult -Skipped -Because "known pre-existing exception: $Name lacks [OutputType()] (documented allow-list)" + return + } + $HasOutputType | Should -BeTrue -Because "CLAUDE.md: declare [OutputType()] where the return type is known; $Name is a new/known-return public function not on the allow-list" + } +} + +# -- Rule 3: one function per file (file name == function name) ---------------------------------- +Describe 'Each module .ps1 defines exactly one top-level function named for the file' { + It ' defines a single top-level function matching its BaseName' -ForEach $script:publicFileCases { + if ($File -in $script:allowMultiFunctionFiles) { + Set-ItResult -Skipped -Because "known pre-existing exception: $File keeps file-scope helpers (documented allow-list)" + return + } + $TopLevelCount | Should -Be 1 -Because "CLAUDE.md one-function-per-file: $File declares top-level function(s): $($TopLevelNames -join ', ')" + $TopLevelNames[0] | Should -BeExactly $BaseName -Because "CLAUDE.md: the file name must match the function name ($File)" + } +} + +# -- Rule 4: SEB-prefix rule (exported MUST carry it; private must NOT) -------------------------- +Describe 'SEB-prefix naming rule' { + It 'exported matches ^[A-Z][a-z]+-SEB[A-Z] ()' -ForEach @($script:namingCases | Where-Object { $_.IsExported }) { + $Name | Should -Match '^[A-Z][a-z]+-SEB[A-Z]' -Because "CLAUDE.md: every exported function is Verb-SEBNoun ($File)" + } + It 'private does NOT carry the SEB prefix ()' -ForEach @($script:namingCases | Where-Object { -not $_.IsExported }) { + if ($Name -in $script:allowPrivateSebPrefix) { + Set-ItResult -Skipped -Because "known pre-existing exception: private $Name carries the SEB prefix (documented allow-list)" + return + } + $Name | Should -Not -Match '-SEB' -Because "CLAUDE.md: private/internal functions do NOT use the SEB prefix; $Name is not exported and not on the allow-list ($File)" + } +} + +# -- Rule 5: approved PowerShell verbs ---------------------------------------------------------- +Describe 'All module functions use approved verbs' { + It ' uses an approved verb ()' -ForEach $script:namingCases { + $verb = ($Name -split '-', 2)[0] + ($Name -match '-') | Should -BeTrue -Because "function names must be Verb-Noun: $Name ($File)" + $script:approvedVerbs.Contains($verb) | Should -BeTrue -Because "CLAUDE.md: use approved PowerShell verbs (Get-Verb); '$verb' in $Name is not approved ($File)" + } +} + +# -- Rule 6: no PowerShell aliases in module code ----------------------------------------------- +Describe 'Module code uses full cmdlet names (no aliases)' { + It ' uses no command aliases' -ForEach $script:moduleFileCases { + $hits = foreach ($c in (Get-CommandAstsFromFile -Path $Path)) { + $name = $c.GetCommandName() + if ($name -and $script:aliasNames.Contains($name)) { "L$($c.Extent.StartLineNumber): '$name'" } + } + $hits = @($hits) + $hits -join "`n" | Should -BeNullOrEmpty -Because "CLAUDE.md: use full cmdlet names, not aliases, in $File`n$($hits -join "`n")" + } +} + +# -- Rule 7: no Write-Host in module code (logger console sink excepted) ------------------------- +Describe 'Module code does not use Write-Host' { + It ' contains no Write-Host (outside the allow-list)' -ForEach $script:moduleFileCases { + $hits = foreach ($c in (Get-CommandAstsFromFile -Path $Path)) { + if ($c.GetCommandName() -eq 'Write-Host') { + $key = "$File`:$($c.Extent.StartLineNumber)" + if ($key -notin $script:allowWriteHost) { "L$($c.Extent.StartLineNumber)" } + } + } + $hits = @($hits) + $hits -join "`n" | Should -BeNullOrEmpty -Because "CLAUDE.md: do not use Write-Host in module code (use Write-SEBLog) in $File`n$($hits -join "`n")" + } +} diff --git a/Tests/Contract/Exports.Tests.ps1 b/Tests/Contract/Exports.Tests.ps1 index 24f14af..7f5cc13 100644 --- a/Tests/Contract/Exports.Tests.ps1 +++ b/Tests/Contract/Exports.Tests.ps1 @@ -4,6 +4,11 @@ # root manifest's FunctionsToExport. A function that exists but is not exported is # invisible to anyone importing the SEBackup module (and to callers that depend on it), # which is how the RestoreEngine and MetricsCollector functions ended up dead. +# +# This file also enforces the REVERSE direction and the wider export wiring (issue #21): +# - reverse-export: every root FunctionsToExport entry has a backing Public/*.ps1 file; +# - the root manifest's export set equals the union of every sub-module manifest's exports; +# - every module directory on disk is listed in the root SEBackup.psm1 $Modules array. BeforeDiscovery { $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path @@ -14,9 +19,36 @@ BeforeDiscovery { BeforeAll { $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + $manifest = Import-PowerShellDataFile -Path "$repoRoot/SEBackup.psd1" $script:exported = [System.Collections.Generic.HashSet[string]]::new( [string[]]$manifest.FunctionsToExport, [System.StringComparer]::OrdinalIgnoreCase) + + # Basenames of every Public/*.ps1 across the sub-modules (the set of functions that SHOULD + # be exported). Used by the reverse-export check. + $script:publicBasenames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Filter '*.ps1' | + Where-Object { $_.FullName -match '[\\/]Public[\\/]' } | + ForEach-Object { [void]$script:publicBasenames.Add($_.BaseName) } + + # Union of every sub-module manifest's FunctionsToExport. + $script:moduleExportUnion = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($mm in @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Filter '*.psd1')) { + $md = Import-PowerShellDataFile -Path $mm.FullName + foreach ($fn in [string[]]$md.FunctionsToExport) { if ($fn) { [void]$script:moduleExportUnion.Add($fn) } } + } + + # The $Modules array from the root SEBackup.psm1, read via AST (not by executing the module). + $tokens = $null; $errors = $null + $psm1Ast = [System.Management.Automation.Language.Parser]::ParseFile("$repoRoot/SEBackup.psm1", [ref]$tokens, [ref]$errors) + $assign = @($psm1Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true) | + Where-Object { $_.Left.Extent.Text -eq '$Modules' }) | Select-Object -First 1 + $script:listedModules = @() + if ($assign) { + $script:listedModules = @($assign.Right.FindAll( + { param($n) $n -is [System.Management.Automation.Language.StringConstantExpressionAst] }, $true).Value) + } + $script:moduleDirs = @(Get-ChildItem -Path "$repoRoot/Modules" -Directory | Select-Object -ExpandProperty Name) } Describe 'Root manifest exports every public sub-module function' { @@ -24,3 +56,29 @@ Describe 'Root manifest exports every public sub-module function' { $script:exported.Contains($Name) | Should -BeTrue -Because "$Name is a Public/ function and must be in SEBackup.psd1 FunctionsToExport" } } + +Describe 'Export wiring is internally consistent' { + + It 'every FunctionsToExport entry has a backing Public/*.ps1 (reverse-export)' { + $orphans = @($script:exported | Where-Object { -not $script:publicBasenames.Contains($_) }) + $orphans -join ', ' | Should -BeNullOrEmpty -Because "each exported name needs a matching Public/ file; orphaned exports: $($orphans -join ', ')" + } + + It 'root FunctionsToExport equals the union of all sub-module manifest exports' { + $inRootOnly = @($script:exported | Where-Object { -not $script:moduleExportUnion.Contains($_) }) + $inModuleOnly = @($script:moduleExportUnion | Where-Object { -not $script:exported.Contains($_) }) + $inRootOnly -join ', ' | Should -BeNullOrEmpty -Because "root exports a name no sub-module manifest exports: $($inRootOnly -join ', ')" + $inModuleOnly -join ', ' | Should -BeNullOrEmpty -Because "a sub-module exports a name the root manifest omits: $($inModuleOnly -join ', ')" + } + + It 'every module directory is listed in the root SEBackup.psm1 $Modules array' { + $script:listedModules.Count | Should -BeGreaterThan 0 -Because "the $Modules array must be parseable from SEBackup.psm1" + $missing = @($script:moduleDirs | Where-Object { $_ -notin $script:listedModules }) + $missing -join ', ' | Should -BeNullOrEmpty -Because "module dir(s) not imported by SEBackup.psm1: $($missing -join ', ')" + } + + It 'every $Modules entry maps to a module directory on disk' { + $dangling = @($script:listedModules | Where-Object { $_ -notin $script:moduleDirs }) + $dangling -join ', ' | Should -BeNullOrEmpty -Because "SEBackup.psm1 lists module(s) with no directory: $($dangling -join ', ')" + } +} From f478136f5b81300427927c2cddd1d5f498e1dd73 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Thu, 25 Jun 2026 19:20:28 -0500 Subject: [PATCH 2/2] test(#21): frozen multi-fn set; module dirs by manifest; non-vacuity sentinels; stale-allow-list guard; top-level naming; memoized AST (8-lens) Co-Authored-By: Claude Opus 4.8 --- Tests/Contract/ApiContract.Tests.ps1 | 4 +- Tests/Contract/Conventions.Tests.ps1 | 197 ++++++++++++++++----- Tests/Contract/Exports.Tests.ps1 | 21 ++- Tests/Contract/Parse.Tests.ps1 | 8 +- Tests/Contract/RemoteScriptBlock.Tests.ps1 | 7 +- Tests/Contract/_ContractAst.ps1 | 74 ++++++++ 6 files changed, 253 insertions(+), 58 deletions(-) create mode 100644 Tests/Contract/_ContractAst.ps1 diff --git a/Tests/Contract/ApiContract.Tests.ps1 b/Tests/Contract/ApiContract.Tests.ps1 index 78e289f..0406fb7 100644 --- a/Tests/Contract/ApiContract.Tests.ps1 +++ b/Tests/Contract/ApiContract.Tests.ps1 @@ -19,6 +19,7 @@ BeforeDiscovery { } BeforeAll { + . "$PSScriptRoot/_ContractAst.ps1" # memoized Get-ContractAst (shared parse cache) $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path Import-Module "$repoRoot/SEBackup.psd1" -Force -DisableNameChecking 3>$null @@ -53,8 +54,7 @@ BeforeAll { Describe 'Exported SEBackup functions are called with valid parameters' { It 'all SEB-function calls in use declared parameters' -ForEach $script:callerFiles { - $tokens = $null; $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + $ast = (Get-ContractAst -Path $Path).Ast $calls = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true) diff --git a/Tests/Contract/Conventions.Tests.ps1 b/Tests/Contract/Conventions.Tests.ps1 index 2efc816..aa1000f 100644 --- a/Tests/Contract/Conventions.Tests.ps1 +++ b/Tests/Contract/Conventions.Tests.ps1 @@ -9,36 +9,17 @@ # A few rules have *pre-existing* violations that pre-date this contract. Following the build.ps1 # PSScriptAnalyzer-baseline pattern, those are captured as small DOCUMENTED allow-lists so the # contract is GREEN now yet still fails on any NEW drift. Shrink the allow-lists as code is fixed. +# +# Two anti-false-green guards back the allow-lists themselves (see the bottom of this file): +# * a STALE-ENTRY guard asserts every allow-list entry STILL corresponds to a real current +# violation, so a fixed-but-still-listed name fails and forces the list to be pruned; and +# * a NON-VACUITY sentinel asserts the data-driven discovery actually found cases (counts above +# sane floors), so an empty Get-ChildItem/glob regression turns the build RED instead of green. BeforeDiscovery { + . "$PSScriptRoot/_ContractAst.ps1" # memoized Get-ContractAst / Get-ContractFunctionInfo $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path - # -- Shared AST helper --------------------------------------------------------------------- - function script:Get-ContractFunctionInfo { - # Returns one hashtable per function definition found in $Path, with the metadata each - # convention test needs. TopLevel=$true means the function is at file scope (not nested - # inside another function) -- used by the one-function-per-file rule. - param([string]$Path) - $tokens = $null; $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) - $topLevel = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) - $all = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) - foreach ($fn in $all) { - $attrs = @() - if ($fn.Body.ParamBlock) { $attrs = @($fn.Body.ParamBlock.Attributes.TypeName.FullName) } - $help = $fn.GetHelpContent() - @{ - Name = $fn.Name - IsTopLevel = ($topLevel -contains $fn) - HasCmdletBind = ($attrs -contains 'CmdletBinding') - HasOutputType = ($attrs -contains 'OutputType') - HasSynopsis = ($help -and -not [string]::IsNullOrWhiteSpace($help.Synopsis)) - HasDescription = ($help -and -not [string]::IsNullOrWhiteSpace($help.Description)) - HasExample = ($help -and $help.Examples -and $help.Examples.Count -gt 0) - } - } - } - # -- Which functions are actually exported ------------------------------------------------- # Source of truth = every sub-module manifest's FunctionsToExport (no module import needed at # discovery time). A Public/ function whose name is in this set is "public/exported". @@ -90,14 +71,15 @@ BeforeDiscovery { # Per-function-NAME cases for naming rules (SEB-prefix both ways, approved verbs). Marks each # function as exported or private so the SEB-prefix rule can be applied in the right direction. + # SCOPED TO TOP-LEVEL (file-scope) functions only -- consistent with how exports and the + # one-function-per-file rule are evaluated. A nested helper inside a public function (e.g. + # Resolve-SessionFromCache inside Invoke-SEBWithShadowCopy) is an implementation detail and is + # neither exported nor subject to the public/private SEB-prefix split, so flagging or exempting + # it by these rules would be wrong. $script:namingCases = @( - foreach ($f in $publicFiles) { - foreach ($info in (Get-ContractFunctionInfo -Path $f.FullName)) { - @{ Name = $info.Name; File = (& $rel $f.FullName); IsExported = $script:exportedNames.Contains($info.Name) } - } - } - foreach ($f in $privateFiles) { + foreach ($f in @($publicFiles + $privateFiles)) { foreach ($info in (Get-ContractFunctionInfo -Path $f.FullName)) { + if (-not $info.IsTopLevel) { continue } @{ Name = $info.Name; File = (& $rel $f.FullName); IsExported = $script:exportedNames.Contains($info.Name) } } } @@ -108,13 +90,31 @@ BeforeDiscovery { @{ Path = $f.FullName; File = (& $rel $f.FullName) } } $script:moduleFileCases = @($script:moduleFileCases) + + # Non-vacuity sentinel cases. Each carries the ACTUAL discovery-time count of one enumeration + # plus the sane floor it must exceed. Data-driving the sentinel (vs reading a $script: scalar in + # the It) is deliberate: a -ForEach value is captured AT DISCOVERY and surfaced to the run-time + # It as $Count, so the assertion is tied to the exact count discovery used to expand the rules + # above. Floors = real current counts minus a margin; raise them as the codebase grows, never + # lower them to paper over a regression. + $script:sentinelCases = @( + @{ What = 'exported public functions (help/CmdletBinding/OutputType rules)'; Count = $script:exportedFunctionCases.Count; Floor = 50 } + @{ What = 'Public/ files (one-function-per-file rule)'; Count = $publicFiles.Count; Floor = 50 } + @{ What = 'Private/ files (private-SEB-prefix rule)'; Count = $privateFiles.Count; Floor = 25 } + @{ What = 'module source files (alias/Write-Host scans)'; Count = $moduleSourceFiles.Count; Floor = 90 } + @{ What = 'top-level naming cases (SEB-prefix/approved-verb rules)'; Count = $script:namingCases.Count; Floor = 80 } + ) } BeforeAll { + . "$PSScriptRoot/_ContractAst.ps1" # memoized Get-CommandAstsFromFile / Get-ContractFunctionInfo + $script:repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + # -- Allow-lists (documented baselines of known, pre-existing exceptions) ------------------ # Defined in BeforeAll (run-time scope) because the It bodies that consult them execute there, # not at discovery time. Following the build.ps1 PSScriptAnalyzer-baseline pattern: the rule is # GREEN now, yet any NEW violation NOT on the allow-list still fails. Shrink as code is fixed. + # The stale-allow-list guard (bottom of file) keeps every entry honest. # Public functions that legitimately lack [OutputType()] today (e.g. void/side-effecting # senders, console writers, throttled-copy starters). NEW public functions must declare @@ -146,12 +146,18 @@ BeforeAll { 'New-SEBVRageAuthHeaders', 'Test-SEBVSSService' ) - # Public-file paths (relative, forward-slash) that define more than one TOP-LEVEL function. - # Test-SEBConfig.ps1 keeps three file-scope validation helpers (Test-GlobalConfig/NodeConfig/ - # InstanceConfig) alongside the exported Test-SEBConfig. NEW files must be one-function-per-file. - $script:allowMultiFunctionFiles = @( - 'Modules/ConfigManager/Public/Test-SEBConfig.ps1' - ) + # Public-file paths (relative, forward-slash) that define more than one TOP-LEVEL function, + # mapped to the EXACT, FROZEN set of top-level function names that file is allowed to define + # (sorted). The one-function-per-file rule asserts an allow-listed file's current top-level set + # EQUALS this frozen set -- so adding a 4th/renamed top-level function to such a file FAILS, + # rather than being skipped. Test-SEBConfig.ps1 keeps three file-scope validation helpers + # (Test-GlobalConfig/NodeConfig/InstanceConfig) alongside the exported Test-SEBConfig. NEW files + # must be strictly one-function-per-file (do NOT add entries here without strong justification). + $script:allowMultiFunctionFiles = @{ + 'Modules/ConfigManager/Public/Test-SEBConfig.ps1' = @( + 'Test-GlobalConfig', 'Test-InstanceConfig', 'Test-NodeConfig', 'Test-SEBConfig' + ) + } # Write-Host sites permitted in module code, keyed "relative/path:line". CLAUDE.md forbids # Write-Host in modules EXCEPT the logger's own colour-coded console sink (Write-SEBLog IS the @@ -168,13 +174,6 @@ BeforeAll { # so it tracks PowerShell's own alias table rather than a hand-maintained list. $script:aliasNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($a in (Get-Alias)) { [void]$script:aliasNames.Add($a.Name) } - - function script:Get-CommandAstsFromFile { - param([string]$Path) - $tokens = $null; $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) - return @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)) - } } # -- Rule 1: comment-based help on every PUBLIC function ---------------------------------------- @@ -210,8 +209,12 @@ Describe 'Public functions declare [OutputType()] (allow-listed exceptions aside # -- Rule 3: one function per file (file name == function name) ---------------------------------- Describe 'Each module .ps1 defines exactly one top-level function named for the file' { It ' defines a single top-level function matching its BaseName' -ForEach $script:publicFileCases { - if ($File -in $script:allowMultiFunctionFiles) { - Set-ItResult -Skipped -Because "known pre-existing exception: $File keeps file-scope helpers (documented allow-list)" + if ($script:allowMultiFunctionFiles.ContainsKey($File)) { + # Allow-listed multi-function file: do NOT skip. Assert the CURRENT top-level set equals + # the frozen, documented set -- so a NEW/renamed top-level function here fails. + $expected = @($script:allowMultiFunctionFiles[$File] | Sort-Object) + $actual = @($TopLevelNames | Sort-Object) + ($actual -join ', ') | Should -Be ($expected -join ', ') -Because "CLAUDE.md one-function-per-file allow-list for $File is FROZEN to [$($expected -join ', ')]; the file now defines [$($actual -join ', ')]. Update Test-SEBConfig (split the file) or, with justification, the frozen allow-list." return } $TopLevelCount | Should -Be 1 -Because "CLAUDE.md one-function-per-file: $File declares top-level function(s): $($TopLevelNames -join ', ')" @@ -267,3 +270,103 @@ Describe 'Module code does not use Write-Host' { $hits -join "`n" | Should -BeNullOrEmpty -Because "CLAUDE.md: do not use Write-Host in module code (use Write-SEBLog) in $File`n$($hits -join "`n")" } } + +# -- Anti-false-green guard A: non-vacuity sentinel --------------------------------------------- +# The rule Describes above are -ForEach data-driven from BeforeDiscovery enumerations. If any +# enumeration returns EMPTY (a path typo, a glob regression, a worktree layout change), Pester +# expands that Describe to ZERO Its and reports GREEN while enforcing NOTHING. These sentinel Its +# assert each discovered case count (captured at discovery, surfaced here as $Count) exceeds a sane +# floor (real current count minus a margin), so an empty/under-populated discovery is a RED build. +# Authoring-time counts: 80 exported public fns, 80 Public files, 39 Private files, 135 module +# source files, 122 top-level naming cases. Bump floors UP as the codebase grows; never down. +Describe 'Contract discovery is non-vacuous (sentinel)' { + It 'discovered a healthy number of ' -ForEach $script:sentinelCases { + $Count | Should -BeGreaterThan $Floor -Because "an empty/under-populated discovery would vacuously pass the corresponding rule(s); discovered $Count $What (floor $Floor)" + } +} + +# -- Anti-false-green guard B: stale-allow-list guard ------------------------------------------- +# The four allow-lists above are append-only baselines. Without a guard they are STICKY: if a +# function's debt is later fixed (it gains [OutputType()], a private function is renamed off the +# SEB prefix, a multi-function file is split, a Write-Host is removed) but its allow-list entry is +# left behind, that stale entry would silently MASK a NEW violation should the same name/site recur. +# These guards assert every allow-list ENTRY still maps to a REAL current violation; a fixed-but- +# still-listed entry FAILS, forcing the list to be pruned. This keeps the baseline honest. +Describe 'Allow-lists contain no stale entries (guard)' { + + BeforeAll { + . "$PSScriptRoot/_ContractAst.ps1" + $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path + $rel = { param($p) $p.Replace($repoRoot, '').TrimStart('\', '/').Replace('\', '/') } + + # Rebuild the exported-name set here (self-contained; do not rely on discovery-scope leakage). + $exportedNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($mm in @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Filter '*.psd1')) { + $md = Import-PowerShellDataFile -Path $mm.FullName + foreach ($fn in [string[]]$md.FunctionsToExport) { if ($fn) { [void]$exportedNames.Add($fn) } } + } + + $publicFiles = @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Filter '*.ps1' | + Where-Object { $_.FullName -match '[\\/]Public[\\/]' }) + $privateFiles = @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Filter '*.ps1' | + Where-Object { $_.FullName -match '[\\/]Private[\\/]' }) + $moduleSourceFiles = @(Get-ChildItem -Path "$repoRoot/Modules" -Recurse -File -Include '*.ps1', '*.psm1') + + # Top-level public functions that genuinely LACK [OutputType()] right now. + $script:noOutputTypeNow = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($f in $publicFiles) { + foreach ($info in (Get-ContractFunctionInfo -Path $f.FullName)) { + if ($info.IsTopLevel -and $exportedNames.Contains($info.Name) -and -not $info.HasOutputType) { + [void]$script:noOutputTypeNow.Add($info.Name) + } + } + } + + # Top-level PRIVATE (non-exported) functions that genuinely carry a -SEB prefix right now. + $script:privateSebNow = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($f in $privateFiles) { + foreach ($info in (Get-ContractFunctionInfo -Path $f.FullName)) { + if ($info.IsTopLevel -and -not $exportedNames.Contains($info.Name) -and $info.Name -match '-SEB') { + [void]$script:privateSebNow.Add($info.Name) + } + } + } + + # Relative paths of Public files that genuinely define MORE than one top-level function now. + $script:multiFnFilesNow = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($f in @($publicFiles + $privateFiles)) { + $top = @((Get-ContractFunctionInfo -Path $f.FullName) | Where-Object { $_.IsTopLevel }) + if ($top.Count -gt 1) { [void]$script:multiFnFilesNow.Add((& $rel $f.FullName)) } + } + + # "relative/path:line" of every Write-Host call site that genuinely exists in module code now. + $script:writeHostSitesNow = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($f in $moduleSourceFiles) { + foreach ($c in (Get-CommandAstsFromFile -Path $f.FullName)) { + if ($c.GetCommandName() -eq 'Write-Host') { + [void]$script:writeHostSitesNow.Add("$(& $rel $f.FullName):$($c.Extent.StartLineNumber)") + } + } + } + } + + It 'every allowNoOutputType entry is STILL a public function lacking [OutputType()]' { + $stale = @($script:allowNoOutputType | Where-Object { -not $script:noOutputTypeNow.Contains($_) }) + $stale -join ', ' | Should -BeNullOrEmpty -Because "these now declare [OutputType()] (or are no longer exported) and must be REMOVED from allowNoOutputType: $($stale -join ', ')" + } + + It 'every allowPrivateSebPrefix entry is STILL a private SEB-prefixed function' { + $stale = @($script:allowPrivateSebPrefix | Where-Object { -not $script:privateSebNow.Contains($_) }) + $stale -join ', ' | Should -BeNullOrEmpty -Because "these are no longer private-and-SEB-prefixed (renamed or now exported) and must be REMOVED from allowPrivateSebPrefix: $($stale -join ', ')" + } + + It 'every allowMultiFunctionFiles entry is STILL a multi-top-level-function file' { + $stale = @($script:allowMultiFunctionFiles.Keys | Where-Object { -not $script:multiFnFilesNow.Contains($_) }) + $stale -join ', ' | Should -BeNullOrEmpty -Because "these no longer define more than one top-level function and must be REMOVED from allowMultiFunctionFiles: $($stale -join ', ')" + } + + It 'every allowWriteHost entry is STILL a real Write-Host call site' { + $stale = @($script:allowWriteHost | Where-Object { -not $script:writeHostSitesNow.Contains($_) }) + $stale -join ', ' | Should -BeNullOrEmpty -Because "these no longer point at a Write-Host call (removed or line moved) and must be REMOVED/updated in allowWriteHost: $($stale -join ', ')" + } +} diff --git a/Tests/Contract/Exports.Tests.ps1 b/Tests/Contract/Exports.Tests.ps1 index 7f5cc13..1b894d3 100644 --- a/Tests/Contract/Exports.Tests.ps1 +++ b/Tests/Contract/Exports.Tests.ps1 @@ -8,7 +8,8 @@ # This file also enforces the REVERSE direction and the wider export wiring (issue #21): # - reverse-export: every root FunctionsToExport entry has a backing Public/*.ps1 file; # - the root manifest's export set equals the union of every sub-module manifest's exports; -# - every module directory on disk is listed in the root SEBackup.psm1 $Modules array. +# - every module directory on disk (one carrying a matching .psd1/.psm1 -- see below) is +# listed in the root SEBackup.psm1 $Modules array. BeforeDiscovery { $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path @@ -18,6 +19,7 @@ BeforeDiscovery { } BeforeAll { + . "$PSScriptRoot/_ContractAst.ps1" # memoized Get-ContractAst (shared parse cache) $repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path $manifest = Import-PowerShellDataFile -Path "$repoRoot/SEBackup.psd1" @@ -39,8 +41,7 @@ BeforeAll { } # The $Modules array from the root SEBackup.psm1, read via AST (not by executing the module). - $tokens = $null; $errors = $null - $psm1Ast = [System.Management.Automation.Language.Parser]::ParseFile("$repoRoot/SEBackup.psm1", [ref]$tokens, [ref]$errors) + $psm1Ast = (Get-ContractAst -Path "$repoRoot/SEBackup.psm1").Ast $assign = @($psm1Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true) | Where-Object { $_.Left.Extent.Text -eq '$Modules' }) | Select-Object -First 1 $script:listedModules = @() @@ -48,7 +49,19 @@ BeforeAll { $script:listedModules = @($assign.Right.FindAll( { param($n) $n -is [System.Management.Automation.Language.StringConstantExpressionAst] }, $true).Value) } - $script:moduleDirs = @(Get-ChildItem -Path "$repoRoot/Modules" -Directory | Select-Object -ExpandProperty Name) + + # A directory under Modules/ counts as a MODULE only if it carries a matching manifest/module + # file (.psd1 or .psm1), exactly as SEBackup.psm1 imports them + # (Modules\\.psm1). This deliberately IGNORES non-module directories that can appear + # under Modules/ -- e.g. the gitignored Modules/Credentials/ that the DPAPI credential resolver + # may create at runtime (.gitignore), or a leftover test fixture. Treating ANY subdirectory as a + # module made the "every module dir is listed" rule FAIL on such a stray dir and block every PR. + $script:moduleDirs = @( + Get-ChildItem -Path "$repoRoot/Modules" -Directory | Where-Object { + (Test-Path -LiteralPath (Join-Path $_.FullName "$($_.Name).psd1")) -or + (Test-Path -LiteralPath (Join-Path $_.FullName "$($_.Name).psm1")) + } | Select-Object -ExpandProperty Name + ) } Describe 'Root manifest exports every public sub-module function' { diff --git a/Tests/Contract/Parse.Tests.ps1 b/Tests/Contract/Parse.Tests.ps1 index ee220d9..c3c7c1d 100644 --- a/Tests/Contract/Parse.Tests.ps1 +++ b/Tests/Contract/Parse.Tests.ps1 @@ -12,11 +12,13 @@ BeforeDiscovery { ForEach-Object { @{ Path = $_.FullName; Name = $_.FullName.Replace($repoRoot, '').TrimStart('\', '/') } } } +BeforeAll { + . "$PSScriptRoot/_ContractAst.ps1" # memoized Get-ContractAst (caches AST + parse errors) +} + Describe 'Source files parse without errors' { It 'parses with no syntax errors' -ForEach $script:sourceFiles { - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) | Out-Null + $errors = @((Get-ContractAst -Path $Path).Errors) $messages = ($errors | ForEach-Object { "L$($_.Extent.StartLineNumber): $($_.Message)" }) -join "`n" $errors.Count | Should -Be 0 -Because "the file must parse cleanly:`n$messages" } diff --git a/Tests/Contract/RemoteScriptBlock.Tests.ps1 b/Tests/Contract/RemoteScriptBlock.Tests.ps1 index 3d37c5b..9e65939 100644 --- a/Tests/Contract/RemoteScriptBlock.Tests.ps1 +++ b/Tests/Contract/RemoteScriptBlock.Tests.ps1 @@ -13,6 +13,10 @@ BeforeDiscovery { ForEach-Object { @{ Path = $_.FullName; Name = $_.FullName.Replace($repoRoot, '').TrimStart('\', '/') } } } +BeforeAll { + . "$PSScriptRoot/_ContractAst.ps1" # memoized Get-ContractAst (shared parse cache) +} + Describe 'Remote-executed script blocks stay node-local' { It 'remote blocks in call no SEB functions and reference no C&C vars' -ForEach $script:callerFiles { # C&C-local variables that must never be referenced directly inside a remote block. @@ -23,8 +27,7 @@ Describe 'Remote-executed script blocks stay node-local' { ) $remoteWrappers = @('Invoke-SEBWithShadowCopy', 'Invoke-SEBRemoteCommand') - $tokens = $null; $errors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + $ast = (Get-ContractAst -Path $Path).Ast $violations = [System.Collections.Generic.List[string]]::new() diff --git a/Tests/Contract/_ContractAst.ps1 b/Tests/Contract/_ContractAst.ps1 new file mode 100644 index 0000000..24ea5eb --- /dev/null +++ b/Tests/Contract/_ContractAst.ps1 @@ -0,0 +1,74 @@ +# Shared AST memoization for the Contract suite. +# +# WHY: the contract tests are AST lint over the SAME ~135 module source files, and several of +# them parse each file independently (Conventions parses every Public/Private file 2-3x just to +# build its discovery cases, then again at run time for the alias/Write-Host scan; Parse, +# ApiContract, RemoteScriptBlock and Exports each parse overlapping sets too). Re-running +# [Parser]::ParseFile on the same path many times dominates contract DISCOVERY time. +# +# This helper parses each file EXACTLY ONCE and caches the resulting AST in a script-scoped +# hashtable keyed by full path. The cache lives in this dot-sourced file's own script scope, +# which Pester keeps alive across every container in a single Invoke-Pester run (verified: +# a cache populated in one .Tests.ps1's BeforeDiscovery is reused by the next), so all +# discovery sites -- across all contract files -- share one parse per file. +# +# Dot-source this from each contract file's BeforeDiscovery AND BeforeAll: +# . "$PSScriptRoot/_ContractAst.ps1" +# then call Get-ContractAst / Get-ContractFunctionInfo / Get-CommandAstsFromFile instead of +# calling [Parser]::ParseFile directly. + +# Initialise the cache once. Guard with Get-Variable so re-dot-sourcing (each container dot-sources +# this file again) does NOT wipe the accumulated cache. +if (-not (Get-Variable -Name __ContractAstCache -Scope Script -ErrorAction SilentlyContinue)) { + $script:__ContractAstCache = @{} +} + +function Get-ContractAst { + # Parse $Path once and return its ScriptBlockAst; subsequent calls for the same path return + # the cached AST. Tokens/parse-errors are cached alongside so callers that need errors (the + # parse-integrity contract) can read them without re-parsing. + param([Parameter(Mandatory)][string]$Path) + $key = [System.IO.Path]::GetFullPath($Path) + if (-not $script:__ContractAstCache.ContainsKey($key)) { + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($key, [ref]$tokens, [ref]$errors) + $script:__ContractAstCache[$key] = [pscustomobject]@{ + Ast = $ast + Tokens = $tokens + Errors = @($errors) + } + } + return $script:__ContractAstCache[$key] +} + +function Get-ContractFunctionInfo { + # Returns one hashtable per function definition found in $Path, with the metadata each + # convention test needs. IsTopLevel=$true means the function is at file scope (not nested + # inside another function) -- used by the one-function-per-file and naming rules. + param([Parameter(Mandatory)][string]$Path) + $parsed = Get-ContractAst -Path $Path + $ast = $parsed.Ast + $topLevel = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) + $all = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) + foreach ($fn in $all) { + $attrs = @() + if ($fn.Body.ParamBlock) { $attrs = @($fn.Body.ParamBlock.Attributes.TypeName.FullName) } + $help = $fn.GetHelpContent() + @{ + Name = $fn.Name + IsTopLevel = ($topLevel -contains $fn) + HasCmdletBind = ($attrs -contains 'CmdletBinding') + HasOutputType = ($attrs -contains 'OutputType') + HasSynopsis = ($help -and -not [string]::IsNullOrWhiteSpace($help.Synopsis)) + HasDescription = ($help -and -not [string]::IsNullOrWhiteSpace($help.Description)) + HasExample = ($help -and $help.Examples -and $help.Examples.Count -gt 0) + } + } +} + +function Get-CommandAstsFromFile { + # All CommandAst nodes (including nested) in $Path, from the cached AST. + param([Parameter(Mandatory)][string]$Path) + $parsed = Get-ContractAst -Path $Path + return @($parsed.Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)) +}