From 3051a276ea0e5eb914d7a5e9f49bbb277eed85c2 Mon Sep 17 00:00:00 2001 From: fadwen <110697945+fadwen@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:18:18 +0000 Subject: [PATCH] chore: sync Copilot instructions from standards repo --- .github/copilot-instructions.md | 29 +- .github/instructions/comments.instructions.md | 2 +- .../errorsandlogs.instructions.md | 4 +- .github/instructions/module.instructions.md | 6 +- .../mocking-patterns.md | 2 +- .../unit-test-template.md | 4 +- .github/instructions/pester.instructions.md | 9 +- .github/instructions/platyps.instructions.md | 2 +- .../style-enforcement.instructions.md | 2 +- .../Examples/Basic-Function-Example.ps1 | 250 ++++++++++++++++++ .../Configuration/DefaultConfiguration.psd1 | 154 +++++++++++ .../Classes/ExampleClass.ps1 | 32 +++ .../ModuleExample.psd1 | 34 +++ .../ModuleExample.psm1 | 56 ++++ .../Private/Connect-ExampleService.ps1 | 58 ++++ .../Private/Get-ExampleServiceStatus.ps1 | 50 ++++ .../Public/Get-ExampleData.ps1 | 55 ++++ .../Module-Structure-Example/README.md | 123 +++++++++ .../Tests/ModuleExample.Tests.ps1 | 231 ++++++++++++++++ .../docs/ModuleExample/Get-ExampleData.md | 162 ++++++++++++ .../docs/ModuleExample/ModuleExample.md | 24 ++ .../en-US/ModuleExample-Help.xml | 169 ++++++++++++ .../en-US/about_ModuleExample.help.txt | 69 +++++ powershell-standards/Examples/README.md | 64 +++++ .../Testing-Examples/Basic-Function.Tests.ps1 | 179 +++++++++++++ 25 files changed, 1744 insertions(+), 26 deletions(-) create mode 100644 powershell-standards/Examples/Basic-Function-Example.ps1 create mode 100644 powershell-standards/Examples/Configuration/DefaultConfiguration.psd1 create mode 100644 powershell-standards/Examples/Module-Structure-Example/Classes/ExampleClass.ps1 create mode 100644 powershell-standards/Examples/Module-Structure-Example/ModuleExample.psd1 create mode 100644 powershell-standards/Examples/Module-Structure-Example/ModuleExample.psm1 create mode 100644 powershell-standards/Examples/Module-Structure-Example/Private/Connect-ExampleService.ps1 create mode 100644 powershell-standards/Examples/Module-Structure-Example/Private/Get-ExampleServiceStatus.ps1 create mode 100644 powershell-standards/Examples/Module-Structure-Example/Public/Get-ExampleData.ps1 create mode 100644 powershell-standards/Examples/Module-Structure-Example/README.md create mode 100644 powershell-standards/Examples/Module-Structure-Example/Tests/ModuleExample.Tests.ps1 create mode 100644 powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/Get-ExampleData.md create mode 100644 powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/ModuleExample.md create mode 100644 powershell-standards/Examples/Module-Structure-Example/en-US/ModuleExample-Help.xml create mode 100644 powershell-standards/Examples/Module-Structure-Example/en-US/about_ModuleExample.help.txt create mode 100644 powershell-standards/Examples/README.md create mode 100644 powershell-standards/Examples/Testing-Examples/Basic-Function.Tests.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3402493..421953e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -717,21 +717,28 @@ described above: | Need | Example | |---|---| -| A complete advanced function | [Basic-Function-Example.ps1](../Documentation/Examples/Basic-Function-Example.ps1) | -| Module layout and export boundary | [Module-Structure-Example](../Documentation/Examples/Module-Structure-Example/) | -| Pester 6 tests, including private functions | [Module-Structure-Example/Tests](../Documentation/Examples/Module-Structure-Example/Tests/) | -| Mocking CIM and typed parameters | [Testing-Examples](../Documentation/Examples/Testing-Examples/) | -| A module template to copy | [Templates/Powershell-Module](../Templates/Powershell-Module/) | +| A complete advanced function | [Basic-Function-Example.ps1](../powershell-standards/Examples/Basic-Function-Example.ps1) | +| Module layout and export boundary | [Module-Structure-Example](../powershell-standards/Examples/Module-Structure-Example/) | +| Pester 6 tests, including private functions | [Module-Structure-Example/Tests](../powershell-standards/Examples/Module-Structure-Example/Tests/) | +| Mocking CIM and typed parameters | [Testing-Examples](../powershell-standards/Examples/Testing-Examples/) | +| A module template to copy | [Templates/Powershell-Module](https://github.com/fadwen/ai-powershell-standards/tree/main/Templates/Powershell-Module) | -[Test-QualityGates.ps1](../Documentation/Examples/Test-QualityGates.ps1) is the exception: it -intentionally violates these standards so the gates have something to catch. Never use it as a model. +[Test-QualityGates.ps1](https://github.com/fadwen/ai-powershell-standards/blob/main/Documentation/Anti-Patterns/Test-QualityGates.ps1) +is the exception: it intentionally violates these standards so the gates have something to catch. +Never use it as a model. It is not mirrored into consuming projects. ### Implementation Documentation -- [Implementation Guide](../Documentation/Implementation-Guide.md): Step-by-step setup and adoption -- [PowerShell Best Practices](../Documentation/PowerShell-Best-Practices.md): Comprehensive community standards -- [Enterprise Extensions](../Documentation/Enterprise-Extensions.md): Organizational customizations -- [Troubleshooting Guides](../Troubleshooting/): Organized problem-solving resources +These live in the standards repository only and are not mirrored into consuming projects: + +- [Implementation Guide](https://github.com/fadwen/ai-powershell-standards/blob/main/Documentation/Implementation-Guide.md): + step-by-step setup and adoption +- [PowerShell Best Practices](https://github.com/fadwen/ai-powershell-standards/blob/main/Documentation/PowerShell-Best-Practices.md): + comprehensive community standards +- [Enterprise Extensions](https://github.com/fadwen/ai-powershell-standards/blob/main/Documentation/Enterprise-Extensions.md): + organizational customizations +- [Troubleshooting Guides](https://github.com/fadwen/ai-powershell-standards/tree/main/Troubleshooting): + organized problem-solving resources --- diff --git a/.github/instructions/comments.instructions.md b/.github/instructions/comments.instructions.md index 6cbf2f7..48cae17 100644 --- a/.github/instructions/comments.instructions.md +++ b/.github/instructions/comments.instructions.md @@ -32,7 +32,7 @@ becomes a second copy that drifts. ## Help Generation Requirements > **Worked example**: -> [Basic-Function-Example.ps1](../../Documentation/Examples/Basic-Function-Example.ps1) carries a +> [Basic-Function-Example.ps1](../../powershell-standards/Examples/Basic-Function-Example.ps1) carries a > complete help block in the form described here - synopsis, description, per-parameter text, > multiple examples with expected output, and `.NOTES` with troubleshooting links but no change > history. It is a standalone function, so the whole block lives in the `.ps1`. In a module, that diff --git a/.github/instructions/errorsandlogs.instructions.md b/.github/instructions/errorsandlogs.instructions.md index 06ac6f4..bc32678 100644 --- a/.github/instructions/errorsandlogs.instructions.md +++ b/.github/instructions/errorsandlogs.instructions.md @@ -11,8 +11,8 @@ Implement robust error handling and structured logging patterns for PowerShell c correlation tracking, and enterprise-grade diagnostic capabilities. > **Worked examples**: -> [Basic-Function-Example.ps1](../../Documentation/Examples/Basic-Function-Example.ps1) and -> [Get-ExampleData.ps1](../../Documentation/Examples/Module-Structure-Example/Public/Get-ExampleData.ps1) +> [Basic-Function-Example.ps1](../../powershell-standards/Examples/Basic-Function-Example.ps1) and +> [Get-ExampleData.ps1](../../powershell-standards/Examples/Module-Structure-Example/Public/Get-ExampleData.ps1) > both generate a correlation ID once in `begin`, carry it through every message, use `$_` in the > catch, and `continue` so one failed item does not abort the batch. Both failure paths are covered > by tests. diff --git a/.github/instructions/module.instructions.md b/.github/instructions/module.instructions.md index 7e976d0..2d17522 100644 --- a/.github/instructions/module.instructions.md +++ b/.github/instructions/module.instructions.md @@ -37,7 +37,7 @@ Collect essential information for module development: ## Module Structure Generation > **Worked example**: -> [Documentation/Examples/Module-Structure-Example](../../Documentation/Examples/Module-Structure-Example/) +> [powershell-standards/Examples/Module-Structure-Example](../../powershell-standards/Examples/Module-Structure-Example/) > is a small working module implementing everything in this section - the folder layout, the load > order in `ModuleExample.psm1`, an explicit `FunctionsToExport`, a class used as a named output > type, and a private helper that is never exported. Read it before generating a new module; prefer @@ -94,7 +94,7 @@ comment block. ### Module Manifest Creation Generate comprehensive module manifest (ModuleName.psd1). For a complete, valid manifest see -[ModuleExample.psd1](../../Documentation/Examples/Module-Structure-Example/ModuleExample.psd1) - +[ModuleExample.psd1](../../powershell-standards/Examples/Module-Structure-Example/ModuleExample.psd1) - note that `FunctionsToExport` names each public function explicitly, which is what keeps private helpers internal: @@ -150,7 +150,7 @@ helpers internal: Create optimized root module file (ModuleName.psm1). Load order matters: classes first, then private functions, then public ones - see -[ModuleExample.psm1](../../Documentation/Examples/Module-Structure-Example/ModuleExample.psm1) for a +[ModuleExample.psm1](../../powershell-standards/Examples/Module-Structure-Example/ModuleExample.psm1) for a working loader. ```powershell diff --git a/.github/instructions/pester-supporting-docs/mocking-patterns.md b/.github/instructions/pester-supporting-docs/mocking-patterns.md index 7c4296a..42ff7b4 100644 --- a/.github/instructions/pester-supporting-docs/mocking-patterns.md +++ b/.github/instructions/pester-supporting-docs/mocking-patterns.md @@ -70,7 +70,7 @@ InModuleScope MyModule { ``` Worked instance: -[Module-Structure-Example/Tests](../../../Documentation/Examples/Module-Structure-Example/Tests/) +[Module-Structure-Example/Tests](../../../powershell-standards/Examples/Module-Structure-Example/Tests/) mocks a private function this way to exercise a per-item failure path. ## Global Mocks (Experimental) diff --git a/.github/instructions/pester-supporting-docs/unit-test-template.md b/.github/instructions/pester-supporting-docs/unit-test-template.md index 5f2b7a0..496cac7 100644 --- a/.github/instructions/pester-supporting-docs/unit-test-template.md +++ b/.github/instructions/pester-supporting-docs/unit-test-template.md @@ -9,9 +9,9 @@ text descriptions and standard ASCII characters only. ## Standard Unit Test Structure > Complete passing implementations of this template: -> [Module-Structure-Example/Tests](../../../Documentation/Examples/Module-Structure-Example/Tests/) +> [Module-Structure-Example/Tests](../../../powershell-standards/Examples/Module-Structure-Example/Tests/) > and -> [Testing-Examples](../../../Documentation/Examples/Testing-Examples/). +> [Testing-Examples](../../../powershell-standards/Examples/Testing-Examples/). Use this template for all unit tests: diff --git a/.github/instructions/pester.instructions.md b/.github/instructions/pester.instructions.md index b80944f..f19c891 100644 --- a/.github/instructions/pester.instructions.md +++ b/.github/instructions/pester.instructions.md @@ -214,13 +214,14 @@ tests. Reach into the module instead. These are complete, passing implementations of the patterns above. Prefer matching them over inventing a structure: -- [Module-Structure-Example/Tests](../../Documentation/Examples/Module-Structure-Example/Tests/) - +- [Module-Structure-Example/Tests](../../powershell-standards/Examples/Module-Structure-Example/Tests/) - module contract, class assertions, `InModuleScope` for two private functions, and a per-item failure path -- [Testing-Examples/Basic-Function.Tests.ps1](../../Documentation/Examples/Testing-Examples/Basic-Function.Tests.ps1) - +- [Basic-Function.Tests.ps1](../../powershell-standards/Examples/Testing-Examples/Basic-Function.Tests.ps1) - CIM mocking, `-RemoveParameterType`, `-TestCases`, and mock scoping across contexts -- [Tools/Tests](../../Tools/Tests/) - tests for the repository's own tooling, including fixture - files written per test and error-path coverage +- [Tools/Tests](https://github.com/fadwen/ai-powershell-standards/tree/main/Tools/Tests) - tests for the standards + repository's own tooling, including fixture files written per test and error-path coverage. Not + mirrored into consuming projects ### Documentation Integration diff --git a/.github/instructions/platyps.instructions.md b/.github/instructions/platyps.instructions.md index 4d05b76..9c0be4d 100644 --- a/.github/instructions/platyps.instructions.md +++ b/.github/instructions/platyps.instructions.md @@ -279,7 +279,7 @@ working the moment the repository moves or goes private. Reserve URLs for materi lives outside the module, remembering that a user who installed from the Gallery has no repository checkout — which is also why a relative path would be useless even if `Get-Help` accepted it. -[Module-Structure-Example](../../Documentation/Examples/Module-Structure-Example/docs/ModuleExample/Get-ExampleData.md) +[Module-Structure-Example](../../powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/Get-ExampleData.md) shows both forms in one file. Also fail the build on leftover placeholders — `Test-MarkdownCommandHelp` checks structure, not diff --git a/.github/instructions/style-enforcement.instructions.md b/.github/instructions/style-enforcement.instructions.md index 5fc7090..2bd109d 100644 --- a/.github/instructions/style-enforcement.instructions.md +++ b/.github/instructions/style-enforcement.instructions.md @@ -8,7 +8,7 @@ description: 'Automatic style guide enforcement' Automatically enforce PowerShell community style guidelines in all code generation. > **Worked example**: every file under -> [Documentation/Examples](../../Documentation/Examples/) is written to these rules and passes the +> [powershell-standards/Examples](../../powershell-standards/Examples/) is written to these rules and passes the > repository's own quality gates. When the wording here is ambiguous, match the examples. ## Mandatory Style Patterns diff --git a/powershell-standards/Examples/Basic-Function-Example.ps1 b/powershell-standards/Examples/Basic-Function-Example.ps1 new file mode 100644 index 0000000..000d7c4 --- /dev/null +++ b/powershell-standards/Examples/Basic-Function-Example.ps1 @@ -0,0 +1,250 @@ +<# +.SYNOPSIS + Example of a basic enterprise-standard PowerShell function + +.DESCRIPTION + This function demonstrates all the key patterns and standards + required for enterprise PowerShell development using our + GitHub Copilot standards. + +.PARAMETER ComputerName + [String[]] (Mandatory: Yes, Pipeline: ByValue, ByPropertyName) + + One or more computer names to query for information. + + VALIDATION: + - Must be valid computer names (NetBIOS or FQDN) + - Pattern: Letters, numbers, hyphens, and dots only + - Length: 1-255 characters + + EXAMPLES: + - "SERVER01" + - "web01.contoso.com" + - @("APP01", "APP02", "DB01") + +.PARAMETER Credential + [PSCredential] (Mandatory: No, Pipeline: No) + + Credential to use for remote connections. If not provided, + uses current user context. + +.PARAMETER IncludeServices + [Switch] (Mandatory: No, Pipeline: No) + + Include running services information in the output. + +.EXAMPLE + PS> Get-BasicServerInfo -ComputerName "SERVER01" + + DESCRIPTION: Basic server information retrieval + OUTPUT: Server information object with OS and hardware details + USE CASE: Daily server health checks + +.EXAMPLE + PS> @("WEB01", "WEB02") | Get-BasicServerInfo -IncludeServices + + DESCRIPTION: Pipeline processing with service information + OUTPUT: Server info objects including running services + USE CASE: Comprehensive server inventory + +.EXAMPLE + PS> Get-BasicServerInfo -ComputerName "PROD-DB01" -Credential $cred | Export-Csv "server-info.csv" + + DESCRIPTION: Secure connection with credential and export + OUTPUT: CSV file with server information + USE CASE: Automated reporting for compliance + +.NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + Version: 1.0.0 + Last Updated: 2024-01-15 + + DEPENDENCIES: + - PowerShell 7.6 (LTS) or Windows PowerShell 5.1 + - WinRM enabled on target computers + - Appropriate permissions on target systems + + PERFORMANCE: + - Execution time: ~10-30 seconds per server + - Memory usage: ~5MB per server + - Network bandwidth: Minimal (WMI queries only) + + TROUBLESHOOTING: + - Connection issues: .\Troubleshooting\Common\Function-Issues.md + - WinRM problems: .\Troubleshooting\Security\WinRM-Setup.md + - Performance: .\Troubleshooting\Performance\Server-Monitoring.md +#> + +function Get-BasicServerInfo { + [CmdletBinding(SupportsShouldProcess = $true)] + # Quoted, and named. [OutputType([PSCustomObject])] is the anti-pattern the + # standards call out: it tells a caller nothing about the shape returned. + [OutputType('BasicServerInfo')] + param( + [Parameter(Mandatory = $true, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true, + HelpMessage = "Enter one or more computer names")] + [ValidateNotNullOrEmpty()] + [ValidatePattern('^[a-zA-Z0-9\-\.]+$')] + [ValidateLength(1, 255)] + [Alias('CN', 'ServerName', 'Name')] + [string[]]$ComputerName, + + [Parameter()] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential, + + [Parameter()] + [switch]$IncludeServices + ) + + begin { + # Initialize correlation tracking for enterprise monitoring + $correlationId = [System.Guid]::NewGuid() + $startTime = Get-Date + + Write-Verbose "Starting $($MyInvocation.MyCommand.Name) - CorrelationId: $correlationId" + Write-Verbose "Parameters: ComputerName count = $($ComputerName.Count), IncludeServices = $IncludeServices" + + # Initialize counters for performance tracking + $processedCount = 0 + $successCount = 0 + $failureCount = 0 + + # Prepare credential for CIM sessions if provided + $cimSessionOptions = New-CimSessionOption -Protocol WSMan + $sessionParams = @{ + SessionOption = $cimSessionOptions + ErrorAction = 'Stop' + } + if ($Credential) { + $sessionParams.Credential = $Credential + } + } + + process { + foreach ($computer in $ComputerName) { + $processedCount++ + $computerStartTime = Get-Date + + try { + Write-Verbose "Processing computer: $computer (CorrelationId: $correlationId)" + + if ($PSCmdlet.ShouldProcess($computer, "Retrieve server information")) { + + # Test connectivity first + $connectionTest = Test-Connection -ComputerName $computer -Count 1 -Quiet -ErrorAction SilentlyContinue + if (-not $connectionTest) { + throw "Computer $computer is not reachable via ping" + } + + # Create CIM session for efficient querying + $sessionParams.ComputerName = $computer + $cimSession = New-CimSession @sessionParams + + try { + # Gather basic system information + Write-Verbose "Querying system information for $computer" + $os = Get-CimInstance -CimSession $cimSession -ClassName Win32_OperatingSystem + $computerSystem = Get-CimInstance -CimSession $cimSession -ClassName Win32_ComputerSystem + $processor = Get-CimInstance -CimSession $cimSession -ClassName Win32_Processor | Select-Object -First 1 + + # Calculate uptime + $uptime = (Get-Date) - $os.LastBootUpTime + + # Build result object with raw data for maximum reusability. + # PSTypeName gives the output a name that matches [OutputType]. + $result = [PSCustomObject]@{ + PSTypeName = 'BasicServerInfo' + ComputerName = $computer + OperatingSystem = $os.Caption + Version = $os.Version + ServicePackLevel = $os.ServicePackMajorVersion + Architecture = $os.OSArchitecture + LastBootTime = $os.LastBootUpTime + UptimeDays = [math]::Round($uptime.TotalDays, 2) + TotalMemoryGB = [math]::Round($computerSystem.TotalPhysicalMemory / 1GB, 2) + Manufacturer = $computerSystem.Manufacturer + Model = $computerSystem.Model + ProcessorName = $processor.Name + ProcessorCores = $processor.NumberOfCores + ProcessorLogicalProcessors = $processor.NumberOfLogicalProcessors + Domain = $computerSystem.Domain + Workgroup = $computerSystem.Workgroup + CorrelationId = $correlationId + QueryTime = Get-Date + QueryDurationMs = ((Get-Date) - $computerStartTime).TotalMilliseconds + } + + # Add services information if requested + if ($IncludeServices) { + Write-Verbose "Querying services information for $computer" + $runningServices = Get-CimInstance -CimSession $cimSession -ClassName Win32_Service | + Where-Object State -eq 'Running' | + Select-Object Name, DisplayName, StartMode + + $result | Add-Member -MemberType NoteProperty -Name 'RunningServices' -Value $runningServices + $result | Add-Member -MemberType NoteProperty -Name 'RunningServiceCount' -Value $runningServices.Count + } + + $successCount++ + Write-Verbose "Successfully processed $computer in $([math]::Round($result.QueryDurationMs, 0))ms" + + # Return the result object + $result + + } + finally { + # Clean up CIM session + if ($cimSession) { + Remove-CimSession -CimSession $cimSession -ErrorAction SilentlyContinue + } + } + } + + } + catch { + $failureCount++ + $errorDetails = @{ + ComputerName = $computer + CorrelationId = $correlationId + ErrorMessage = $_.Exception.Message + ErrorTime = Get-Date + Function = $MyInvocation.MyCommand.Name + } + + Write-Error "Failed to process computer '$computer': $($_.Exception.Message) (CorrelationId: $correlationId)" + Write-Verbose "Error details: $($errorDetails | ConvertTo-Json -Compress)" + + # Continue processing other computers instead of terminating + continue + } + } + } + + end { + $endTime = Get-Date + $totalDuration = ($endTime - $startTime).TotalSeconds + + # Performance and summary logging + $summary = @{ + CorrelationId = $correlationId + TotalComputers = $processedCount + SuccessfulQueries = $successCount + FailedQueries = $failureCount + SuccessRate = if ($processedCount -gt 0) { [math]::Round(($successCount / $processedCount) * 100, 1) } else { 0 } + TotalDurationSeconds = [math]::Round($totalDuration, 2) + AverageTimePerServer = if ($successCount -gt 0) { [math]::Round($totalDuration / $successCount, 2) } else { 0 } + } + + Write-Verbose "Completed $($MyInvocation.MyCommand.Name) - Summary: $($summary | ConvertTo-Json -Compress)" + + if ($failureCount -gt 0) { + Write-Warning "Operation completed with $failureCount failures out of $processedCount computers. Check error messages above for details." + } + } +} diff --git a/powershell-standards/Examples/Configuration/DefaultConfiguration.psd1 b/powershell-standards/Examples/Configuration/DefaultConfiguration.psd1 new file mode 100644 index 0000000..fc25a84 --- /dev/null +++ b/powershell-standards/Examples/Configuration/DefaultConfiguration.psd1 @@ -0,0 +1,154 @@ +@{ + # Module Behavior Settings + ModuleSettings = @{ + EnableVerboseLogging = $true + RequireCorrelationIds = $true + EnforceParameterValidation = $true + EnablePerformanceMonitoring = $true + } + + # Environment-Specific Configuration + Environments = @{ + Development = @{ + LogLevel = 'Verbose' + TimeoutSeconds = 30 + RetryCount = 3 + PerformanceThreshold = 5000 + AuditLevel = 'Full' + SecurityValidation = 'Relaxed' + } + + Testing = @{ + LogLevel = 'Standard' + TimeoutSeconds = 20 + RetryCount = 2 + PerformanceThreshold = 3000 + AuditLevel = 'Standard' + SecurityValidation = 'Standard' + } + + Production = @{ + LogLevel = 'Minimal' + TimeoutSeconds = 10 + RetryCount = 1 + PerformanceThreshold = 1000 + AuditLevel = 'Required' + SecurityValidation = 'Strict' + } + } + + # Security Configuration + Security = @{ + RequireEncryption = $true + AllowBasicAuth = $false # Expert feedback: Basic auth not recommended + RequireSignedCertificates = $true + EnforceCredentialValidation = $true + AuditAllOperations = $true + + # Approved authentication methods + AuthenticationMethods = @('Kerberos', 'Certificate', 'Negotiate') + + # Security scanning patterns + ProhibitedPatterns = @( + 'password\s*[:=]\s*["\x27]\w{3,}["\x27]', + 'api[_-]?key\s*[:=]\s*["\x27]\w{10,}["\x27]', + 'ConvertTo-SecureString.*-AsPlainText.*-Force' + ) + } + + # Performance Optimization Settings + Performance = @{ + # String operation thresholds (expert feedback integration) + StringConcatenationThreshold = 100 # Use StringBuilder above this + # Above this, use direct loop assignment or List[T] - never ArrayList. + # On PowerShell 7.5+, += on object arrays is optimized and this threshold can be relaxed. + ArrayAppendThreshold = 50 + + # Memory management + MaxMemoryUsageMB = 512 + EnableGarbageCollection = $true + + # Pipeline optimization + PreferPipelineOperations = $true + BatchSizeLimit = 1000 + } + + # Compliance Framework Integration + Compliance = @{ + SOX = @{ + RequireAuditTrails = $true + RequireApprovalWorkflow = $true + MandatoryFields = @('CorrelationId', 'UserName', 'Timestamp', 'Operation') + } + + GDPR = @{ + EnableDataMinimization = $true + RequireConsentTracking = $true + AutoDeleteExpiredData = $true + DataRetentionDays = 2555 # 7 years + } + + HIPAA = @{ + RequireEncryption = $true + EnableAccessLogging = $true + RequireRoleBasedAccess = $true + AuditAccessAttempts = $true + } + } + + # Error Handling Configuration + ErrorHandling = @{ + # Expert feedback: Use Write-Error -ErrorAction Stop instead of bare throw + UseWriteErrorForTermination = $true + + # Correlation ID requirements + RequireCorrelationIds = $true + + # Error escalation thresholds + EscalationLevels = @{ + Low = @{ ThresholdCount = 10; EscalationTimeMinutes = 60 } + Medium = @{ ThresholdCount = 5; EscalationTimeMinutes = 30 } + High = @{ ThresholdCount = 2; EscalationTimeMinutes = 15 } + Critical = @{ ThresholdCount = 1; EscalationTimeMinutes = 5 } + } + } + + # Quality Standards Integration + QualityStandards = @{ + # PSScriptAnalyzer settings + EnableStrictMode = $true + RequireApprovedVerbs = $true + MaxComplexityScore = 15 + MinimumTestCoverage = 80 + + # Documentation requirements + RequireCommentBasedHelp = $true + RequireExamples = $true + RequireParameterDocumentation = $true + + # Parameter validation standards + ValidateParametersBeforeDownstreamUsage = $true # Expert feedback Part 2 + TrimStringParametersAutomatically = $true + } + + # Integration Endpoints + Integration = @{ + LoggingService = @{ + Endpoint = 'https://logs.yourorg.com/api/v1/logs' + RequireAuthentication = $true + TimeoutSeconds = 30 + } + + SecurityService = @{ + Endpoint = 'https://security.yourorg.com/api/v1/audit' + RequireAuthentication = $true + TimeoutSeconds = 15 + } + + ComplianceService = @{ + Endpoint = 'https://compliance.yourorg.com/api/v1/events' + RequireAuthentication = $true + TimeoutSeconds = 45 + } + } +} \ No newline at end of file diff --git a/powershell-standards/Examples/Module-Structure-Example/Classes/ExampleClass.ps1 b/powershell-standards/Examples/Module-Structure-Example/Classes/ExampleClass.ps1 new file mode 100644 index 0000000..3a8b2c3 --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/Classes/ExampleClass.ps1 @@ -0,0 +1,32 @@ +class ExampleServiceResult { + <# + Demonstrates a PowerShell class used as a descriptive output type. + + The standards discourage [OutputType([PSCustomObject])] because it tells a + caller nothing about the shape returned. A class names the shape, provides + IntelliSense, and lets behaviour live alongside the data. + #> + + [string]$ServiceName + [string]$Environment + [ValidateSet('Healthy', 'Degraded', 'Unavailable')] + [string]$Status + [datetime]$CheckedAt + [guid]$CorrelationId + + ExampleServiceResult([string]$ServiceName, [string]$Environment, [guid]$CorrelationId) { + $this.ServiceName = $ServiceName + $this.Environment = $Environment + $this.Status = 'Unavailable' + $this.CheckedAt = Get-Date + $this.CorrelationId = $CorrelationId + } + + [bool] IsUsable() { + return $this.Status -in @('Healthy', 'Degraded') + } + + [string] ToString() { + return "$($this.ServiceName) [$($this.Environment)]: $($this.Status)" + } +} diff --git a/powershell-standards/Examples/Module-Structure-Example/ModuleExample.psd1 b/powershell-standards/Examples/Module-Structure-Example/ModuleExample.psd1 new file mode 100644 index 0000000..da1d07c --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/ModuleExample.psd1 @@ -0,0 +1,34 @@ +@{ + # Module Information + RootModule = 'ModuleExample.psm1' + ModuleVersion = '1.0.0' + GUID = '8f2b1c9d-4e7a-4b3f-9c21-5d6e8a0f7b34' + + # Author Information + Author = 'Jeffrey Stuhr' + CompanyName = 'YourOrg' + Copyright = '(c) 2026 YourOrg. All rights reserved.' + Description = 'Minimal reference module demonstrating the Public/Private/Classes layout and the export boundary described in the PowerShell Copilot Standards.' + + # PowerShell Requirements + # 7.6 is the current LTS. Use '5.1' with @('Desktop','Core') only when Windows + # PowerShell support is an explicit requirement. + PowerShellVersion = '7.6' + CompatiblePSEditions = @('Core') + + # Only the public surface is exported. Connect-ExampleService stays internal, so + # it can change without a breaking release. + FunctionsToExport = @('Get-ExampleData') + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('PowerShell', 'Example', 'Standards', 'Reference') + ProjectUri = 'https://github.com/fadwen/ai-powershell-standards' + LicenseUri = 'https://github.com/fadwen/ai-powershell-standards/blob/main/LICENSE' + ReleaseNotes = 'Reference implementation accompanying the PowerShell Copilot Standards.' + } + } +} diff --git a/powershell-standards/Examples/Module-Structure-Example/ModuleExample.psm1 b/powershell-standards/Examples/Module-Structure-Example/ModuleExample.psm1 new file mode 100644 index 0000000..36a7ca4 --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/ModuleExample.psm1 @@ -0,0 +1,56 @@ +<# + ModuleExample root module. + + Loads classes first, then private functions, then public ones - classes must be + available before any function that returns one is defined. Export is controlled + by the manifest's FunctionsToExport rather than a wildcard here, so the public + surface stays explicit. + + Quality Standards: https://github.com/fadwen/ai-powershell-standards +#> + +$ErrorActionPreference = 'Stop' + +#region Classes +# Classes load first: Get-ExampleData returns an ExampleServiceResult, so the type +# must exist before that function is dot-sourced. +$ClassFiles = Get-ChildItem -Path "$PSScriptRoot/Classes/*.ps1" -ErrorAction SilentlyContinue +foreach ($ClassFile in $ClassFiles) { + try { + . $ClassFile.FullName + Write-Verbose "Loaded class: $($ClassFile.BaseName)" + } + catch { + throw "Failed to load class $($ClassFile.BaseName): $($_.Exception.Message)" + } +} +#endregion + +#region Private Functions +# Internal helpers - deliberately not exported by the manifest. +$PrivateFunctions = Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction SilentlyContinue +foreach ($Function in $PrivateFunctions) { + try { + . $Function.FullName + Write-Verbose "Loaded private function: $($Function.BaseName)" + } + catch { + throw "Failed to load private function $($Function.BaseName): $($_.Exception.Message)" + } +} +#endregion + +#region Public Functions +$PublicFunctions = Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction SilentlyContinue +foreach ($Function in $PublicFunctions) { + try { + . $Function.FullName + Write-Verbose "Loaded public function: $($Function.BaseName)" + } + catch { + throw "Failed to load public function $($Function.BaseName): $($_.Exception.Message)" + } +} +#endregion + +Write-Verbose "ModuleExample loaded: $($PublicFunctions.Count) public, $($PrivateFunctions.Count) private" diff --git a/powershell-standards/Examples/Module-Structure-Example/Private/Connect-ExampleService.ps1 b/powershell-standards/Examples/Module-Structure-Example/Private/Connect-ExampleService.ps1 new file mode 100644 index 0000000..c43530d --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/Private/Connect-ExampleService.ps1 @@ -0,0 +1,58 @@ +function Connect-ExampleService { + <# + .SYNOPSIS + Opens a session against the example backing service. + + .DESCRIPTION + Private helper, not exported by the manifest. Demonstrates the boundary the + standards draw between internal and public surface: callers depend on + Get-ExampleData, so this can change shape without a breaking release. + + Returns a session descriptor the public function passes back in. There is no + real service behind it; the sleep stands in for connection latency. + + .PARAMETER Environment + Target environment for the connection. + + .PARAMETER CorrelationId + Correlation identifier propagated from the caller so a single operation can + be traced across functions. + + .EXAMPLE + PS> $session = Connect-ExampleService -Environment 'Test' -CorrelationId $id + + DESCRIPTION: Opens a session against the test environment. + OUTPUT: A hashtable describing the session. + USE CASE: Called by Get-ExampleData before querying. + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + #> + + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [ValidateSet('Development', 'Test', 'Production')] + [string]$Environment, + + [Parameter(Mandatory)] + [guid]$CorrelationId + ) + + process { + Write-Verbose "Connecting to $Environment - CorrelationId: $CorrelationId" + + # Stand-in for real connection latency; a genuine implementation would open + # a session here and throw on failure so the caller's catch block runs. + Start-Sleep -Milliseconds 20 + + @{ + Environment = $Environment + CorrelationId = $CorrelationId + ConnectedAt = Get-Date + Endpoint = "https://example-service.yourorg.com/$($Environment.ToLower())" + } + } +} diff --git a/powershell-standards/Examples/Module-Structure-Example/Private/Get-ExampleServiceStatus.ps1 b/powershell-standards/Examples/Module-Structure-Example/Private/Get-ExampleServiceStatus.ps1 new file mode 100644 index 0000000..b39b48a --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/Private/Get-ExampleServiceStatus.ps1 @@ -0,0 +1,50 @@ +function Get-ExampleServiceStatus { + <# + .SYNOPSIS + Returns the status of a single service. + + .DESCRIPTION + Private helper representing the one call in Get-ExampleData that can fail. + + It exists so the caller's per-item catch block is reachable. Without a + fallible call inside the loop, error handling in an example is decorative: + it looks like resilience but nothing can exercise it, and no test can prove + it works. + + There is no real service. Status is derived from the environment so a batch + returns a non-uniform result set. + + .PARAMETER ServiceName + Service to query. + + .PARAMETER Session + Session descriptor from Connect-ExampleService. + + .EXAMPLE + PS> Get-ExampleServiceStatus -ServiceName 'Billing' -Session $session + + DESCRIPTION: Queries one service over an open session. + OUTPUT: One of Healthy, Degraded, Unavailable. + USE CASE: Called per item by Get-ExampleData. + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + #> + + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string]$ServiceName, + + [Parameter(Mandatory)] + [hashtable]$Session + ) + + process { + Write-Verbose "Querying $ServiceName via $($Session.Endpoint)" + + if ($Session.Environment -eq 'Production') { 'Degraded' } else { 'Healthy' } + } +} diff --git a/powershell-standards/Examples/Module-Structure-Example/Public/Get-ExampleData.ps1 b/powershell-standards/Examples/Module-Structure-Example/Public/Get-ExampleData.ps1 new file mode 100644 index 0000000..ac602a0 --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/Public/Get-ExampleData.ps1 @@ -0,0 +1,55 @@ +function Get-ExampleData { + # The full help for this command lives in docs/ModuleExample/Get-ExampleData.md and + # ships compiled as en-US/ModuleExample-Help.xml. See + # .github/instructions/platyps.instructions.md + + <# + .EXTERNALHELP ModuleExample-Help.xml + .SYNOPSIS + Retrieves service status for one or more services. + #> + + [CmdletBinding()] + [OutputType('ExampleServiceResult')] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [string[]]$ServiceName, + + [Parameter()] + [ValidateSet('Development', 'Test', 'Production')] + [string]$Environment = 'Development', + + [Parameter()] + [guid]$CorrelationId = [guid]::NewGuid() + ) + + begin { + Write-Verbose "Starting $($MyInvocation.MyCommand.Name) - CorrelationId: $CorrelationId" + $session = Connect-ExampleService -Environment $Environment -CorrelationId $CorrelationId + $failureCount = 0 + } + + process { + foreach ($name in $ServiceName) { + try { + $result = [ExampleServiceResult]::new($name, $Environment, $CorrelationId) + + # The one fallible call in the loop. Keeping it in a helper is what + # makes the catch below reachable - and therefore testable. + $result.Status = Get-ExampleServiceStatus -ServiceName $name -Session $session + + $result + } + catch { + # $_ in the catch block, per the standards - not $Error[0] + $failureCount++ + Write-Error "Failed to query '$name': $($_.Exception.Message) (CorrelationId: $CorrelationId)" + continue + } + } + } + + end { + Write-Verbose "Completed with $failureCount failure(s) - CorrelationId: $CorrelationId" + } +} diff --git a/powershell-standards/Examples/Module-Structure-Example/README.md b/powershell-standards/Examples/Module-Structure-Example/README.md new file mode 100644 index 0000000..a8ce707 --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/README.md @@ -0,0 +1,123 @@ +# Module Structure Example + +A minimal but working module showing the layout the standards expect, and - more importantly - the +export boundary that layout exists to create. + +Small enough to read in one sitting. For a fuller starting point to copy, use +[Templates/Powershell-Module](https://github.com/fadwen/ai-powershell-standards/tree/main/Templates/Powershell-Module). + +## Layout + +```text +Module-Structure-Example/ +├── ModuleExample.psd1 # Manifest. Declares the public surface explicitly +├── ModuleExample.psm1 # Loader. Classes, then private, then public +├── Classes/ +│ └── ExampleClass.ps1 # ExampleServiceResult - a named output type +├── Private/ +│ └── Connect-ExampleService.ps1 # Internal, never exported +├── Public/ +│ └── Get-ExampleData.ps1 # The only exported function +├── docs/ # PlatyPS Markdown - the help source you edit +│ └── ModuleExample/ +│ ├── ModuleExample.md # Module page +│ └── Get-ExampleData.md # Command help, canonical +└── en-US/ + ├── ModuleExample-Help.xml # Compiled MAML - what Get-Help reads + └── about_ModuleExample.help.txt # Hand-written; PlatyPS never touches it +``` + +## Why the folders exist + +**Load order is not arbitrary.** `ModuleExample.psm1` loads `Classes/` first, because +`Get-ExampleData` returns an `ExampleServiceResult` and the type must exist before the function +referencing it is defined. Private functions load next, so public functions can call them. + +**The manifest controls export, not the loader.** `FunctionsToExport = @('Get-ExampleData')` names +the public surface. `Connect-ExampleService` is dot-sourced and callable inside the module, but is +never exported - so its signature can change without a breaking release. A wildcard export would +remove that freedom and slow module autoloading. + +**A class replaces `[PSCustomObject]`.** `[OutputType('ExampleServiceResult')]` tells a caller what +they receive and gives them IntelliSense. The standards discourage `[OutputType([PSCustomObject])]` +because it communicates nothing. + +**Help is generated, not hand-written.** `Get-ExampleData.ps1` keeps only `.EXTERNALHELP` and a +one-line `.SYNOPSIS`; everything a user sees lives in `docs/ModuleExample/Get-ExampleData.md` and +compiles to `en-US/ModuleExample-Help.xml`. Import the module and run +`Get-Help Get-ExampleData -Full` to see the compiled help served. + +Rebuild after editing the Markdown: + +```powershell +Import-Module Microsoft.PowerShell.PlatyPS + +Measure-PlatyPSMarkdown -Path ./docs/ModuleExample/*.md | + Where-Object Filetype -match 'CommandHelp' | + Import-MarkdownCommandHelp -Path {$_.FilePath} | + Export-MamlCommandHelp -OutputFolder ./maml -Force + +Copy-Item ./maml/ModuleExample/ModuleExample-Help.xml ./en-US/ -Force +``` + +**`RELATED LINKS` uses two forms, and the choice is not stylistic.** `Get-Help` rejects a relative +path outright — it throws `The specified URI ... is not valid` and returns nothing — so a `.LINK` +value is either a bare topic name or an absolute URL: + +```markdown +- [about_ModuleExample]() <- bare topic: a command or about_ topic +- [Module structure standards](https://...) <- absolute URL: anything outside the module +``` + +The empty parentheses are the PlatyPS form for a cross-reference `Get-Help` can resolve itself, and +they render as a plain name rather than a URL. Use them for sibling commands and about topics. +Absolute URLs are for documentation that lives outside the installed module — someone who installed +from the Gallery has no repository checkout, so a relative path to a repo file would be unusable +even if `Get-Help` accepted it. That is why the three standards links here are absolute rather than +relative: it is the correct form for their target, not a workaround. + +**An about topic covers what no single command owns.** `about_ModuleExample.help.txt` documents the +correlation-ID convention, the environment parameter, and partial-failure behaviour — concepts that +span commands. It is plain text, hand-written, and PlatyPS neither generates nor rewrites it. Run +`Get-Help about_ModuleExample` after importing. + +`.EXTERNALHELP` sits **inside** the `<# #>` block deliberately. As a bare `#` comment preceded by +ordinary prose it stops being recognized, and `Get-Help` silently falls back to the stub synopsis +instead of the compiled help. See +[platyps.instructions.md](../../../.github/instructions/platyps.instructions.md). + +## Patterns demonstrated + +| Pattern | Where | +|---|---| +| Approved verb, descriptive `[OutputType]` | `Public/Get-ExampleData.ps1` | +| Pipeline input via `ValueFromPipeline` | `Public/Get-ExampleData.ps1` | +| Correlation ID generated once, passed through | All three files | +| `$_` in `catch`, not `$Error[0]` | `Public/Get-ExampleData.ps1` | +| Per-item failure that does not abort the batch | `Public/Get-ExampleData.ps1` | +| Class with validation and behaviour | `Classes/ExampleClass.ps1` | +| A fallible call, so the catch is reachable | `Private/Get-ExampleServiceStatus.ps1` | + +## Tests + +[Tests/ModuleExample.Tests.ps1](./Tests/ModuleExample.Tests.ps1) covers the module contract, the +class, both private helpers via `InModuleScope`, and the per-item failure path. + +`Get-ExampleServiceStatus` exists so that failure path is real. Without a fallible call inside the +loop, a `catch` block in an example is decorative - it looks like resilience, but nothing can +exercise it and no test can prove it works. + +## Trying it + +```powershell +Import-Module ./ModuleExample.psd1 -Force + +Get-ExampleData -ServiceName 'Billing' +'Billing', 'Identity' | Get-ExampleData -Environment Test -Verbose + +# The private helper is deliberately not available +Get-Command Connect-ExampleService -ErrorAction SilentlyContinue # returns nothing +``` + +There is no real service behind this module. `Connect-ExampleService` sleeps briefly to stand in for +connection latency, and status is derived from the environment so the result set is not uniform. diff --git a/powershell-standards/Examples/Module-Structure-Example/Tests/ModuleExample.Tests.ps1 b/powershell-standards/Examples/Module-Structure-Example/Tests/ModuleExample.Tests.ps1 new file mode 100644 index 0000000..6765b16 --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/Tests/ModuleExample.Tests.ps1 @@ -0,0 +1,231 @@ +#Requires -Module Pester + +BeforeAll { + $script:ManifestPath = Join-Path $PSScriptRoot '..' 'ModuleExample.psd1' | Resolve-Path + Import-Module $script:ManifestPath -Force +} + +AfterAll { + Remove-Module ModuleExample -Force -ErrorAction SilentlyContinue +} + +Describe 'ModuleExample' -Tag 'Unit', 'Example' { + + Context 'Module contract' { + + It 'Has a valid manifest' { + $manifest = Test-ModuleManifest $script:ManifestPath + $manifest.Name | Should-Be 'ModuleExample' + $manifest.PowerShellVersion | Should-Be ([version]'7.6') + } + + It 'Exports only the public function' { + (Get-Command -Module ModuleExample).Name | Should-BeCollection @('Get-ExampleData') + } + + It 'Keeps the private helper internal' { + # The point of the Public/Private split: the manifest does not export it, + # so it cannot be called from outside the module. + Get-Command Connect-ExampleService -ErrorAction SilentlyContinue | Should-BeNull + } + + It 'Declares a descriptive output type rather than PSCustomObject' { + (Get-Command Get-ExampleData).OutputType.Name | Should-ContainCollection 'ExampleServiceResult' + } + } + + Context 'Get-ExampleData' { + + It 'Returns one result for one service' { + $result = Get-ExampleData -ServiceName 'Billing' + + $result | Should-NotBeNull + $result.ServiceName | Should-Be 'Billing' + $result.Environment | Should-Be 'Development' + } + + It 'Returns the class type the function declares' { + $result = Get-ExampleData -ServiceName 'Billing' + $result.GetType().Name | Should-Be 'ExampleServiceResult' + } + + It 'Accepts pipeline input and emits one result per service' { + $results = 'Billing', 'Identity', 'Reporting' | Get-ExampleData + + $results | Should-BeCollection -Count 3 + $results.ServiceName | Should-BeCollection @('Billing', 'Identity', 'Reporting') + } + + It 'Accepts an array parameter as well as the pipeline' { + (Get-ExampleData -ServiceName @('Billing', 'Identity')) | Should-BeCollection -Count 2 + } + + It 'Reports Healthy for non-production environments: ' -TestCases @( + @{ Environment = 'Development' } + @{ Environment = 'Test' } + ) { + param($Environment) + (Get-ExampleData -ServiceName 'Billing' -Environment $Environment).Status | Should-Be 'Healthy' + } + + It 'Reports Degraded for Production' { + (Get-ExampleData -ServiceName 'Billing' -Environment 'Production').Status | Should-Be 'Degraded' + } + + It 'Rejects an environment outside the allowed set' { + { Get-ExampleData -ServiceName 'Billing' -Environment 'Staging' } | + Should-Throw -ExceptionMessage '*ValidateSet*' + } + + It 'Requires ServiceName' { + { Get-ExampleData -ServiceName '' } | Should-Throw + } + + It 'Generates a correlation id when none is supplied' { + $result = Get-ExampleData -ServiceName 'Billing' + + $result.CorrelationId | Should-NotBe ([guid]::Empty) + $result.CorrelationId.ToString() | + Should-MatchString '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + } + + It 'Uses the supplied correlation id for every service in the batch' { + $id = [guid]::NewGuid() + $results = 'Billing', 'Identity' | Get-ExampleData -CorrelationId $id + + # One id per operation, not per item - that is what makes a batch traceable + $results | Should-All { $_.CorrelationId -eq $id } + } + + It 'Stamps CheckedAt on each result' { + $before = Get-Date + $result = Get-ExampleData -ServiceName 'Billing' + + $result.CheckedAt | Should-HaveType ([datetime]) + $result.CheckedAt | Should-BeGreaterThanOrEqual $before.AddSeconds(-5) + } + } + + Context 'Per-item failure handling' { + + It 'Continues the batch when one service fails' { + InModuleScope ModuleExample { + Mock Get-ExampleServiceStatus { + if ($ServiceName -eq 'Broken') { throw 'service unreachable' } + 'Healthy' + } + + $results = 'Billing', 'Broken', 'Identity' | + Get-ExampleData -ErrorAction SilentlyContinue + + # The failure must not abort the remaining items + $results | Should-BeCollection -Count 2 + $results.ServiceName | Should-BeCollection @('Billing', 'Identity') + } + } + + It 'Reports the failing service by name and includes the correlation id' { + InModuleScope ModuleExample { + Mock Get-ExampleServiceStatus { throw 'service unreachable' } + $id = [guid]::NewGuid() + + Get-ExampleData -ServiceName 'Broken' -CorrelationId $id ` + -ErrorAction SilentlyContinue -ErrorVariable err + $null = $err + + "$err" | Should-MatchString 'Broken' + "$err" | Should-MatchString ([regex]::Escape($id.ToString())) + } + } + + It 'Emits nothing when every service fails' { + InModuleScope ModuleExample { + Mock Get-ExampleServiceStatus { throw 'service unreachable' } + + $results = 'A', 'B' | Get-ExampleData -ErrorAction SilentlyContinue + $results | Should-BeFalsy + } + } + } + + Context 'Module loader' { + + It 'Fails loudly when a source file cannot be dot-sourced' { + # The loader throws rather than warning: a module that half-loads is + # worse than one that refuses to. + # Built under $TestDrive so Pester disposes of it - see test-data-guide.md + $broken = Join-Path $TestDrive "mse-$([guid]::NewGuid().ToString('N'))" + foreach ($folder in 'Public', 'Private', 'Classes') { + New-Item -Path (Join-Path $broken $folder) -ItemType Directory -Force | Out-Null + } + Copy-Item (Join-Path $PSScriptRoot '..' 'ModuleExample.psm1') $broken + Set-Content -Path (Join-Path $broken 'Public\Broken.ps1') -Value 'function Get-Broken { this is not powershell {' + + { Import-Module (Join-Path $broken 'ModuleExample.psm1') -Force -ErrorAction Stop } | + Should-Throw -ExceptionMessage '*Failed to load public function*' + } + } + + Context 'ExampleServiceResult class' { + + It 'Initialises to Unavailable until a status is set' { + InModuleScope ModuleExample { + $r = [ExampleServiceResult]::new('Billing', 'Test', [guid]::NewGuid()) + $r.Status | Should-Be 'Unavailable' + } + } + + It 'Treats Healthy and Degraded as usable, Unavailable as not' { + InModuleScope ModuleExample { + $r = [ExampleServiceResult]::new('Billing', 'Test', [guid]::NewGuid()) + + $r.Status = 'Healthy'; $r.IsUsable() | Should-BeTrue + $r.Status = 'Degraded'; $r.IsUsable() | Should-BeTrue + $r.Status = 'Unavailable'; $r.IsUsable() | Should-BeFalse + } + } + + It 'Rejects a status outside the allowed set' { + InModuleScope ModuleExample { + $r = [ExampleServiceResult]::new('Billing', 'Test', [guid]::NewGuid()) + { $r.Status = 'Exploded' } | Should-Throw + } + } + + It 'Renders a readable string' { + InModuleScope ModuleExample { + $r = [ExampleServiceResult]::new('Billing', 'Test', [guid]::NewGuid()) + $r.Status = 'Healthy' + $r.ToString() | Should-Be 'Billing [Test]: Healthy' + } + } + } + + Context 'Connect-ExampleService (internal)' { + + It 'Returns a session describing the target environment' { + InModuleScope ModuleExample { + $session = Connect-ExampleService -Environment 'Test' -CorrelationId ([guid]::NewGuid()) + + $session | Should-HaveType ([hashtable]) + $session.Environment | Should-Be 'Test' + $session.Endpoint | Should-MatchString 'test$' + } + } + + It 'Carries the caller correlation id through' { + InModuleScope ModuleExample { + $id = [guid]::NewGuid() + (Connect-ExampleService -Environment 'Development' -CorrelationId $id).CorrelationId | + Should-Be $id + } + } + + It 'Rejects an environment outside the allowed set' { + InModuleScope ModuleExample { + { Connect-ExampleService -Environment 'Staging' -CorrelationId ([guid]::NewGuid()) } | + Should-Throw -ExceptionMessage '*ValidateSet*' + } + } + } +} diff --git a/powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/Get-ExampleData.md b/powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/Get-ExampleData.md new file mode 100644 index 0000000..a1e2e27 --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/Get-ExampleData.md @@ -0,0 +1,162 @@ +--- +document type: cmdlet +external help file: ModuleExample-Help.xml +HelpUri: '' +Locale: en-US +Module Name: ModuleExample +ms.date: 08 29 2026 +PlatyPS schema version: 2024-05-01 +title: Get-ExampleData +--- + +# Get-ExampleData + +## SYNOPSIS + +Retrieves service status for one or more services. + +## SYNTAX + +### __AllParameterSets + +``` +Get-ExampleData [-ServiceName] [[-Environment] ] [[-CorrelationId] ] +``` + +## DESCRIPTION + +The exported surface of this example module. +Demonstrates the patterns the +standards require: + +- An approved verb, with a descriptive [OutputType] rather than PSCustomObject +- Pipeline input, so the function composes +- A correlation ID generated once and carried through every call +- $_ in the catch block, and per-item failure that does not abort the batch + +## EXAMPLES + +### EXAMPLE 1 + +Get-ExampleData -ServiceName 'Billing' + +DESCRIPTION: Queries a single service in the default environment. +OUTPUT: One ExampleServiceResult. +USE CASE: Ad-hoc check of a single service. + +### EXAMPLE 2 + +'Billing', 'Identity' | Get-ExampleData -Environment Test + +DESCRIPTION: Queries two services via the pipeline. +OUTPUT: One ExampleServiceResult per service. +USE CASE: Batch check where one failure must not stop the rest. + +## PARAMETERS + +### -CorrelationId + +Optional correlation identifier. +One is generated when not supplied, which is +why the parameter carries no ValidateNotNullOrEmpty - it is never empty. + +```yaml +Type: System.Guid +DefaultValue: '[guid]::NewGuid()' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 2 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Environment + +Environment to query. +Defaults to Development so the example is safe to run. + +```yaml +Type: System.String +DefaultValue: Development +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -ServiceName + +One or more service names to query. +Accepts pipeline input. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: true + ValueFromPipeline: true + ValueFromPipelineByPropertyName: true + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.String[] + +One or more service names, bound from the pipeline by value or by property name. Piping a +collection queries each service in turn; a failure on one does not stop the rest. + +## OUTPUTS + +### ExampleServiceResult + +One object per service queried, carrying the service name, the environment, the resolved status, +and the correlation ID shared by every result in the batch. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +TROUBLESHOOTING: + +- Connection issues: .\Troubleshooting\Common\Function-Issues.md + + +## RELATED LINKS + +- [about_ModuleExample]() +- [Module structure standards](https://github.com/fadwen/ai-powershell-standards/blob/main/.github/instructions/module.instructions.md) +- [Help documentation standards](https://github.com/fadwen/ai-powershell-standards/blob/main/.github/instructions/platyps.instructions.md) +- [Function troubleshooting](https://github.com/fadwen/ai-powershell-standards/blob/main/Troubleshooting/Common/Function-Issues.md) + diff --git a/powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/ModuleExample.md b/powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/ModuleExample.md new file mode 100644 index 0000000..654e429 --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/docs/ModuleExample/ModuleExample.md @@ -0,0 +1,24 @@ +--- +document type: module +Help Version: 1.0.0.0 +HelpInfoUri: +Locale: en-US +Module Guid: 8f2b1c9d-4e7a-4b3f-9c21-5d6e8a0f7b34 +Module Name: ModuleExample +ms.date: 08 29 2026 +PlatyPS schema version: 2024-05-01 +title: ModuleExample Module +--- + +# ModuleExample Module + +## Description + +Minimal reference module demonstrating the Public/Private/Classes layout and the export boundary described in the PowerShell Copilot Standards. + +## ModuleExample + +### [Get-ExampleData](Get-ExampleData.md) + +Retrieves service status for one or more services. + diff --git a/powershell-standards/Examples/Module-Structure-Example/en-US/ModuleExample-Help.xml b/powershell-standards/Examples/Module-Structure-Example/en-US/ModuleExample-Help.xml new file mode 100644 index 0000000..c0e10ff --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/en-US/ModuleExample-Help.xml @@ -0,0 +1,169 @@ + + + + + Get-ExampleData + + Retrieves service status for one or more services. + + Get + ExampleData + + + The exported surface of this example module. +Demonstrates the patterns the +standards require: + +- An approved verb, with a descriptive [OutputType] rather than PSCustomObject +- Pipeline input, so the function composes +- A correlation ID generated once and carried through every call +- $_ in the catch block, and per-item failure that does not abort the batch + + + + Get-ExampleData + + ServiceName + + string[] + + System.String[] + + + + Environment + + string + + System.String + + + + CorrelationId + + guid + + System.Guid + + + + + + + CorrelationId + + Optional correlation identifier. +One is generated when not supplied, which is +why the parameter carries no ValidateNotNullOrEmpty - it is never empty. + + System.Guid + + System.Guid + + + + Environment + + Environment to query. +Defaults to Development so the example is safe to run. + + System.String + + System.String + + + + ServiceName + + One or more service names to query. +Accepts pipeline input. + + System.String[] + + System.String[] + + + + + + + System.String[] + + + One or more service names, bound from the pipeline by value or by property name. Piping a +collection queries each service in turn; a failure on one does not stop the rest. + + + + + + + ExampleServiceResult + + + One object per service queried, carrying the service name, the environment, the resolved status, +and the correlation ID shared by every result in the batch. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +TROUBLESHOOTING: + +- Connection issues: .\Troubleshooting\Common\Function-Issues.md + + + + + --------- EXAMPLE 1 --------- + + Get-ExampleData -ServiceName 'Billing' + € + DESCRIPTION: Queries a single service in the default environment. +OUTPUT: One ExampleServiceResult. +USE CASE: Ad-hoc check of a single service. + + + + + + --------- EXAMPLE 2 --------- + + 'Billing', 'Identity' | Get-ExampleData -Environment Test + € + DESCRIPTION: Queries two services via the pipeline. +OUTPUT: One ExampleServiceResult per service. +USE CASE: Batch check where one failure must not stop the rest. + + + + + + + + Online Version + + + + about_ModuleExample + + + + Module structure standards + https://github.com/fadwen/ai-powershell-standards/blob/main/.github/instructions/module.instructions.md + + + Help documentation standards + https://github.com/fadwen/ai-powershell-standards/blob/main/.github/instructions/platyps.instructions.md + + + Function troubleshooting + https://github.com/fadwen/ai-powershell-standards/blob/main/Troubleshooting/Common/Function-Issues.md + + + + \ No newline at end of file diff --git a/powershell-standards/Examples/Module-Structure-Example/en-US/about_ModuleExample.help.txt b/powershell-standards/Examples/Module-Structure-Example/en-US/about_ModuleExample.help.txt new file mode 100644 index 0000000..5f0d8fb --- /dev/null +++ b/powershell-standards/Examples/Module-Structure-Example/en-US/about_ModuleExample.help.txt @@ -0,0 +1,69 @@ +TOPIC + about_ModuleExample + +SHORT DESCRIPTION + Describes the conventions ModuleExample demonstrates, and the concepts that + span its commands rather than belonging to any one of them. + +LONG DESCRIPTION + ModuleExample is a reference module. It exists to show the layout, export + boundary, and help pipeline the PowerShell Copilot Standards expect, in a + form small enough to read in one sitting. + + This topic is hand-written plain text. PlatyPS does not generate about + topics, and never rewrites them. That is the point: a module with more than + a handful of commands needs somewhere to explain the ideas its commands + share, and per-command help is the wrong place for that. + + CORRELATION IDS + + Every command accepts a -CorrelationId parameter and generates one when it + is not supplied. The identifier is created once, in the begin block, and + carried through every downstream call and every log line for the duration of + the pipeline. + + The convention matters when a batch partially fails. Because one identifier + covers the whole invocation, the successful and failed items in a single run + share a key, and the logs for that run can be retrieved as a unit: + + 'Billing', 'Identity' | Get-ExampleData -Environment Test -Verbose + + Passing your own identifier lets you correlate this module's activity with a + caller's trace: + + $id = [guid]::NewGuid() + Get-ExampleData -ServiceName 'Billing' -CorrelationId $id + + ENVIRONMENTS + + Commands take an -Environment parameter constrained to Development, Test, + and Production. It defaults to Development so that every example in the help + is safe to run without touching a live system. + + The environment selects the connection the private helper opens. It is not a + formatting or verbosity switch, and changing it changes what the command + talks to. + + PARTIAL FAILURE + + Commands that accept pipeline input do not abort the batch when one item + fails. The failing item writes a non-terminating error and the loop + continues, so a bad name in the middle of a list does not discard the + results either side of it. + + To make a batch stop on the first failure, opt in at the call site: + + 'Billing', 'Identity' | Get-ExampleData -ErrorAction Stop + + WHERE HELP LIVES + + Command help is generated. The .ps1 files under Public/ carry only an + EXTERNALHELP keyword and a one-line synopsis; the content users read lives + in docs/ModuleExample/*.md and compiles to en-US/ModuleExample-Help.xml. + + Edit the Markdown, never the XML and never the comment block. See + .github/instructions/platyps.instructions.md in the standards repository. + +SEE ALSO + Get-ExampleData + https://github.com/fadwen/ai-powershell-standards diff --git a/powershell-standards/Examples/README.md b/powershell-standards/Examples/README.md new file mode 100644 index 0000000..6bbe8f5 --- /dev/null +++ b/powershell-standards/Examples/README.md @@ -0,0 +1,64 @@ +# Examples + +Working code demonstrating the standards in this repository. Every file here runs and is covered by +tests. This folder is mirrored into consuming projects by the sync workflow. + +## What is here + +| Example | Demonstrates | +|---|---| +| [Basic-Function-Example.ps1](./Basic-Function-Example.ps1) | A complete advanced function: pipeline input, parameter validation, correlation IDs, per-item error handling, and a named `[OutputType]` | +| [Module-Structure-Example/](./Module-Structure-Example/) | A minimal working module - the Public/Private/Classes layout and the export boundary it creates | +| [Testing-Examples/](./Testing-Examples/) | Pester 6 tests for `Basic-Function-Example.ps1`, including CIM mocking and `-RemoveParameterType` | +| [Configuration/DefaultConfiguration.psd1](./Configuration/DefaultConfiguration.psd1) | A configuration data file: environment settings, validation thresholds, and logging targets | + +## Start with the function example + +[Basic-Function-Example.ps1](./Basic-Function-Example.ps1) is the densest single file: + +```powershell +. .\Basic-Function-Example.ps1 + +Get-BasicServerInfo -ComputerName 'SERVER01' -WhatIf +'SERVER01', 'SERVER02' | Get-BasicServerInfo -Verbose +``` + +It shows: + +- `[OutputType('BasicServerInfo')]` with a matching `PSTypeName` on the output, rather than + `[OutputType([PSCustomObject])]`, which tells a caller nothing about the shape returned +- A correlation ID generated once in `begin` and carried through every message +- `$_` in `catch`, and `continue` so one unreachable host does not abort the batch +- `ShouldProcess` support, so `-WhatIf` works + +## Then the module + +[Module-Structure-Example/](./Module-Structure-Example/) is deliberately small. Its point is not the +code but the boundary: `FunctionsToExport` names the public surface, so the private helper stays +internal and can change without a breaking release. Classes load before the functions that return +them. + +Its own tests live in [Module-Structure-Example/Tests/](./Module-Structure-Example/Tests/) and cover +the module contract, the class, the private helper via `InModuleScope`, and the per-item failure +path. Examples here are held to the same coverage bar as the tooling. + +## The anti-pattern file + +[Test-QualityGates.ps1](https://github.com/fadwen/ai-powershell-standards/blob/main/Documentation/Anti-Patterns/Test-QualityGates.ps1) +lives in the standards repository, outside this mirrored folder. It intentionally breaks the +standards - `Write-Host`, `$Error[0]` in a catch, a non-approved verb, string building in a loop. It +exists so the quality gates have something to catch, and the CI workflows exclude it from production +analysis by name for that reason. + +Its tests in [Test-QualityGates.Tests.ps1](https://github.com/fadwen/ai-powershell-standards/blob/main/Documentation/Anti-Patterns/Test-QualityGates.Tests.ps1) +document that behaviour +rather than endorsing it. + +## Related + +- [PowerShell Best Practices](https://github.com/fadwen/ai-powershell-standards/blob/main/Documentation/PowerShell-Best-Practices.md): + the reasoning behind these patterns +- [Templates/Powershell-Module](https://github.com/fadwen/ai-powershell-standards/tree/main/Templates/Powershell-Module): + a fuller starting point to copy +- [Version baseline](../../.github/instructions/powershell-version.instructions.md) - which PowerShell + version to target, and why diff --git a/powershell-standards/Examples/Testing-Examples/Basic-Function.Tests.ps1 b/powershell-standards/Examples/Testing-Examples/Basic-Function.Tests.ps1 new file mode 100644 index 0000000..232012f --- /dev/null +++ b/powershell-standards/Examples/Testing-Examples/Basic-Function.Tests.ps1 @@ -0,0 +1,179 @@ +#Requires -Module Pester + +BeforeAll { + # Import the module containing the function to test + $ModulePath = Join-Path $PSScriptRoot '..\..\Examples\Basic-Function-Example.ps1' + . $ModulePath +} + +Describe "Get-BasicServerInfo" -Tag "Unit", "Example" { + + # Default mocks live at Describe level so every Context is hermetic - no test + # reaches a real network. Contexts below override individual mocks as needed. + # + # -RemoveParameterType CimSession is required: PowerShell binds parameters using + # the *original* command's metadata even when a command is mocked, so passing a + # PSCustomObject stub to -CimSession fails type coercion before the mock body runs. + BeforeEach { + Mock Test-Connection { $true } + + Mock New-CimSession { [PSCustomObject]@{ ComputerName = $ComputerName } } + + Mock Remove-CimSession -RemoveParameterType CimSession -MockWith { } + + Mock Get-CimInstance -RemoveParameterType CimSession -MockWith { + switch ($ClassName) { + 'Win32_OperatingSystem' { + [PSCustomObject]@{ + Caption = 'Microsoft Windows Server 2019' + Version = '10.0.17763' + ServicePackMajorVersion = 0 + OSArchitecture = '64-bit' + LastBootUpTime = (Get-Date).AddDays(-5) + } + } + 'Win32_ComputerSystem' { + [PSCustomObject]@{ + TotalPhysicalMemory = 17179869184 # 16 GB + Manufacturer = 'Dell Inc.' + Model = 'PowerEdge R740' + Domain = 'contoso.com' + Workgroup = $null + } + } + 'Win32_Processor' { + [PSCustomObject]@{ + Name = 'Intel(R) Xeon(R) Gold 6248 CPU @ 2.50GHz' + NumberOfCores = 8 + NumberOfLogicalProcessors = 16 + } + } + 'Win32_Service' { + @( + [PSCustomObject]@{ Name = 'Spooler'; DisplayName = 'Print Spooler'; StartMode = 'Auto'; State = 'Running' } + [PSCustomObject]@{ Name = 'Themes'; DisplayName = 'Themes'; StartMode = 'Auto'; State = 'Running' } + ) + } + } + } + } + + Context "Parameter Validation" { + It "Should accept valid computer names: " -TestCases @( + @{ ComputerName = 'SERVER01'; Expected = $true } + @{ ComputerName = 'web01.contoso.com'; Expected = $true } + @{ ComputerName = 'DB-SERVER-01'; Expected = $true } + ) { + param($ComputerName, $Expected) + + # This should not throw + Get-BasicServerInfo -ComputerName $ComputerName -WhatIf + } + + # Expected messages are the ones PowerShell's validation attributes actually + # emit. ValidatePattern/ValidateLength do not produce friendly text; asserting + # invented wording here is what let these tests rot unnoticed. + It "Should reject invalid computer names: " -TestCases @( + @{ InvalidName = 'SERVER_01'; ExpectedError = '*does not match the*pattern*' } + @{ InvalidName = 'SERVER 01'; ExpectedError = '*does not match the*pattern*' } + @{ InvalidName = ''; ExpectedError = '*length*is too short*' } + ) { + param($InvalidName, $ExpectedError) + + { Get-BasicServerInfo -ComputerName $InvalidName } | Should-Throw -ExceptionMessage $ExpectedError + } + + It "Should support pipeline input" { + $computerNames = @('SERVER01', 'SERVER02') + + # This should not throw and should accept pipeline input + $computerNames | Get-BasicServerInfo -WhatIf + } + } + + Context "Core Functionality" { + + It "Should return expected object structure" { + $result = Get-BasicServerInfo -ComputerName 'MOCKSERVER' + + # Verify object structure + $result | Should-NotBeNull + $result | Should-NotBeNull + + # Verify required properties + $result.ComputerName | Should-Be 'MOCKSERVER' + $result.OperatingSystem | Should-Be 'Microsoft Windows Server 2019' + $result.TotalMemoryGB | Should-Be 16 + $result.CorrelationId | Should-NotBeNull + } + + It "Should include services when IncludeServices switch is used" { + $result = Get-BasicServerInfo -ComputerName 'MOCKSERVER' -IncludeServices + + $result.RunningServices | Should-NotBeNull + $result.RunningServiceCount | Should-Be 2 + $result.RunningServices[0].Name | Should-Be 'Spooler' + } + + It "Should calculate uptime correctly" { + $result = Get-BasicServerInfo -ComputerName 'MOCKSERVER' + + $result.UptimeDays | Should-BeGreaterThan 4.9 + $result.UptimeDays | Should-BeLessThan 5.1 + } + } + + Context "Error Handling" { + + # NOTE: PowerShell 7 renamed Test-Connection's -ComputerName parameter to + # -TargetName, keeping ComputerName only as an alias. Pester binds mock + # variables by the *real* parameter name, so a filter written against + # $ComputerName never matches and the mock silently does nothing. Use + # $TargetName here. + # + # These overrides also live in BeforeEach rather than inside It, because a + # mock declared inside It does not reliably apply to a function that was + # dot-sourced in BeforeAll. + BeforeEach { + Mock Test-Connection { + if ($TargetName -eq 'OFFLINE') { return $false } + return $true + } + Mock New-CimSession { + if ($ComputerName -eq 'OFFLINE') { throw "Connection failed" } + [PSCustomObject]@{ ComputerName = $ComputerName } + } + } + + It "Should handle connection failures gracefully" { + Get-BasicServerInfo -ComputerName 'OFFLINE' -ErrorAction SilentlyContinue + } + + It "Should continue processing other computers when one fails" { + $results = Get-BasicServerInfo -ComputerName @('MOCKSERVER', 'OFFLINE') -ErrorAction SilentlyContinue + + # Should get one successful result despite one failure + $results | Should-NotBeNull + $results.ComputerName | Should-ContainCollection 'MOCKSERVER' + } + } + + Context "Performance Requirements" { + It "Should complete within acceptable time limits" { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + Get-BasicServerInfo -ComputerName 'MOCKSERVER' | Out-Null + + $stopwatch.Stop() + $stopwatch.ElapsedMilliseconds | Should-BeLessThan 5000 # 5 seconds max for mocked operations + } + + It "Should include performance metrics in output" { + $result = Get-BasicServerInfo -ComputerName 'MOCKSERVER' + + $result.QueryTime | Should-NotBeNull + $result.QueryDurationMs | Should-BeGreaterThan 0 + $result.CorrelationId | Should-NotBeNull + } + } +} \ No newline at end of file