From f7cfc58e5495d3010f9792fe789f3ec380c7a57d Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:23:29 -0400 Subject: [PATCH 01/12] fix(style): Fix formatting in optimization plan markdown for clarity and consistency --- build/maester-optimization-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build/maester-optimization-plan.md b/build/maester-optimization-plan.md index e22d28087..2dae2d3fc 100644 --- a/build/maester-optimization-plan.md +++ b/build/maester-optimization-plan.md @@ -372,7 +372,7 @@ param ( **Output directory structure:** -``` +```text ./module/ ├── Maester.psd1 # updated manifest (auto-generated FunctionsToExport) ├── Maester.psm1 # consolidated internal + public functions @@ -576,7 +576,7 @@ release point. The tracking issue pattern is better suited to linear, sequential The following were considered and rejected during planning: | Idea | Reason rejected | -|---|---| +| --- | --- | | Remove `Microsoft.Graph.Authentication` from `RequiredModules` | `Connect-Maester` is optional. Automation users never call it. 180+ functions call Graph cmdlets directly and would fail with cryptic errors if the module is not loaded. The performance gain is near zero when the module is already in the session, which covers virtually all real-world usage. | | Lazy-loading Pester | The team intentionally pins Pester at `0.0.0` in `RequiredModules` to avoid conflicts with the Windows-bundled version. Runtime validation is already in place. | | Adding Graph response caching | Already implemented via `$Script:`-scoped caches in `Invoke-MtGraphRequest` and `Get-MtExo`. | @@ -588,7 +588,7 @@ The following were considered and rejected during planning: The items above have dependencies and should be implemented in this sequence: -``` +```text 1. Move comment-based help inside function bodies (one-time source pass, own PR) ↓ 2. Consolidate internal + public PS1 files into Maester.psm1 @@ -609,9 +609,9 @@ The items above have dependencies and should be implemented in this sequence: **Recommended PR sequence:** | PR | Contents | -|---|---| +| --- | --- | | PR 1 | Item 1 only — comment-based help move, project-wide. Isolated and reviewable on its own. | -| PR 2 | Items 2, 3, 4, and 7 — build script that produces the consolidated `./module/` output. | +| PR 2 | Items 2, 3, 4, 6, and 7 — build script that produces the consolidated `./module/` output. | | PR 3 | Item 5 — test suite consolidation and `Update-MaesterTests` runtime update. | | PR 4 | Item 8 — workflow updates to use `./build/` and publish from `./module/`. | | PR 5 | Item 9 — documentation and contributing guidelines. | From 8728809439fc79840c52dfb9830d7772bc9915a4 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:54:37 -0400 Subject: [PATCH 02/12] fix(gitignore): Add module build output directory to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d3bb30790..186552942 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # Claude Code .claude/ +# Build output: consolidated module produced by build/Build-MaesterModule.ps1 +/module/ + # User-specific files *.rsuser *.suo From 2666a16d7c9494d2d1de5248f34c44d68cbf9fb9 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:54:50 -0400 Subject: [PATCH 03/12] feat(build): Add script to consolidate Maester PowerShell module and assets --- build/Build-MaesterModule.ps1 | 536 ++++++++++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 build/Build-MaesterModule.ps1 diff --git a/build/Build-MaesterModule.ps1 b/build/Build-MaesterModule.ps1 new file mode 100644 index 000000000..409b296cb --- /dev/null +++ b/build/Build-MaesterModule.ps1 @@ -0,0 +1,536 @@ +<# +.SYNOPSIS + Builds the Maester PowerShell module into a consolidated, publishable artifact. + +.DESCRIPTION + Consolidates all source files from powershell/internal/ and powershell/public/ into + a single Maester.psm1, consolidates ORCA class definitions into OrcaClasses.ps1, + auto-generates the FunctionsToExport list via AST parsing, and copies static assets + and tests into the output directory. + + The source tree is never modified. All output goes to the OutputRoot directory. + +.PARAMETER SourceRoot + Path to the PowerShell module source directory. Defaults to ../powershell relative + to this script. + +.PARAMETER TestsRoot + Path to the test suites directory. Defaults to ../tests relative to this script. + +.PARAMETER OutputRoot + Path to the output directory for the built module. Defaults to ../module relative + to this script. This directory is cleaned and recreated on every run. + +.PARAMETER Profile + When specified, measures and reports Import-Module time and exported function count + for the built module. +#> +[CmdletBinding()] +param ( + [Parameter()] + [string] $SourceRoot = (Resolve-Path -LiteralPath "$PSScriptRoot/../powershell").Path, + + [Parameter()] + [string] $TestsRoot = (Resolve-Path -LiteralPath "$PSScriptRoot/../tests").Path, + + [Parameter()] + [string] $OutputRoot = "$PSScriptRoot/../module", + + [Parameter()] + [switch] $Profile +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ────────────────────────────────────────────────────────────────────────────── +# Phase A — Clean and recreate the output directory +# ────────────────────────────────────────────────────────────────────────────── + +Write-Host '── Phase A: Preparing output directory' -ForegroundColor Cyan + +if (Test-Path -LiteralPath $OutputRoot) { + Remove-Item -LiteralPath $OutputRoot -Recurse -Force +} +$null = New-Item -Path $OutputRoot -ItemType Directory -Force +$OutputRoot = (Resolve-Path -LiteralPath $OutputRoot).Path + +Write-Host " Output: $OutputRoot" + +# ────────────────────────────────────────────────────────────────────────────── +# Phase B — AST parsing: collect FunctionsToExport from public source files +# ────────────────────────────────────────────────────────────────────────────── + +Write-Host '── Phase B: Parsing public functions (AST)' -ForegroundColor Cyan + +$PublicSourceFiles = Get-ChildItem -Path "$SourceRoot/public" -Filter '*.ps1' -Recurse | + Where-Object { $_.Name -notlike '*.Tests.ps1' } | + Sort-Object -Property FullName + +$ApprovedVerbs = (Get-Verb).Verb + +$ExportFunctionList = [System.Collections.Generic.List[string]]::new() + +foreach ($File in $PublicSourceFiles) { + $Tokens = $null + $ParseErrors = $null + $Ast = [System.Management.Automation.Language.Parser]::ParseFile( + $File.FullName, [ref]$Tokens, [ref]$ParseErrors + ) + + if ($ParseErrors.Count -gt 0) { + Write-Warning "Parse errors in '$($File.Name)': $($ParseErrors[0].Message)" + } + + # Find top-level function definitions only (not nested inside other functions). + # A top-level function's parent chain: FunctionDefinitionAst → NamedBlockAst → + # ScriptBlockAst (of file) → nothing above. + $TopLevelFunctions = $Ast.FindAll({ + param ($Node) + $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + -not ($Node.Parent -is [System.Management.Automation.Language.FunctionDefinitionAst] -or + ($Node.Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $Node.Parent.Parent -is [System.Management.Automation.Language.ScriptBlockAst] -and + $Node.Parent.Parent.Parent -is [System.Management.Automation.Language.FunctionDefinitionAst])) + }, $true) + + if ($TopLevelFunctions.Count -eq 0) { + Write-Warning "No top-level function found in '$($File.Name)'" + continue + } + + if ($TopLevelFunctions.Count -gt 1) { + $Names = ($TopLevelFunctions | ForEach-Object { $_.Name }) -join ', ' + Write-Warning "Multiple top-level functions in '$($File.Name)': $Names" + } + + foreach ($Function in $TopLevelFunctions) { + # Only export functions that follow the Verb-Noun naming convention. + # Helper functions, class definitions, and constructors that lack the + # conventional '-' separator are internal and should not be exported. + if ($Function.Name -notmatch '-') { + Write-Warning "Skipping '$($Function.Name)' in '$($File.Name)' — not a Verb-Noun function" + continue + } + + # Validate function name matches filename + if ($Function.Name -ne $File.BaseName) { + Write-Warning "Function name '$($Function.Name)' does not match filename '$($File.Name)'" + } + + # Validate approved verb + $Verb = ($Function.Name -split '-', 2)[0] + if ($Verb -and $Verb -notin $ApprovedVerbs) { + Write-Warning "Function '$($Function.Name)' uses unapproved verb '$Verb'" + } + + $ExportFunctionList.Add($Function.Name) + } +} + +$ExportFunctionList.Sort() + +# Deduplicate — some helper functions (e.g., SPFRecord) are defined in multiple files. +$SeenFunctions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$DuplicateNames = [System.Collections.Generic.List[string]]::new() +foreach ($Name in $ExportFunctionList) { + if (-not $SeenFunctions.Add($Name)) { + $DuplicateNames.Add($Name) + } +} +if ($DuplicateNames.Count -gt 0) { + $ExportFunctionList = [System.Collections.Generic.List[string]]::new($SeenFunctions) + $ExportFunctionList.Sort() + foreach ($Dupe in $DuplicateNames) { + Write-Warning "Deduplicated function: '$Dupe'" + } + Write-Warning "Removed $($DuplicateNames.Count) duplicate function name(s)" +} + +Write-Host " Found $($ExportFunctionList.Count) public functions" + +# ────────────────────────────────────────────────────────────────────────────── +# Phase C — Consolidate internal + public .ps1 files into Maester.psm1 +# ────────────────────────────────────────────────────────────────────────────── + +Write-Host '── Phase C: Consolidating Maester.psm1' -ForegroundColor Cyan + +$InternalFiles = Get-ChildItem -Path "$SourceRoot/internal" -Filter '*.ps1' -Recurse | + Where-Object { + $_.Name -notlike '*.Tests.ps1' -and + $_.Name -notlike 'check-ORCA*.ps1' + } | + Sort-Object -Property FullName + +$PublicFiles = Get-ChildItem -Path "$SourceRoot/public" -Filter '*.ps1' -Recurse | + Where-Object { $_.Name -notlike '*.Tests.ps1' } | + Sort-Object -Property FullName + +Write-Host " Internal files: $($InternalFiles.Count)" +Write-Host " Public files: $($PublicFiles.Count)" + +# Helper: compute directory depth of a file relative to $SourceRoot. +# e.g. powershell/internal/foo.ps1 → depth 1, powershell/public/core/bar.ps1 → depth 2 +function Get-RelativeDepth { + param ( + [string] $FilePath, + [string] $BasePath + ) + $RelativePath = $FilePath.Substring($BasePath.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $DirectoryPart = [System.IO.Path]::GetDirectoryName($RelativePath) + if ([string]::IsNullOrEmpty($DirectoryPart)) { + return 0 + } + return ($DirectoryPart.Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)).Count +} + +# Helper: adjust $PSScriptRoot-relative paths in consolidated file content. +# After consolidation, $PSScriptRoot resolves to the output directory root instead of +# each file's original subdirectory. This function strips the appropriate number of +# parent-directory traversals (../) based on the file's original depth. +function Resolve-ConsolidatedPaths { + param ( + [string] $Content, + [int] $Depth, + [string] $FileName + ) + + if ($Depth -lt 1) { + return $Content + } + + # Build the parent-navigation patterns for forward and back slashes. + # Depth 1: '../' or '..\' + # Depth 2: '../../' or '..\..\' + $ForwardPattern = ('../' * $Depth) + $BackslashPattern = ('..\' * $Depth) + + # Pattern A: inline string interpolation — $PSScriptRoot/../... or $PSScriptRoot\..\... + $Content = $Content.Replace("`$PSScriptRoot/$ForwardPattern", '$PSScriptRoot/') + $Content = $Content.Replace("`$PSScriptRoot\$BackslashPattern", '$PSScriptRoot\') + + # Pattern B: Join-Path with separate -ChildPath string arguments — '../...' or '..\..' + # Process line-by-line to only adjust lines that reference $PSScriptRoot. + $Lines = $Content -split "`n" + $AdjustedLines = [System.Collections.Generic.List[string]]::new($Lines.Count) + foreach ($Line in $Lines) { + if ($Line -match '\$PSScriptRoot') { + $Line = $Line.Replace("'$ForwardPattern", "'") + $Line = $Line.Replace("'$BackslashPattern", "'") + $Line = $Line.Replace("""$ForwardPattern", """") + $Line = $Line.Replace("""$BackslashPattern", """") + } + $AdjustedLines.Add($Line) + } + $Content = $AdjustedLines -join "`n" + + # Safety check: warn about any remaining parent-directory navigation after $PSScriptRoot + if ($Content -match '\$PSScriptRoot[/\\]\.\.') { + Write-Warning "Remaining `$PSScriptRoot/.. reference in consolidated content from '$FileName' — manual review recommended" + } + + return $Content +} + +# Helper: strip file-level preamble lines that are only valid at the top of an +# individual .ps1 script file. When concatenated into a single PSM1, these bare +# attributes and param() become syntax errors. This function only removes leading +# preamble lines (before the first function/class definition), leaving identical +# attributes inside function bodies untouched. +function Remove-FileLevelPreamble { + param ( + [string] $Content, + [string] $FileName = '' + ) + + $SuppressPattern = '^\s*\[Diagnostics\.CodeAnalysis\.SuppressMessageAttribute\(' + $ParamPattern = '^\s*param\s*\(\s*\)\s*$' + $UsingModulePattern = '^\s*using\s+module\s+' + $GeneratedPattern = '^\s*#\s*Generated by' + + $Lines = $Content -split "`n" + $Result = [System.Collections.Generic.List[string]]::new($Lines.Count) + $StrippedItems = [System.Collections.Generic.List[string]]::new() + $InPreamble = $true + + foreach ($Line in $Lines) { + if ($InPreamble) { + # While in the preamble region, skip lines matching preamble patterns. + # Stop the preamble at the first line that is actual code (function, + # class, or any non-blank, non-comment, non-preamble line). + $Trimmed = $Line.Trim() + + if ($Trimmed -eq '' -or $Trimmed.StartsWith('#')) { + # Blank lines and regular comments — keep in preamble region. + # But skip "# Generated by" comments. + if ($Trimmed -match $GeneratedPattern) { + $StrippedItems.Add('Generated-by comment') + continue + } + $Result.Add($Line) + continue + } + + if ($Trimmed -match $SuppressPattern) { + $StrippedItems.Add('SuppressMessageAttribute') + continue + } + if ($Trimmed -match $ParamPattern) { + $StrippedItems.Add('param()') + continue + } + if ($Trimmed -match $UsingModulePattern) { + $StrippedItems.Add('using module') + continue + } + + # This line is actual code — exit preamble mode and keep it. + $InPreamble = $false + $Result.Add($Line) + } else { + $Result.Add($Line) + } + } + + if ($StrippedItems.Count -gt 0 -and $FileName) { + Write-Host " Stripped preamble from '$FileName': $($StrippedItems -join ', ')" + } + + return ($Result -join "`n") +} + +# Build the consolidated PSM1 content. +$Builder = [System.Text.StringBuilder]::new() + +# Preamble: module header, #Requires, and session variable initialization. +# Extracted from the source Maester.psm1 — the dot-sourcing loops are replaced by +# the inline consolidated content below. +$null = $Builder.AppendLine(@' +<# +.DISCLAIMER + THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF + ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A + PARTICULAR PURPOSE. + + Copyright (c) Microsoft Corporation. All rights reserved. +#> + +## Initialize Module Configuration +#Requires -Modules Pester, Microsoft.Graph.Authentication + +## Initialize Module Variables +## Update Clear-ModuleVariable function in internal/Clear-ModuleVariable.ps1 if you add new variables here +$__MtSession = @{ + GraphCache = @{} + GraphBaseUri = $null + TestResultDetail = @{} + Connections = @() + DnsCache = @() + ExoCache = @{} + OrcaCache = @{} + AIAgentInfo = $null + DataverseApiBase = $null # Resolved Dataverse OData API base URL (e.g. https://org123.api.crm.dynamics.com/api/data/v9.2) + DataverseResourceUrl = $null # Dataverse resource URL for token acquisition (e.g. https://org123.crm.dynamics.com) + DataverseEnvironmentId = $null # Environment identifier for display (e.g. org123.crm.dynamics.com) +} +New-Variable -Name __MtSession -Value $__MtSession -Scope Script -Force +'@) + +$null = $Builder.AppendLine() +$null = $Builder.AppendLine('#region Internal Functions') +$null = $Builder.AppendLine() + +foreach ($File in $InternalFiles) { + $FileContent = Get-Content -Path $File.FullName -Raw + $FileContent = Remove-FileLevelPreamble -Content $FileContent -FileName $File.Name + $Depth = Get-RelativeDepth -FilePath $File.FullName -BasePath $SourceRoot + $FileContent = Resolve-ConsolidatedPaths -Content $FileContent -Depth $Depth -FileName $File.Name + + $null = $Builder.AppendLine("# ── $($File.Name) ──") + $null = $Builder.AppendLine($FileContent.TrimEnd()) + $null = $Builder.AppendLine() +} + +$null = $Builder.AppendLine('#endregion Internal Functions') +$null = $Builder.AppendLine() +$null = $Builder.AppendLine('#region Public Functions') +$null = $Builder.AppendLine() + +foreach ($File in $PublicFiles) { + $FileContent = Get-Content -Path $File.FullName -Raw + $FileContent = Remove-FileLevelPreamble -Content $FileContent -FileName $File.Name + $Depth = Get-RelativeDepth -FilePath $File.FullName -BasePath $SourceRoot + $FileContent = Resolve-ConsolidatedPaths -Content $FileContent -Depth $Depth -FileName $File.Name + + $null = $Builder.AppendLine("# ── $($File.Name) ──") + $null = $Builder.AppendLine($FileContent.TrimEnd()) + $null = $Builder.AppendLine() +} + +$null = $Builder.AppendLine('#endregion Public Functions') +$null = $Builder.AppendLine() + +# Read aliases from the source manifest for the Export-ModuleMember statement. +$SourceManifest = Import-PowerShellDataFile -Path "$SourceRoot/Maester.psd1" +$AliasExportList = $SourceManifest['AliasesToExport'] + +$FunctionExportString = ($ExportFunctionList | ForEach-Object { "'$_'" }) -join ",`n " +$AliasExportString = ($AliasExportList | ForEach-Object { "'$_'" }) -join ", " + +$null = $Builder.AppendLine("Export-ModuleMember -Function @(") +$null = $Builder.AppendLine(" $FunctionExportString") +$null = $Builder.AppendLine(") -Alias @($AliasExportString)") +$null = $Builder.AppendLine() + +# Safely import module manifest (mirrors source Maester.psm1 behavior). +$null = $Builder.AppendLine(@' +# Safely import module manifest +try { + $ModuleInfo = Import-PowerShellDataFile -Path "$PSScriptRoot/Maester.psd1" -ErrorAction Stop +} catch { + Write-Warning "Failed to load module manifest: $($_.Exception.Message)" + $ModuleInfo = $null +} +'@) +$null = $Builder.AppendLine() + +$OutputPsm1 = Join-Path $OutputRoot 'Maester.psm1' +Set-Content -Path $OutputPsm1 -Value $Builder.ToString() -Encoding utf8BOM -NoNewline +Write-Host " Written: Maester.psm1" + +# ────────────────────────────────────────────────────────────────────────────── +# Phase D — Consolidate ORCA class files into OrcaClasses.ps1 +# ────────────────────────────────────────────────────────────────────────────── + +Write-Host '── Phase D: Consolidating ORCA classes' -ForegroundColor Cyan + +$OrcaBuilder = [System.Text.StringBuilder]::new() + +# Base classes and enums from orcaClass.psm1 — must come first (defines all base +# types before any derived check classes). +$OrcaClassPath = Join-Path $SourceRoot 'internal/orca/orcaClass.psm1' +$OrcaBaseContent = Get-Content -Path $OrcaClassPath -Raw + +$null = $OrcaBuilder.AppendLine('# Consolidated ORCA class definitions') +$null = $OrcaBuilder.AppendLine('# Generated by Build-MaesterModule.ps1 — do not edit manually.') +$null = $OrcaBuilder.AppendLine() +$null = $OrcaBuilder.AppendLine('# ── Base Classes and Enums (orcaClass.psm1) ──') +$null = $OrcaBuilder.AppendLine($OrcaBaseContent.TrimEnd()) +$null = $OrcaBuilder.AppendLine() + +# Derived check classes — each check-ORCA*.ps1 file defines a class that inherits +# from ORCACheck. The `using module` directive is stripped because the base classes +# are now defined inline above. +$OrcaCheckFiles = Get-ChildItem -Path "$SourceRoot/internal/orca" -Filter 'check-ORCA*.ps1' | + Sort-Object -Property Name + +$UsingModulePattern = '^\s*using\s+module\s+["'']\.[\\/]orcaClass\.psm1["'']\s*$' + +foreach ($File in $OrcaCheckFiles) { + $FileContent = Get-Content -Path $File.FullName -Raw + + # Strip file-level preamble (SuppressMessageAttribute, param(), Generated-by, + # using module) using the same preamble-aware helper as Phase C. + $FileContent = Remove-FileLevelPreamble -Content $FileContent -FileName $File.Name + + # Also strip 'using module' references to the base class file that appear outside + # the preamble, since the base classes are now defined inline above. + if ($FileContent -match $UsingModulePattern) { + $FileContent = ($FileContent -split "`n" | + Where-Object { $_ -notmatch $UsingModulePattern }) -join "`n" + } + + $null = $OrcaBuilder.AppendLine("# ── $($File.Name) ──") + $null = $OrcaBuilder.AppendLine($FileContent.TrimEnd()) + $null = $OrcaBuilder.AppendLine() +} + +$OutputOrcaClasses = Join-Path $OutputRoot 'OrcaClasses.ps1' +Set-Content -Path $OutputOrcaClasses -Value $OrcaBuilder.ToString() -Encoding utf8BOM -NoNewline +Write-Host " Written: OrcaClasses.ps1 ($($OrcaCheckFiles.Count) check classes)" + +# ────────────────────────────────────────────────────────────────────────────── +# Phase E — Copy static assets (must run before manifest update so that +# FormatsToProcess references can be validated) +# ────────────────────────────────────────────────────────────────────────────── + +Write-Host '── Phase E: Copying static assets' -ForegroundColor Cyan + +# Assets directory +$AssetsSource = Join-Path $SourceRoot 'assets' +$AssetsOutput = Join-Path $OutputRoot 'assets' +Copy-Item -Path $AssetsSource -Destination $AssetsOutput -Recurse -Force +Write-Host " Copied: assets/" + +# Format file +$FormatFile = Join-Path $SourceRoot 'Maester.Format.ps1xml' +if (Test-Path -LiteralPath $FormatFile) { + Copy-Item -Path $FormatFile -Destination $OutputRoot -Force + Write-Host " Copied: Maester.Format.ps1xml" +} + +# README +$ReadmeFile = Join-Path $SourceRoot 'README.md' +if (Test-Path -LiteralPath $ReadmeFile) { + Copy-Item -Path $ReadmeFile -Destination $OutputRoot -Force + Write-Host " Copied: README.md" +} + +# ────────────────────────────────────────────────────────────────────────────── +# Phase F — Copy and update module manifest +# ────────────────────────────────────────────────────────────────────────────── + +Write-Host '── Phase F: Updating module manifest' -ForegroundColor Cyan + +$OutputManifest = Join-Path $OutputRoot 'Maester.psd1' +Copy-Item -Path "$SourceRoot/Maester.psd1" -Destination $OutputManifest -Force + +# Update FunctionsToExport and ScriptsToProcess in the output manifest. +Update-ModuleManifest -Path $OutputManifest ` + -FunctionsToExport $ExportFunctionList.ToArray() ` + -ScriptsToProcess @('OrcaClasses.ps1') + +Write-Host " FunctionsToExport: $($ExportFunctionList.Count) functions" +Write-Host " ScriptsToProcess: OrcaClasses.ps1" + +# ────────────────────────────────────────────────────────────────────────────── +# Phase G — Copy tests as-is (PR 3 will replace with per-suite consolidation) +# ────────────────────────────────────────────────────────────────────────────── + +Write-Host '── Phase G: Copying test suites' -ForegroundColor Cyan + +$TestsOutput = Join-Path $OutputRoot 'maester-tests' +Copy-Item -Path $TestsRoot -Destination $TestsOutput -Recurse -Force +Write-Host " Copied: tests/ → maester-tests/" + +# ────────────────────────────────────────────────────────────────────────────── +# Phase H — Build profiling (optional) +# ────────────────────────────────────────────────────────────────────────────── + +if ($Profile) { + Write-Host '── Phase H: Profiling module import' -ForegroundColor Cyan + + $OutputManifestPath = Join-Path $OutputRoot 'Maester.psd1' + $ImportTime = Measure-Command { + Import-Module $OutputManifestPath -Force -ErrorAction Stop + } + $CommandCount = (Get-Command -Module Maester).Count + + Write-Host " Import time: $([math]::Round($ImportTime.TotalSeconds, 3))s" + Write-Host " Exported commands: $CommandCount" + + Remove-Module Maester -Force -ErrorAction SilentlyContinue +} + +# ────────────────────────────────────────────────────────────────────────────── +# Summary +# ────────────────────────────────────────────────────────────────────────────── + +Write-Host '' +Write-Host '── Build complete' -ForegroundColor Green +Write-Host " Output directory: $OutputRoot" +Write-Host " Consolidated PSM1: Maester.psm1" +Write-Host " ORCA classes: OrcaClasses.ps1" +Write-Host " Public functions: $($ExportFunctionList.Count)" +Write-Host '' From 200154f5fde6d8ec335948277e6fbf5cd69dc94d Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:57:18 -0400 Subject: [PATCH 04/12] fix(style): Improve code formatting and consistency in Build-MaesterModule.ps1 --- build/Build-MaesterModule.ps1 | 56 +++++++++++++++++------------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/build/Build-MaesterModule.ps1 b/build/Build-MaesterModule.ps1 index 409b296cb..7879d05a2 100644 --- a/build/Build-MaesterModule.ps1 +++ b/build/Build-MaesterModule.ps1 @@ -65,7 +65,7 @@ Write-Host '── Phase B: Parsing public functions (AST)' -ForegroundColor Cya $PublicSourceFiles = Get-ChildItem -Path "$SourceRoot/public" -Filter '*.ps1' -Recurse | Where-Object { $_.Name -notlike '*.Tests.ps1' } | - Sort-Object -Property FullName + Sort-Object -Property FullName $ApprovedVerbs = (Get-Verb).Verb @@ -86,13 +86,13 @@ foreach ($File in $PublicSourceFiles) { # A top-level function's parent chain: FunctionDefinitionAst → NamedBlockAst → # ScriptBlockAst (of file) → nothing above. $TopLevelFunctions = $Ast.FindAll({ - param ($Node) - $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - -not ($Node.Parent -is [System.Management.Automation.Language.FunctionDefinitionAst] -or - ($Node.Parent -is [System.Management.Automation.Language.NamedBlockAst] -and - $Node.Parent.Parent -is [System.Management.Automation.Language.ScriptBlockAst] -and - $Node.Parent.Parent.Parent -is [System.Management.Automation.Language.FunctionDefinitionAst])) - }, $true) + param ($Node) + $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + -not ($Node.Parent -is [System.Management.Automation.Language.FunctionDefinitionAst] -or + ($Node.Parent -is [System.Management.Automation.Language.NamedBlockAst] -and + $Node.Parent.Parent -is [System.Management.Automation.Language.ScriptBlockAst] -and + $Node.Parent.Parent.Parent -is [System.Management.Automation.Language.FunctionDefinitionAst])) + }, $true) if ($TopLevelFunctions.Count -eq 0) { Write-Warning "No top-level function found in '$($File.Name)'" @@ -160,11 +160,11 @@ $InternalFiles = Get-ChildItem -Path "$SourceRoot/internal" -Filter '*.ps1' -Rec $_.Name -notlike '*.Tests.ps1' -and $_.Name -notlike 'check-ORCA*.ps1' } | - Sort-Object -Property FullName + Sort-Object -Property FullName $PublicFiles = Get-ChildItem -Path "$SourceRoot/public" -Filter '*.ps1' -Recurse | Where-Object { $_.Name -notlike '*.Tests.ps1' } | - Sort-Object -Property FullName + Sort-Object -Property FullName Write-Host " Internal files: $($InternalFiles.Count)" Write-Host " Public files: $($PublicFiles.Count)" @@ -201,7 +201,7 @@ function Resolve-ConsolidatedPaths { # Build the parent-navigation patterns for forward and back slashes. # Depth 1: '../' or '..\' - # Depth 2: '../../' or '..\..\' + # Depth 2: '../../' or '..\..\' $ForwardPattern = ('../' * $Depth) $BackslashPattern = ('..\' * $Depth) @@ -217,8 +217,8 @@ function Resolve-ConsolidatedPaths { if ($Line -match '\$PSScriptRoot') { $Line = $Line.Replace("'$ForwardPattern", "'") $Line = $Line.Replace("'$BackslashPattern", "'") - $Line = $Line.Replace("""$ForwardPattern", """") - $Line = $Line.Replace("""$BackslashPattern", """") + $Line = $Line.Replace("""$ForwardPattern", '"') + $Line = $Line.Replace("""$BackslashPattern", '"') } $AdjustedLines.Add($Line) } @@ -243,10 +243,10 @@ function Remove-FileLevelPreamble { [string] $FileName = '' ) - $SuppressPattern = '^\s*\[Diagnostics\.CodeAnalysis\.SuppressMessageAttribute\(' - $ParamPattern = '^\s*param\s*\(\s*\)\s*$' - $UsingModulePattern = '^\s*using\s+module\s+' - $GeneratedPattern = '^\s*#\s*Generated by' + $SuppressPattern = '^\s*\[Diagnostics\.CodeAnalysis\.SuppressMessageAttribute\(' + $ParamPattern = '^\s*param\s*\(\s*\)\s*$' + $UsingModulePattern = '^\s*using\s+module\s+' + $GeneratedPattern = '^\s*#\s*Generated by' $Lines = $Content -split "`n" $Result = [System.Collections.Generic.List[string]]::new($Lines.Count) @@ -376,9 +376,9 @@ $SourceManifest = Import-PowerShellDataFile -Path "$SourceRoot/Maester.psd1" $AliasExportList = $SourceManifest['AliasesToExport'] $FunctionExportString = ($ExportFunctionList | ForEach-Object { "'$_'" }) -join ",`n " -$AliasExportString = ($AliasExportList | ForEach-Object { "'$_'" }) -join ", " +$AliasExportString = ($AliasExportList | ForEach-Object { "'$_'" }) -join ', ' -$null = $Builder.AppendLine("Export-ModuleMember -Function @(") +$null = $Builder.AppendLine('Export-ModuleMember -Function @(') $null = $Builder.AppendLine(" $FunctionExportString") $null = $Builder.AppendLine(") -Alias @($AliasExportString)") $null = $Builder.AppendLine() @@ -397,7 +397,7 @@ $null = $Builder.AppendLine() $OutputPsm1 = Join-Path $OutputRoot 'Maester.psm1' Set-Content -Path $OutputPsm1 -Value $Builder.ToString() -Encoding utf8BOM -NoNewline -Write-Host " Written: Maester.psm1" +Write-Host ' Written: Maester.psm1' # ────────────────────────────────────────────────────────────────────────────── # Phase D — Consolidate ORCA class files into OrcaClasses.ps1 @@ -438,7 +438,7 @@ foreach ($File in $OrcaCheckFiles) { # the preamble, since the base classes are now defined inline above. if ($FileContent -match $UsingModulePattern) { $FileContent = ($FileContent -split "`n" | - Where-Object { $_ -notmatch $UsingModulePattern }) -join "`n" + Where-Object { $_ -notmatch $UsingModulePattern }) -join "`n" } $null = $OrcaBuilder.AppendLine("# ── $($File.Name) ──") @@ -461,20 +461,20 @@ Write-Host '── Phase E: Copying static assets' -ForegroundColor Cyan $AssetsSource = Join-Path $SourceRoot 'assets' $AssetsOutput = Join-Path $OutputRoot 'assets' Copy-Item -Path $AssetsSource -Destination $AssetsOutput -Recurse -Force -Write-Host " Copied: assets/" +Write-Host ' Copied: assets/' # Format file $FormatFile = Join-Path $SourceRoot 'Maester.Format.ps1xml' if (Test-Path -LiteralPath $FormatFile) { Copy-Item -Path $FormatFile -Destination $OutputRoot -Force - Write-Host " Copied: Maester.Format.ps1xml" + Write-Host ' Copied: Maester.Format.ps1xml' } # README $ReadmeFile = Join-Path $SourceRoot 'README.md' if (Test-Path -LiteralPath $ReadmeFile) { Copy-Item -Path $ReadmeFile -Destination $OutputRoot -Force - Write-Host " Copied: README.md" + Write-Host ' Copied: README.md' } # ────────────────────────────────────────────────────────────────────────────── @@ -492,7 +492,7 @@ Update-ModuleManifest -Path $OutputManifest ` -ScriptsToProcess @('OrcaClasses.ps1') Write-Host " FunctionsToExport: $($ExportFunctionList.Count) functions" -Write-Host " ScriptsToProcess: OrcaClasses.ps1" +Write-Host ' ScriptsToProcess: OrcaClasses.ps1' # ────────────────────────────────────────────────────────────────────────────── # Phase G — Copy tests as-is (PR 3 will replace with per-suite consolidation) @@ -502,7 +502,7 @@ Write-Host '── Phase G: Copying test suites' -ForegroundColor Cyan $TestsOutput = Join-Path $OutputRoot 'maester-tests' Copy-Item -Path $TestsRoot -Destination $TestsOutput -Recurse -Force -Write-Host " Copied: tests/ → maester-tests/" +Write-Host ' Copied: tests/ → maester-tests/' # ────────────────────────────────────────────────────────────────────────────── # Phase H — Build profiling (optional) @@ -530,7 +530,7 @@ if ($Profile) { Write-Host '' Write-Host '── Build complete' -ForegroundColor Green Write-Host " Output directory: $OutputRoot" -Write-Host " Consolidated PSM1: Maester.psm1" -Write-Host " ORCA classes: OrcaClasses.ps1" +Write-Host ' Consolidated PSM1: Maester.psm1' +Write-Host ' ORCA classes: OrcaClasses.ps1' Write-Host " Public functions: $($ExportFunctionList.Count)" Write-Host '' From d98695ba8b39a42ee42ed11fd58526e1bd7d485d Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:57:27 -0400 Subject: [PATCH 05/12] feat(docs): Add comprehensive build documentation for Maester PowerShell module --- build/Build-MaesterModule.md | 168 +++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 build/Build-MaesterModule.md diff --git a/build/Build-MaesterModule.md b/build/Build-MaesterModule.md new file mode 100644 index 000000000..26a864393 --- /dev/null +++ b/build/Build-MaesterModule.md @@ -0,0 +1,168 @@ +# Build-MaesterModule + +Builds the Maester PowerShell module into a consolidated, publishable artifact +under `./module/`. The source tree (`powershell/`, `tests/`) is never modified. + +## Usage + +```powershell +# Standard build +./build/Build-MaesterModule.ps1 + +# Build with import-time profiling +./build/Build-MaesterModule.ps1 -Profile + +# Custom output location +./build/Build-MaesterModule.ps1 -OutputRoot ./out +``` + +## Parameters + +| Parameter | Type | Default | Description | +| ----------- | ------ | --------- | ------------- | +| `SourceRoot` | string | `../powershell` | Path to the module source directory | +| `TestsRoot` | string | `../tests` | Path to the test suites directory | +| `OutputRoot` | string | `../module` | Output directory (cleaned on every run) | +| `Profile` | switch | — | Measure and report `Import-Module` time | + +## Build Phases + +```mermaid +flowchart TD + A["Phase A\nClean output directory"] + B["Phase B\nAST-parse public functions"] + C["Phase C\nConsolidate Maester.psm1"] + D["Phase D\nConsolidate OrcaClasses.ps1"] + E["Phase E\nCopy static assets"] + F["Phase F\nUpdate module manifest"] + G["Phase G\nCopy test suites"] + H{"Profile?"} + I["Phase H\nImport-Module timing"] + Done["Build complete"] + + A --> B + B --> C + C --> D + D --> E + E --> F + F --> G + G --> H + H -- Yes --> I --> Done + H -- No --> Done +``` + +### Phase A — Clean output directory + +Removes and recreates the output directory (`./module/` by default) to ensure a +clean build with no stale artifacts. + +### Phase B — AST-parse public functions + +Scans every `.ps1` file under `powershell/public/` (excluding `*.Tests.ps1`) +using the PowerShell AST parser to discover top-level function definitions. + +- Only functions matching the `Verb-Noun` naming convention are exported. +- Helper functions without a `-` separator are logged and skipped. +- Duplicate function names across files are deduplicated and logged. +- Functions with unapproved verbs or mismatched filenames generate warnings. + +### Phase C — Consolidate Maester.psm1 + +Concatenates all internal and public `.ps1` source files into a single +`Maester.psm1`, organized as: + +1. **Module preamble** — `#Requires`, `$__MtSession` initialization +2. **Internal functions** — from `powershell/internal/` (excluding + `check-ORCA*.ps1`, which go to Phase D) +3. **Public functions** — from `powershell/public/` +4. **Export-ModuleMember** — auto-generated function and alias exports +5. **Manifest loader** — `Import-PowerShellDataFile` for runtime metadata + +Two transformations are applied to each source file: + +- **Preamble stripping** — Removes file-level `[SuppressMessageAttribute]`, + `param()`, `using module`, and `# Generated by` lines that are only valid at + the top of standalone `.ps1` files. Attributes inside function bodies are + preserved. +- **`$PSScriptRoot` path adjustment** — Strips parent-directory traversals + (`../`) that were needed in the original subdirectory structure but are + incorrect after consolidation to a flat module root. Any remaining + `$PSScriptRoot/..` references trigger a warning for manual review. + +### Phase D — Consolidate OrcaClasses.ps1 + +Merges the ORCA class hierarchy into a single `OrcaClasses.ps1`: + +1. **Base classes and enums** from `orcaClass.psm1` (preamble preserved since + this file runs standalone via `ScriptsToProcess`) +2. **Derived check classes** from each `check-ORCA*.ps1` file, with preambles + stripped and `using module` directives removed (the base classes are now + defined inline above) + +This file is registered as `ScriptsToProcess` in the manifest so that class +definitions are available before the module's `.psm1` loads. + +### Phase E — Copy static assets + +Copies unchanged files to the output directory: + +- `assets/` directory (icons, images) +- `Maester.Format.ps1xml` (type formatting) +- `README.md` + +This phase runs before the manifest update (Phase F) so that +`FormatsToProcess` references can be validated by `Update-ModuleManifest`. + +### Phase F — Update module manifest + +Copies the source `Maester.psd1` to the output directory and updates: + +- `FunctionsToExport` — set to the sorted, deduplicated list from Phase B +- `ScriptsToProcess` — set to `@('OrcaClasses.ps1')` + +All other manifest fields (version, GUID, `RequiredModules`, +`AliasesToExport`, etc.) are preserved from the source. + +### Phase G — Copy test suites + +Copies the `tests/` directory to `maester-tests/` in the output. Tests are +copied as-is and not consolidated. + +### Phase H — Build profiling (optional) + +When `-Profile` is specified, imports the built module and reports: + +- `Import-Module` wall-clock time +- Number of exported commands + +The module is unloaded after profiling. + +## Output Structure + +```text +module/ +├── assets/ # Icons and images +├── maester-tests/ # Test suites (copied from tests/) +├── Maester.Format.ps1xml # Type formatting definitions +├── Maester.psd1 # Updated module manifest +├── Maester.psm1 # Consolidated module script +├── OrcaClasses.ps1 # Consolidated ORCA class definitions +└── README.md +``` + +## Design Notes + +- **Source is never modified.** The build reads from `powershell/` and `tests/` + and writes exclusively to the output directory. +- **Deterministic output.** Files are sorted by full path before concatenation. + The `FunctionsToExport` list is sorted alphabetically. Repeated builds from + the same source produce identical output. +- **Encoding.** All generated PowerShell files use UTF-8 with BOM (`utf8BOM`), + matching the project convention. +- **Preamble stripping is position-aware.** Only lines in the file-level + preamble region (before the first function/class definition) are stripped. + Identical attributes inside function bodies are preserved. +- **The hardcoded PSM1 preamble** (module header, `#Requires`, + `$__MtSession`) is extracted from the source `Maester.psm1`. If new session + variables are added to the source, they must also be added to the build + script's preamble. From b6f2d188595ceb5155c6eb1a9cbff9d2005826cf Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:26:47 -0400 Subject: [PATCH 06/12] feat(build): Add -Format parameter to normalize source file indentation during consolidation --- build/Build-MaesterModule.ps1 | 57 ++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/build/Build-MaesterModule.ps1 b/build/Build-MaesterModule.ps1 index 7879d05a2..39cb66a0c 100644 --- a/build/Build-MaesterModule.ps1 +++ b/build/Build-MaesterModule.ps1 @@ -21,6 +21,12 @@ Path to the output directory for the built module. Defaults to ../module relative to this script. This directory is cleaned and recreated on every run. +.PARAMETER Format + When specified, normalizes source file indentation to 4 spaces using + Invoke-Formatter (PSScriptAnalyzer) during consolidation. Requires the + PSScriptAnalyzer module to be installed. Without this switch, source + content is concatenated as-is. + .PARAMETER Profile When specified, measures and reports Import-Module time and exported function count for the built module. @@ -36,6 +42,9 @@ param ( [Parameter()] [string] $OutputRoot = "$PSScriptRoot/../module", + [Parameter()] + [switch] $Format, + [Parameter()] [switch] $Profile ) @@ -299,6 +308,44 @@ function Remove-FileLevelPreamble { return ($Result -join "`n") } +# Helper: normalize indentation to 4 spaces using Invoke-Formatter. +# Only called when the -Format switch is specified. Requires PSScriptAnalyzer. +function Format-SourceContent { + param ( + [string] $Content, + [string] $FileName = '' + ) + + $Settings = @{ + IncludeRules = @('PSUseConsistentIndentation') + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 4 + Kind = 'space' + } + } + } + + try { + $Formatted = Invoke-Formatter -ScriptDefinition $Content -Settings $Settings + return $Formatted + } catch { + Write-Warning "Invoke-Formatter failed for '$FileName': $($_.Exception.Message)" + return $Content + } +} + +# Validate PSScriptAnalyzer availability when -Format is requested. +if ($Format) { + if (-not (Get-Command -Name Invoke-Formatter -ErrorAction SilentlyContinue)) { + Write-Warning 'PSScriptAnalyzer module is not installed. The -Format switch requires it. Continuing without formatting.' + $Format = $false + } else { + Write-Host ' Formatting enabled (Invoke-Formatter)' + } +} + # Build the consolidated PSM1 content. $Builder = [System.Text.StringBuilder]::new() @@ -346,6 +393,9 @@ foreach ($File in $InternalFiles) { $FileContent = Remove-FileLevelPreamble -Content $FileContent -FileName $File.Name $Depth = Get-RelativeDepth -FilePath $File.FullName -BasePath $SourceRoot $FileContent = Resolve-ConsolidatedPaths -Content $FileContent -Depth $Depth -FileName $File.Name + if ($Format) { + $FileContent = Format-SourceContent -Content $FileContent -FileName $File.Name + } $null = $Builder.AppendLine("# ── $($File.Name) ──") $null = $Builder.AppendLine($FileContent.TrimEnd()) @@ -362,6 +412,9 @@ foreach ($File in $PublicFiles) { $FileContent = Remove-FileLevelPreamble -Content $FileContent -FileName $File.Name $Depth = Get-RelativeDepth -FilePath $File.FullName -BasePath $SourceRoot $FileContent = Resolve-ConsolidatedPaths -Content $FileContent -Depth $Depth -FileName $File.Name + if ($Format) { + $FileContent = Format-SourceContent -Content $FileContent -FileName $File.Name + } $null = $Builder.AppendLine("# ── $($File.Name) ──") $null = $Builder.AppendLine($FileContent.TrimEnd()) @@ -393,7 +446,6 @@ try { $ModuleInfo = $null } '@) -$null = $Builder.AppendLine() $OutputPsm1 = Join-Path $OutputRoot 'Maester.psm1' Set-Content -Path $OutputPsm1 -Value $Builder.ToString() -Encoding utf8BOM -NoNewline @@ -433,6 +485,9 @@ foreach ($File in $OrcaCheckFiles) { # Strip file-level preamble (SuppressMessageAttribute, param(), Generated-by, # using module) using the same preamble-aware helper as Phase C. $FileContent = Remove-FileLevelPreamble -Content $FileContent -FileName $File.Name + if ($Format) { + $FileContent = Format-SourceContent -Content $FileContent -FileName $File.Name + } # Also strip 'using module' references to the base class file that appear outside # the preamble, since the base classes are now defined inline above. From 8e894762df828567b168129871d665fc8b9b3983 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:26:56 -0400 Subject: [PATCH 07/12] feat(docs): Update Build-MaesterModule.md to include -Format parameter details for indentation normalization --- build/Build-MaesterModule.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/build/Build-MaesterModule.md b/build/Build-MaesterModule.md index 26a864393..f5726de25 100644 --- a/build/Build-MaesterModule.md +++ b/build/Build-MaesterModule.md @@ -9,6 +9,9 @@ under `./module/`. The source tree (`powershell/`, `tests/`) is never modified. # Standard build ./build/Build-MaesterModule.ps1 +# Build with indentation normalization (requires PSScriptAnalyzer) +./build/Build-MaesterModule.ps1 -Format + # Build with import-time profiling ./build/Build-MaesterModule.ps1 -Profile @@ -23,6 +26,7 @@ under `./module/`. The source tree (`powershell/`, `tests/`) is never modified. | `SourceRoot` | string | `../powershell` | Path to the module source directory | | `TestsRoot` | string | `../tests` | Path to the test suites directory | | `OutputRoot` | string | `../module` | Output directory (cleaned on every run) | +| `Format` | switch | — | Normalize indentation to 4 spaces via `Invoke-Formatter` (requires PSScriptAnalyzer) | | `Profile` | switch | — | Measure and report `Import-Module` time | ## Build Phases @@ -88,6 +92,10 @@ Two transformations are applied to each source file: (`../`) that were needed in the original subdirectory structure but are incorrect after consolidation to a flat module root. Any remaining `$PSScriptRoot/..` references trigger a warning for manual review. +- **Indentation normalization** (with `-Format`) — Runs `Invoke-Formatter` + per file to enforce 4-space indentation. Only the + `PSUseConsistentIndentation` rule is applied. If PSScriptAnalyzer is not + installed, a warning is emitted and formatting is skipped. ### Phase D — Consolidate OrcaClasses.ps1 @@ -102,6 +110,9 @@ Merges the ORCA class hierarchy into a single `OrcaClasses.ps1`: This file is registered as `ScriptsToProcess` in the manifest so that class definitions are available before the module's `.psm1` loads. +When `-Format` is specified, each derived check class file is also passed +through `Invoke-Formatter` for indentation normalization. + ### Phase E — Copy static assets Copies unchanged files to the output directory: From 2eab0d1a3004170970ad916fc15a1d19e5a14b59 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:40:44 -0400 Subject: [PATCH 08/12] feat(docs): Add TODO for simplified README creation in Build-MaesterModule.ps1 --- build/Build-MaesterModule.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/Build-MaesterModule.ps1 b/build/Build-MaesterModule.ps1 index 39cb66a0c..9d6858882 100644 --- a/build/Build-MaesterModule.ps1 +++ b/build/Build-MaesterModule.ps1 @@ -526,11 +526,13 @@ if (Test-Path -LiteralPath $FormatFile) { } # README +<# To Do: Consider creating a simplified README that is intended specifically to be shipped with the module. Otherwise, do not include. $ReadmeFile = Join-Path $SourceRoot 'README.md' if (Test-Path -LiteralPath $ReadmeFile) { Copy-Item -Path $ReadmeFile -Destination $OutputRoot -Force Write-Host ' Copied: README.md' } +#> # ────────────────────────────────────────────────────────────────────────────── # Phase F — Copy and update module manifest From 2e9aaf293eeba2cad958ebb10fb5577741583e41 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:16:05 -0400 Subject: [PATCH 09/12] Update build/Build-MaesterModule.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- build/Build-MaesterModule.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/build/Build-MaesterModule.md b/build/Build-MaesterModule.md index f5726de25..c00a9f018 100644 --- a/build/Build-MaesterModule.md +++ b/build/Build-MaesterModule.md @@ -157,8 +157,7 @@ module/ ├── Maester.Format.ps1xml # Type formatting definitions ├── Maester.psd1 # Updated module manifest ├── Maester.psm1 # Consolidated module script -├── OrcaClasses.ps1 # Consolidated ORCA class definitions -└── README.md +└── OrcaClasses.ps1 # Consolidated ORCA class definitions ``` ## Design Notes From 90b58394ce81ad96f440b3bf1e805757a6971c96 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:23:37 -0400 Subject: [PATCH 10/12] feat(build): Update .gitignore to refine module and test result exclusions --- .gitignore | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 186552942..eca9ca38d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,9 @@ # Claude Code .claude/ -# Build output: consolidated module produced by build/Build-MaesterModule.ps1 -/module/ +#Ignore Codacy's MCP server configuration and instruction files +.github/instructions/codacy.instructions.md +.codacy/** # User-specific files *.rsuser @@ -493,18 +494,24 @@ test.json .idea -# Don't allow test results in the main branch +## ========================== ## +## Custom Entries for Maester ## +## ========================== ## + +# Don't allow Maester test results in the main branch test-results -website/build/ -/powershell/maester-tests +# Module build output: exists in local builds and CI builds, but should not be checked into source control. +# Users should download the module from the PowerShell Gallery or from the GitHub releases page. +module/ -# Don't add any test files to this folder +# Don't add any test files to the Custom folder. +# This location is reserved for Maester users to add their own tests. tests/Custom/*.ps1 +# Build outputs and staging artifacts should not be checked in. +website/build/ # ORCA Staging build/orca/orca/ -#Ignore Codacy's MCP server configuration and instruction files -.github/instructions/codacy.instructions.md -.codacy/** +powershell/maester-tests From dbe15cedc9fdf8f653e564f4adc28de4f33ed85b Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:53:43 -0400 Subject: [PATCH 11/12] feat(docs): Remove README.md from the list of copied files in Build-MaesterModule.md --- build/Build-MaesterModule.md | 1 - 1 file changed, 1 deletion(-) diff --git a/build/Build-MaesterModule.md b/build/Build-MaesterModule.md index c00a9f018..9401d4785 100644 --- a/build/Build-MaesterModule.md +++ b/build/Build-MaesterModule.md @@ -119,7 +119,6 @@ Copies unchanged files to the output directory: - `assets/` directory (icons, images) - `Maester.Format.ps1xml` (type formatting) -- `README.md` This phase runs before the manifest update (Phase F) so that `FormatsToProcess` references can be validated by `Update-ModuleManifest`. From 577014f63fb98710faff64fb79c948ad9c36dcd0 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:53:55 -0400 Subject: [PATCH 12/12] feat(build): Enhance safety checks for OutputRoot and refine function export logic --- build/Build-MaesterModule.ps1 | 77 +++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/build/Build-MaesterModule.ps1 b/build/Build-MaesterModule.ps1 index 9d6858882..9c5c21a48 100644 --- a/build/Build-MaesterModule.ps1 +++ b/build/Build-MaesterModule.ps1 @@ -58,6 +58,17 @@ $ErrorActionPreference = 'Stop' Write-Host '── Phase A: Preparing output directory' -ForegroundColor Cyan +# Safety guard: reject OutputRoot paths that could cause catastrophic deletion. +$RepoRoot = (Resolve-Path -LiteralPath "$PSScriptRoot/..").Path +$ResolvedOutput = [System.IO.Path]::GetFullPath($OutputRoot).TrimEnd('\', '/') +$DriveRoot = [System.IO.Path]::GetPathRoot($ResolvedOutput).TrimEnd('\', '/') +if ($ResolvedOutput -ieq $DriveRoot) { + throw "Refusing to use OutputRoot '$OutputRoot' because it resolves to a filesystem root: '$ResolvedOutput'." +} +if ($ResolvedOutput -ieq $RepoRoot.TrimEnd('\', '/')) { + throw "Refusing to use OutputRoot '$OutputRoot' because it resolves to the repository root: '$RepoRoot'." +} + if (Test-Path -LiteralPath $OutputRoot) { Remove-Item -LiteralPath $OutputRoot -Recurse -Force } @@ -92,15 +103,21 @@ foreach ($File in $PublicSourceFiles) { } # Find top-level function definitions only (not nested inside other functions). - # A top-level function's parent chain: FunctionDefinitionAst → NamedBlockAst → - # ScriptBlockAst (of file) → nothing above. + # Walk the full parent chain — any FunctionDefinitionAst ancestor means this + # function is nested, regardless of intermediate block types. $TopLevelFunctions = $Ast.FindAll({ param ($Node) - $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - -not ($Node.Parent -is [System.Management.Automation.Language.FunctionDefinitionAst] -or - ($Node.Parent -is [System.Management.Automation.Language.NamedBlockAst] -and - $Node.Parent.Parent -is [System.Management.Automation.Language.ScriptBlockAst] -and - $Node.Parent.Parent.Parent -is [System.Management.Automation.Language.FunctionDefinitionAst])) + if ($Node -isnot [System.Management.Automation.Language.FunctionDefinitionAst]) { + return $false + } + $Parent = $Node.Parent + while ($Parent) { + if ($Parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { + return $false + } + $Parent = $Parent.Parent + } + return $true }, $true) if ($TopLevelFunctions.Count -eq 0) { @@ -113,31 +130,37 @@ foreach ($File in $PublicSourceFiles) { Write-Warning "Multiple top-level functions in '$($File.Name)': $Names" } - foreach ($Function in $TopLevelFunctions) { - # Only export functions that follow the Verb-Noun naming convention. - # Helper functions, class definitions, and constructors that lack the - # conventional '-' separator are internal and should not be exported. - if ($Function.Name -notmatch '-') { - Write-Warning "Skipping '$($Function.Name)' in '$($File.Name)' — not a Verb-Noun function" - continue - } + # Only export the function whose name matches the filename. Additional + # top-level functions (helpers co-located in the same file) are logged + # and skipped to avoid unintentionally expanding the public API surface. + $MatchingFunction = $TopLevelFunctions | Where-Object { $_.Name -eq $File.BaseName } | Select-Object -First 1 + if (-not $MatchingFunction) { + $DiscoveredNames = ($TopLevelFunctions | ForEach-Object { $_.Name }) -join ', ' + Write-Warning "No top-level function matching filename '$($File.Name)' was found. Discovered: $DiscoveredNames" + continue + } - # Validate function name matches filename - if ($Function.Name -ne $File.BaseName) { - Write-Warning "Function name '$($Function.Name)' does not match filename '$($File.Name)'" - } + $AdditionalTopLevelFunctions = $TopLevelFunctions | Where-Object { $_.Name -ne $File.BaseName } + foreach ($Extra in $AdditionalTopLevelFunctions) { + Write-Warning "Skipping additional top-level function '$($Extra.Name)' in '$($File.Name)' — only '$($File.BaseName)' is exported" + } - # Validate approved verb - $Verb = ($Function.Name -split '-', 2)[0] - if ($Verb -and $Verb -notin $ApprovedVerbs) { - Write-Warning "Function '$($Function.Name)' uses unapproved verb '$Verb'" - } + # Only export functions that follow the Verb-Noun naming convention. + if ($MatchingFunction.Name -notmatch '-') { + Write-Warning "Skipping '$($MatchingFunction.Name)' in '$($File.Name)' — not a Verb-Noun function" + continue + } - $ExportFunctionList.Add($Function.Name) + # Validate approved verb + $Verb = ($MatchingFunction.Name -split '-', 2)[0] + if ($Verb -and $Verb -notin $ApprovedVerbs) { + Write-Warning "Function '$($MatchingFunction.Name)' uses unapproved verb '$Verb'" } + + $ExportFunctionList.Add($MatchingFunction.Name) } -$ExportFunctionList.Sort() +$ExportFunctionList.Sort([System.StringComparer]::OrdinalIgnoreCase) # Deduplicate — some helper functions (e.g., SPFRecord) are defined in multiple files. $SeenFunctions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -149,7 +172,7 @@ foreach ($Name in $ExportFunctionList) { } if ($DuplicateNames.Count -gt 0) { $ExportFunctionList = [System.Collections.Generic.List[string]]::new($SeenFunctions) - $ExportFunctionList.Sort() + $ExportFunctionList.Sort([System.StringComparer]::OrdinalIgnoreCase) foreach ($Dupe in $DuplicateNames) { Write-Warning "Deduplicated function: '$Dupe'" }