diff --git a/.gitignore b/.gitignore index 89d397c..9f9e43e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ temp/ run.ps1 dist/ -justfile \ No newline at end of file +justfile +testResults.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 613a940..0ebbd9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added +- Opt-in project settings: + - `BuildRecursiveFolders` (default `false`): recursive discovery for `src/classes`, `src/private` and `tests`. + - `FailOnDuplicateFunctionNames` (default `false`): fail build when duplicate top-level function names exist in generated `dist//.psm1`. + +### Changed +- Build determinism: files are processed in a deterministic order by relative path (case-insensitive), and load order is always `classes β†’ public β†’ private`. + +### Documentation +- README: document opt-in flags, deterministic load order, and recommended duplicate-function validation. + ## [1.3.0] - 2025-09-23 - Added support for `ps1xml1` format data. Place it in resources folder with `Name.format.ps1xml` to be automatically added as format file and imported in module manifest diff --git a/README.md b/README.md index 09e7468..a935b0e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Whether you're creating simple or robust modules, ModuleTools streamlines the pr The structure of the ModuleTools module is meticulously designed according to PowerShell best practices for module development. While some design decisions may seem unconventional, they are made to ensure that ModuleTools and the process of building modules remain straightforward and easy to manage. > [!IMPORTANT] -> Checkout this [Blog article](https://blog.belibug.com/post/ps-modulebuild) explaining core concepts of ModuleTools. +> Check out this [blog article](https://blog.belibug.com/post/ps-modulebuild) explaining the core concepts of ModuleTools. ## βš™οΈ Install @@ -26,7 +26,7 @@ The structure of the ModuleTools module is meticulously designed according to Po Install-Module -Name ModuleTools ``` -> Note: ModuleTolls is still in early development phase and lot of changes are expected. Please read through [ChangeLog](/CHANGELOG.md) for all updates. +> Note: ModuleTools is still in an early development phase and lots of changes are expected. Please read through the [changelog](/CHANGELOG.md) for all updates. ## 🧡 Design @@ -34,7 +34,7 @@ To ensure this module works correctly, you need to maintain the folder structure ## πŸ“‚ Folder Structure -All the Module files should be in inside `src` folder +All module files should be inside the `src` folder. ``` ο„• . @@ -52,7 +52,7 @@ All the Module files should be in inside `src` folder ### Dist Folder -Generated module is stored in dist folder, you can easily import it or publish it to PowerShell repository. +The generated module is stored in the `dist` folder. You can easily import it or publish it to a PowerShell repository. ``` ο„• dist @@ -63,7 +63,7 @@ Generated module is stored in dist folder, you can easily import it or publish i ### Docs Folder -Store `Microsoft.PowerShell.PlatyPs` generated markdown files in `docs` folder. If `docs` folder exists and contain valid markdown files, Build will generate MAML help file in the built module. +Store `Microsoft.PowerShell.PlatyPS` generated Markdown files in the `docs` folder. If the `docs` folder exists and contains valid Markdown files, the build will generate a MAML help file in the built module. ``` ο„• docs @@ -77,13 +77,53 @@ The `project.json` file contains all the important details about your module and Run `New-MTModule` to generate the scaffolding; this will also create the `project.json` file. +#### Build settings (optional) + +ModuleTools supports these optional settings at the top level of `project.json`: + +- `BuildRecursiveFolders` (default: `false`) + - When `true`, ModuleTools will discover `.ps1` files recursively in `src/classes` and `src/private`. + - `src/public` is always **top-level only** (never recursive). + - For `Invoke-MTTest`, `BuildRecursiveFolders=false` runs only top-level `tests/*.Tests.ps1` files (the usual Pester naming convention), while `BuildRecursiveFolders=true` also includes tests in subfolders. +- `FailOnDuplicateFunctionNames` (default: `false`, recommended: `true`) + - When `true`, ModuleTools will parse the generated `dist//.psm1` and fail the build if duplicate **top-level** function names exist. + +Example: + +```json +{ + "BuildRecursiveFolders": false, + "FailOnDuplicateFunctionNames": true +} +``` + ### Src Folder - Place all your functions in the `private` and `public` folders within the `src` directory. - All functions in the `public` folder are exported during the module build. - All functions in the `private` folder are accessible internally within the module but are not exposed outside the module. -- All `ps1` files in `classes` folder contains classes and enums, that are processed and placed in topmost of generated `psm1` files -- Contents of the `src/resources` folder will be handled based on setting `copyResourcesToModuleRoot` +- `src/classes` should contain classes and enums. These files are placed at the top of the generated `psm1`. +- `src/resources` content is handled based on `copyResourcesToModuleRoot`. + +#### Deterministic processing order + +To ensure builds are deterministic across platforms, files are processed in this order: + +1. `src/classes` +2. `src/public` +3. `src/private` + +Within each folder group, files are processed in a deterministic order by relative path (case-insensitive). + +#### Recursive folder support + +By default, ModuleTools loads only top-level `.ps1` files in each folder. + +If `BuildRecursiveFolders` is set to `true`: + +- `src/classes` and `src/private` are processed recursively. +- `src/public` remains top-level only. +- `Invoke-MTTest` also includes test files in nested folders under `tests`. #### Resources Folder @@ -98,6 +138,7 @@ The `resources` folder within the `src` directory is intended for including any - **Subfolder**: Include any additional folders and their content to be included with the module, such as dependant Modules, APIs, DLLs, etc... organized by a subfolder. + By default, resource files from `src/resources` go into `dist/resources`. To place them directly in dist (avoiding the resources subfolder), set `copyResourcesToModuleRoot` to `true`. This provides greater control in certain deployment scenarios where resources files are preferred in module root directory. Leave `src\resources` empty if there is no need to include any additional content in the `dist` folder. @@ -121,7 +162,7 @@ dist ### Tests Folder -If you want to run `pester` tests keep them in `tests` folder, if not you can ignore this function. +If you want to run Pester tests, keep them in the `tests` folder. Otherwise, you can ignore this feature. ## πŸ’» Commands @@ -150,13 +191,13 @@ Invoke-MTBuild -Verbose ### Get-MTProjectInfo -This functions give you complete info about the project which can be used in pester tests or for general troubleshooting. +This function provides complete info about the project, which can be used in Pester tests or for general troubleshooting. ### Invoke-MTTest -All the pester configurations are stored in `project.json`, simply run `Invoke-MTTest` command from project root, it will run all the tests inside `tests` folder +All Pester configuration is stored in `project.json`. Run `Invoke-MTTest` from the project root; with `BuildRecursiveFolders=false` it runs only top-level `tests/*.Tests.ps1` files, matching Pester's normal test-file convention, and with `BuildRecursiveFolders=true` it also runs tests in nested folders under `tests`. -- To skip a test insdie test directory use `-skip` in describe/it/context block within Pester test. +- To skip a test inside the test directory, use `-skip` in a `Describe`/`It`/`Context` block within the Pester test. - Use `Get-MTProjectInfo` command inside pester to get great amount of info about project and files ### Update-MTModuleVersion @@ -220,7 +261,8 @@ jobs: ## πŸ“ Requirement - Only tested on PowerShell 7.4, ~most likely~ will not work on 5.1. Underlying module can still support older version, only the ModuleTools builder wont work on older version. -- No depenedencies. This module doesn’t depend on any other module. Completely self contained +- Only tested on PowerShell 7.4, so it most likely will not work on 5.1. The underlying module can still support older versions; only the ModuleTools builder won't work on older versions. +- No dependencies. This module doesn’t depend on any other module. Completely self-contained. ## βœ… ToDo diff --git a/docs/ModuleTools/Invoke-MTTest.md b/docs/ModuleTools/Invoke-MTTest.md index a2feab1..3f47bdc 100644 --- a/docs/ModuleTools/Invoke-MTTest.md +++ b/docs/ModuleTools/Invoke-MTTest.md @@ -30,7 +30,7 @@ This cmdlet has the following aliases, ## DESCRIPTION -Run Pester tests using the specified configuration and settings as defined in project.json. Place all your tests in "tests" folder +Run Pester tests using the specified configuration and settings as defined in project.json. When `BuildRecursiveFolders` is `false`, only top-level `tests/*.Tests.ps1` files are run, following Pester's normal test-file convention. When `BuildRecursiveFolders` is `true`, test files in nested folders under `tests` are also discovered and run. ## EXAMPLES diff --git a/project.json b/project.json index 972a2aa..b209c0e 100644 --- a/project.json +++ b/project.json @@ -1,8 +1,10 @@ { "ProjectName": "ModuleTools", "Description": "ModuleTools is a versatile, standalone PowerShell module builder. Create anything from simple to robust modules with ease. Built for CICD and Automation.", - "Version": "1.7.1", + "Version": "1.8.0", "copyResourcesToModuleRoot": false, + "BuildRecursiveFolders": false, + "FailOnDuplicateFunctionNames": false, "Manifest": { "Author": "Manjunath Beli", "PowerShellHostVersion": "7.4", diff --git a/src/private/AssertBuiltModuleHasNoDuplicateFunctionNames.ps1 b/src/private/AssertBuiltModuleHasNoDuplicateFunctionNames.ps1 new file mode 100644 index 0000000..5aadde9 --- /dev/null +++ b/src/private/AssertBuiltModuleHasNoDuplicateFunctionNames.ps1 @@ -0,0 +1,30 @@ +function Assert-BuiltModuleHasNoDuplicateFunctionName { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$ProjectInfo + ) + + $psm1Path = $ProjectInfo.ModuleFilePSM1 + if (-not (Test-Path -LiteralPath $psm1Path)) { + throw "Built module file not found: $psm1Path" + } + + $parsed = Get-PowerShellAstFromFile -Path $psm1Path + if ($parsed.Errors -and $parsed.Errors.Count -gt 0) { + $messages = @($parsed.Errors | ForEach-Object { $_.Message }) -join '; ' + throw "Built module contains parse errors and cannot be validated for duplicates. File: $psm1Path. Errors: $messages" + } + + $topLevelFunctions = Get-TopLevelFunctionAst -Ast $parsed.Ast + $duplicates = Get-DuplicateFunctionGroup -FunctionAst $topLevelFunctions + + if (-not $duplicates) { + return + } + + $sourceFiles = Get-ProjectScriptFile -ProjectInfo $ProjectInfo + $sourceIndex = Get-FunctionSourceIndex -File $sourceFiles + + $errorText = Format-DuplicateFunctionErrorMessage -Psm1Path $psm1Path -DuplicateGroup $duplicates -SourceIndex $sourceIndex + throw $errorText +} diff --git a/src/private/BuildModule.ps1 b/src/private/BuildModule.ps1 index 52af5ab..c4d78ab 100644 --- a/src/private/BuildModule.ps1 +++ b/src/private/BuildModule.ps1 @@ -7,24 +7,10 @@ function Build-Module { $sb = [System.Text.StringBuilder]::new() - # Classes Folder - $files = Get-ChildItem -Path $data.ClassesDir -Filter *.ps1 -ErrorAction SilentlyContinue - $files | ForEach-Object { - $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null - } - - # Public Folder - $files = Get-ChildItem -Path $data.PublicDir -Filter *.ps1 - $files | ForEach-Object { - $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null - } - - # Private Folder - $files = Get-ChildItem -Path $data.PrivateDir -Filter *.ps1 -ErrorAction SilentlyContinue - if ($files) { - $files | ForEach-Object { - $sb.AppendLine([IO.File]::ReadAllText($_.FullName)) | Out-Null - } + $files = Get-ProjectScriptFile -ProjectInfo $data + foreach ($file in $files) { + $sb.AppendLine([IO.File]::ReadAllText($file.FullName)) | Out-Null + $sb.AppendLine() | Out-Null } try { Set-Content -Path $data.ModuleFilePSM1 -Value $sb.ToString() -Encoding 'UTF8' -ErrorAction Stop # psm1 file diff --git a/src/private/FormatDuplicateFunctionErrorMessage.ps1 b/src/private/FormatDuplicateFunctionErrorMessage.ps1 new file mode 100644 index 0000000..8d52881 --- /dev/null +++ b/src/private/FormatDuplicateFunctionErrorMessage.ps1 @@ -0,0 +1,29 @@ +function Format-DuplicateFunctionErrorMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Psm1Path, + [Parameter(Mandatory)][object[]]$DuplicateGroup, + [hashtable]$SourceIndex + ) + + $lines = New-Object 'System.Collections.Generic.List[string]' + $lines.Add("Duplicate top-level function names detected in built module: $Psm1Path") + + foreach ($dup in ($DuplicateGroup | Sort-Object -Property Name)) { + $key = '' + $dup.Name + $displayName = $dup.Group[0].Name + + $lines.Add('') + $lines.Add("- $displayName") + + foreach ($occurrence in ($dup.Group | Sort-Object { $_.Extent.StartLineNumber })) { + $lines.Add((" - dist line {0}" -f $occurrence.Extent.StartLineNumber)) + } + + foreach ($sourceLine in (Get-DuplicateFunctionSourceLine -Key $key -SourceIndex $SourceIndex)) { + $lines.Add($sourceLine) + } + } + + return ($lines -join "`n") +} diff --git a/src/private/GetDuplicateFunctionGroup.ps1 b/src/private/GetDuplicateFunctionGroup.ps1 new file mode 100644 index 0000000..60d1f3a --- /dev/null +++ b/src/private/GetDuplicateFunctionGroup.ps1 @@ -0,0 +1,12 @@ +function Get-DuplicateFunctionGroup { + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Management.Automation.Language.FunctionDefinitionAst[]]$FunctionAst + ) + + return @( + $FunctionAst | + Group-Object -Property { ('' + $_.Name).ToLowerInvariant() } | + Where-Object { $_.Count -gt 1 } + ) +} diff --git a/src/private/GetDuplicateFunctionSourceLine.ps1 b/src/private/GetDuplicateFunctionSourceLine.ps1 new file mode 100644 index 0000000..bac2aab --- /dev/null +++ b/src/private/GetDuplicateFunctionSourceLine.ps1 @@ -0,0 +1,24 @@ +function Get-DuplicateFunctionSourceLine { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Key, + [hashtable]$SourceIndex + ) + + if (-not $SourceIndex) { + return @() + } + + if (-not $SourceIndex.ContainsKey($Key)) { + return @() + } + + $lines = New-Object 'System.Collections.Generic.List[string]' + $lines.Add(' - source files:') + + foreach ($src in ($SourceIndex[$Key] | Sort-Object Path, Line)) { + $lines.Add((" - {0}:{1}" -f $src.Path, $src.Line)) + } + + return @($lines) +} diff --git a/src/private/GetFunctionSourceIndex.ps1 b/src/private/GetFunctionSourceIndex.ps1 new file mode 100644 index 0000000..d2685ed --- /dev/null +++ b/src/private/GetFunctionSourceIndex.ps1 @@ -0,0 +1,22 @@ +function Get-FunctionSourceIndex { + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.IO.FileInfo[]]$File + ) + + $index = @{} + + foreach ($f in $File) { + foreach ($fn in (Get-TopLevelFunctionAstFromFile -Path $f.FullName)) { + $key = ('' + $fn.Name).ToLowerInvariant() + + $list = Get-OrCreateHashtableList -Index $index -Key $key + $list.Add([pscustomobject]@{ + Path = $f.FullName + Line = $fn.Extent.StartLineNumber + }) + } + } + + return $index +} diff --git a/src/private/GetNormalizedRelativePath.ps1 b/src/private/GetNormalizedRelativePath.ps1 new file mode 100644 index 0000000..63eaf9c --- /dev/null +++ b/src/private/GetNormalizedRelativePath.ps1 @@ -0,0 +1,11 @@ +function Get-NormalizedRelativePath { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$FullName + ) + + $rel = [System.IO.Path]::GetRelativePath($Root, $FullName) + $rel = $rel -replace '\\', '/' + return $rel +} diff --git a/src/private/GetOrCreateHashtableList.ps1 b/src/private/GetOrCreateHashtableList.ps1 new file mode 100644 index 0000000..b123a74 --- /dev/null +++ b/src/private/GetOrCreateHashtableList.ps1 @@ -0,0 +1,13 @@ +function Get-OrCreateHashtableList { + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$Index, + [Parameter(Mandatory)][string]$Key + ) + + if (-not $Index.ContainsKey($Key)) { + $Index[$Key] = New-Object 'System.Collections.Generic.List[object]' + } + + return $Index[$Key] +} diff --git a/src/private/GetOrderedScriptFileForDirectory.ps1 b/src/private/GetOrderedScriptFileForDirectory.ps1 new file mode 100644 index 0000000..01273c4 --- /dev/null +++ b/src/private/GetOrderedScriptFileForDirectory.ps1 @@ -0,0 +1,29 @@ +function Get-OrderedScriptFileForDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][string]$ProjectRoot, + [Parameter(Mandatory)][bool]$Recurse + ) + + if (-not (Test-Path -LiteralPath $Directory)) { + return @() + } + + $items = if ($Recurse) { + Get-ChildItem -Path $Directory -Filter '*.ps1' -File -Recurse -ErrorAction SilentlyContinue + } + else { + Get-ChildItem -Path $Directory -Filter '*.ps1' -File -ErrorAction SilentlyContinue + } + + $root = $ProjectRoot + + return @( + $items | + Sort-Object -Stable -Property @( + @{ Expression = { (Get-NormalizedRelativePath -Root $root -FullName $_.FullName).ToLowerInvariant() } }, + @{ Expression = { $_.FullName.ToLowerInvariant() } } + ) + ) +} diff --git a/src/private/GetPowerShellAstFromFile.ps1 b/src/private/GetPowerShellAstFromFile.ps1 new file mode 100644 index 0000000..b3f2723 --- /dev/null +++ b/src/private/GetPowerShellAstFromFile.ps1 @@ -0,0 +1,15 @@ +function Get-PowerShellAstFromFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path + ) + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) + + return [pscustomobject]@{ + Ast = $ast + Errors = $errors + } +} diff --git a/src/private/GetProjectScriptFiles.ps1 b/src/private/GetProjectScriptFiles.ps1 new file mode 100644 index 0000000..67f28b6 --- /dev/null +++ b/src/private/GetProjectScriptFiles.ps1 @@ -0,0 +1,26 @@ +function Get-ProjectScriptFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$ProjectInfo + ) + + $recurse = [bool]$ProjectInfo.BuildRecursiveFolders + + $ordered = New-Object 'System.Collections.Generic.List[System.IO.FileInfo]' + + $root = $ProjectInfo.ProjectRoot + + foreach ($f in (Get-OrderedScriptFileForDirectory -Directory $ProjectInfo.ClassesDir -ProjectRoot $root -Recurse:$recurse)) { + $ordered.Add($f) + } + + foreach ($f in (Get-OrderedScriptFileForDirectory -Directory $ProjectInfo.PublicDir -ProjectRoot $root -Recurse:$false)) { + $ordered.Add($f) + } + + foreach ($f in (Get-OrderedScriptFileForDirectory -Directory $ProjectInfo.PrivateDir -ProjectRoot $root -Recurse:$recurse)) { + $ordered.Add($f) + } + + return @($ordered) +} diff --git a/src/private/GetTopLevelFunctionAst.ps1 b/src/private/GetTopLevelFunctionAst.ps1 new file mode 100644 index 0000000..2c3741b --- /dev/null +++ b/src/private/GetTopLevelFunctionAst.ps1 @@ -0,0 +1,29 @@ +function Get-TopLevelFunctionAst { + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Management.Automation.Language.Ast]$Ast + ) + + $all = @($Ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true)) + + $top = foreach ($candidate in $all) { + $nested = $false + foreach ($other in $all) { + if ($other -eq $candidate) { continue } + + if ($other.Extent.StartOffset -lt $candidate.Extent.StartOffset -and $other.Extent.EndOffset -gt $candidate.Extent.EndOffset) { + $nested = $true + break + } + } + + if (-not $nested) { + $candidate + } + } + + return @($top) +} diff --git a/src/private/GetTopLevelFunctionAstFromFile.ps1 b/src/private/GetTopLevelFunctionAstFromFile.ps1 new file mode 100644 index 0000000..c8b274a --- /dev/null +++ b/src/private/GetTopLevelFunctionAstFromFile.ps1 @@ -0,0 +1,13 @@ +function Get-TopLevelFunctionAstFromFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path + ) + + $parsed = Get-PowerShellAstFromFile -Path $Path + if ($parsed.Errors -and $parsed.Errors.Count -gt 0) { + return @() + } + + return @(Get-TopLevelFunctionAst -Ast $parsed.Ast) +} diff --git a/src/public/GetMTProjectInfo.ps1 b/src/public/GetMTProjectInfo.ps1 index 19783ca..3252a9b 100644 --- a/src/public/GetMTProjectInfo.ps1 +++ b/src/public/GetMTProjectInfo.ps1 @@ -19,6 +19,20 @@ function Get-MTProjectInfo { foreach ($key in $jsonData.Keys) { $Out[$key] = $jsonData[$key] } + + if (-not $Out.ContainsKey('BuildRecursiveFolders')) { + $Out['BuildRecursiveFolders'] = $false + } + else { + $Out['BuildRecursiveFolders'] = [bool]$Out['BuildRecursiveFolders'] + } + + if (-not $Out.ContainsKey('FailOnDuplicateFunctionNames')) { + $Out['FailOnDuplicateFunctionNames'] = $false + } + else { + $Out['FailOnDuplicateFunctionNames'] = [bool]$Out['FailOnDuplicateFunctionNames'] + } $Out.ProjectJson = $projectJson $Out.PSTypeName = 'MTProjectInfo' $ProjectName = $jsonData.ProjectName @@ -29,6 +43,7 @@ function Get-MTProjectInfo { $Out['PrivateDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'private') $Out['ClassesDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'classes') $Out['ResourcesDir'] = [System.IO.Path]::Join($ProjectRoot, 'src', 'resources') + $Out['TestsDir'] = [System.IO.Path]::Join($ProjectRoot, 'tests') $Out['DocsDir'] = [System.IO.Path]::Join($ProjectRoot, 'docs') $Out['OutputDir'] = [System.IO.Path]::Join($ProjectRoot, 'dist') $Out['OutputModuleDir'] = [System.IO.Path]::Join($Out.OutputDir, $ProjectName) diff --git a/src/public/InvokeMTBuild.ps1 b/src/public/InvokeMTBuild.ps1 index a5637e3..c0df53b 100644 --- a/src/public/InvokeMTBuild.ps1 +++ b/src/public/InvokeMTBuild.ps1 @@ -5,6 +5,12 @@ function Invoke-MTBuild { $ErrorActionPreference = 'Stop' Reset-ProjectDist Build-Module + + $data = Get-MTProjectInfo + if ($data.FailOnDuplicateFunctionNames) { + Assert-BuiltModuleHasNoDuplicateFunctionName -ProjectInfo $data + } + Build-Manifest Build-Help Copy-ProjectResource diff --git a/src/public/InvokeMTTests.ps1 b/src/public/InvokeMTTests.ps1 index 65954d7..8197a6d 100644 --- a/src/public/InvokeMTTests.ps1 +++ b/src/public/InvokeMTTests.ps1 @@ -5,20 +5,26 @@ function Invoke-MTTest { [string[]]$ExcludeTagFilter ) Test-ProjectSchema Pester | Out-Null - $Script:data = Get-MTProjectInfo + $Script:data = Get-MTProjectInfo $pesterConfig = New-PesterConfiguration -Hashtable $data.Pester - $testPath = './tests' + $testPath = if ($data.BuildRecursiveFolders) { + $data.TestsDir + } + else { + [System.IO.Path]::Join($data.TestsDir, '*.Tests.ps1') + } + $pesterConfig.Run.Path = $testPath $pesterConfig.Run.PassThru = $true $pesterConfig.Run.Exit = $true $pesterConfig.Run.Throw = $true - $pesterConfig.Filter.Tag = $TagFilter - $pesterConfig.Filter.ExcludeTag = $ExcludeTagFilter + $pesterConfig.Filter.Tag = $TagFilter + $pesterConfig.Filter.ExcludeTag = $ExcludeTagFilter $pesterConfig.TestResult.OutputPath = './dist/TestResults.xml' $TestResult = Invoke-Pester -Configuration $pesterConfig if ($TestResult.Result -ne 'Passed') { - Write-Error 'Tests failed' -ErrorAction Stop + Write-Error 'Tests failed' -ErrorAction Stop return $LASTEXITCODE } } \ No newline at end of file diff --git a/src/resources/ProjectTemplate.json b/src/resources/ProjectTemplate.json index 4259bc0..b2de375 100644 --- a/src/resources/ProjectTemplate.json +++ b/src/resources/ProjectTemplate.json @@ -3,6 +3,8 @@ "Description": "", "Version": "", "copyResourcesToModuleRoot": false, + "BuildRecursiveFolders": false, + "FailOnDuplicateFunctionNames": false, "Manifest": { "Author": "", "PowerShellHostVersion": "", diff --git a/src/resources/Schema-Build.json b/src/resources/Schema-Build.json index 56dd3b6..d4c8803 100644 --- a/src/resources/Schema-Build.json +++ b/src/resources/Schema-Build.json @@ -15,6 +15,14 @@ "copyResourcesToModuleRoot": { "type": "boolean" }, + "BuildRecursiveFolders": { + "type": "boolean", + "description": "Opt-in recursive discovery for src/classes and src/private. src/public stays top-level only." + }, + "FailOnDuplicateFunctionNames": { + "type": "boolean", + "description": "Opt-in validation: fail build when duplicate top-level function names exist in the generated dist//.psm1." + }, "Manifest": { "type": "object", "properties": { diff --git a/tests/BuildOptions.TestSupport.ps1 b/tests/BuildOptions.TestSupport.ps1 new file mode 100644 index 0000000..3670d5a --- /dev/null +++ b/tests/BuildOptions.TestSupport.ps1 @@ -0,0 +1,219 @@ +function New-TestProjectRoot { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$TestDriveRoot, + [Parameter(Mandatory)][string]$Name + ) + + $root = Join-Path $TestDriveRoot $Name + New-Item -ItemType Directory -Path $root -Force | Out-Null + + foreach ($dir in @( + 'src/public', + 'src/public/nested', + 'src/private', + 'src/private/a', + 'src/private/b', + 'src/classes', + 'src/classes/nested', + 'tests', + 'tests/nested', + 'docs' + )) { + New-Item -ItemType Directory -Path (Join-Path $root $dir) -Force | Out-Null + } + + return $root +} + +function Write-TestProjectJson { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProjectRoot, + [Parameter(Mandatory)][hashtable]$Options + ) + + $project = [ordered]@{ + ProjectName = ('' + $Options.ProjectName) + Description = 'Test project' + Version = '0.0.1' + copyResourcesToModuleRoot = $false + BuildRecursiveFolders = [bool]$Options.BuildRecursiveFolders + FailOnDuplicateFunctionNames = [bool]$Options.FailOnDuplicateFunctionNames + Manifest = [ordered]@{ + Author = 'Test' + PowerShellHostVersion = '7.4' + GUID = '11111111-1111-1111-1111-111111111111' + Tags = @() + ProjectUri = '' + } + Pester = [ordered]@{ + TestResult = [ordered]@{ + Enabled = $true + OutputFormat = 'NUnitXml' + } + Output = [ordered]@{ + Verbosity = 'Detailed' + } + } + } + + $json = $project | ConvertTo-Json -Depth 10 + Set-Content -LiteralPath (Join-Path $ProjectRoot 'project.json') -Value $json -Encoding utf8 +} + +function Invoke-BuildAndParsePsm1Ast { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProjectRoot + ) + + Push-Location -LiteralPath $ProjectRoot + try { + Invoke-MTBuild + + $info = Get-MTProjectInfo + $psm1 = Join-Path $ProjectRoot ("dist/{0}/{0}.psm1" -f $info.ProjectName) + if (-not (Test-Path -LiteralPath $psm1)) { + throw "Expected built psm1 not found: $psm1" + } + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($psm1, [ref]$tokens, [ref]$errors) + if ($errors -and $errors.Count -gt 0) { + throw "Built psm1 parse errors: $(@($errors | ForEach-Object Message) -join '; ')" + } + + return $ast + } + finally { + Pop-Location + } +} + +function Get-TopLevelFunctionAstFromAst { + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Management.Automation.Language.Ast]$Ast + ) + + $all = @($Ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $true)) + + $top = foreach ($candidate in $all) { + $nested = $false + foreach ($other in $all) { + if ($other -eq $candidate) { continue } + + if ($other.Extent.StartOffset -lt $candidate.Extent.StartOffset -and $other.Extent.EndOffset -gt $candidate.Extent.EndOffset) { + $nested = $true + break + } + } + + if (-not $nested) { $candidate } + } + + return @($top) +} + +function Write-TestMarkerPesterFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$FilePath, + [Parameter(Mandatory)][pscustomobject]$TestCase + ) + + $content = @" +Describe '$($TestCase.Name)' { + It 'imports built module and writes marker' { + Import-Module '$($TestCase.BuiltModulePath)' -Force + Get-Module -Name '$($TestCase.ProjectName)' | Should -Not -BeNullOrEmpty + Set-Content -LiteralPath '$($TestCase.MarkerPath)' -Value '$($TestCase.Name)' -Encoding utf8 -NoNewline + (Get-Content -LiteralPath '$($TestCase.MarkerPath)' -Raw) | Should -Be '$($TestCase.Name)' + } +} +"@ + + Set-Content -LiteralPath $FilePath -Value $content -Encoding utf8 +} + +function Invoke-TestProjectTests { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProjectRoot, + [Parameter(Mandatory)][string]$ModulePath + ) + + $scriptPath = Join-Path $ProjectRoot 'Run-InvokeMTTest.ps1' + $script = @" +`$ErrorActionPreference = 'Stop' +Import-Module '$ModulePath' -Force +Set-Location -LiteralPath '$ProjectRoot' +Invoke-MTBuild +Invoke-MTTest +"@ + + Set-Content -LiteralPath $scriptPath -Value $script -Encoding utf8 + + try { + $output = & pwsh -NoLogo -NoProfile -File $scriptPath 2>&1 + [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = @($output) + } + } + finally { + Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue + } +} + +function New-TestProjectWithMarkerTests { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$TestDriveRoot, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][bool]$BuildRecursiveFolders + ) + + $root = New-TestProjectRoot -TestDriveRoot $TestDriveRoot -Name $Name + $projectName = $Name + + Write-TestProjectJson -ProjectRoot $root -Options @{ + ProjectName = $projectName + BuildRecursiveFolders = $BuildRecursiveFolders + FailOnDuplicateFunctionNames = $false + } + + Set-Content -LiteralPath (Join-Path $root 'src/public/PublicTop.ps1') -Value 'function Invoke-PublicTop { }' -Encoding utf8 + + $topMarker = Join-Path $root 'top-level-ran.txt' + $nestedMarker = Join-Path $root 'nested-ran.txt' + $builtModulePath = Join-Path $root ("dist/{0}/{0}.psm1" -f $projectName) + + $topLevelTest = [pscustomobject]@{ + Name = 'TopLevel' + MarkerPath = $topMarker + ProjectName = $projectName + BuiltModulePath = $builtModulePath + } + $nestedTest = [pscustomobject]@{ + Name = 'Nested' + MarkerPath = $nestedMarker + ProjectName = $projectName + BuiltModulePath = $builtModulePath + } + + Write-TestMarkerPesterFile -FilePath (Join-Path $root 'tests/TopLevel.Tests.ps1') -TestCase $topLevelTest + Write-TestMarkerPesterFile -FilePath (Join-Path $root 'tests/nested/Nested.Tests.ps1') -TestCase $nestedTest + + [pscustomobject]@{ + Root = $root + TopMarker = $topMarker + NestedMarker = $nestedMarker + } +} + diff --git a/tests/BuildOptions.Tests.ps1 b/tests/BuildOptions.Tests.ps1 new file mode 100644 index 0000000..8a8f957 --- /dev/null +++ b/tests/BuildOptions.Tests.ps1 @@ -0,0 +1,118 @@ +BeforeAll { + . (Join-Path $PSScriptRoot 'BuildOptions.TestSupport.ps1') + + $here = Split-Path -Parent $PSCommandPath + $repoRoot = Split-Path -Parent $here + + $distModuleDir = Join-Path $repoRoot 'dist/ModuleTools' + if (-not (Test-Path -LiteralPath $distModuleDir)) { + throw "Expected built ModuleTools module at: $distModuleDir. Run Invoke-MTBuild in the repo root first." + } + + Remove-Module ModuleTools -ErrorAction SilentlyContinue + Import-Module $distModuleDir -Force +} + +Describe 'Invoke-MTBuild options' { + It 'BuildRecursiveFolders=false excludes nested classes/private and nested public' { + $root = New-TestProjectRoot -TestDriveRoot $TestDrive -Name 'NoRecurse' + Write-TestProjectJson -ProjectRoot $root -Options @{ ProjectName = 'NoRecurse'; BuildRecursiveFolders = $false; FailOnDuplicateFunctionNames = $false } + + Set-Content -LiteralPath (Join-Path $root 'src/classes/nested/Thing.ps1') -Value 'class NestedThing { [string]$Name }' -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'src/private/a/PrivateA.ps1') -Value 'function Invoke-NestedPrivateA { }' -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'src/public/nested/PublicNested.ps1') -Value 'function Invoke-NestedPublic { }' -Encoding utf8 + + $ast = Invoke-BuildAndParsePsm1Ast -ProjectRoot $root + + $typeNames = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.TypeDefinitionAst] }, $true) | ForEach-Object Name) + $typeNames | Should -Not -Contain 'NestedThing' + + $fnNames = @(Get-TopLevelFunctionAstFromAst -Ast $ast | ForEach-Object Name) + $fnNames | Should -Not -Contain 'Invoke-NestedPrivateA' + $fnNames | Should -Not -Contain 'Invoke-NestedPublic' + } + + It 'BuildRecursiveFolders=true includes nested classes/private but never nested public' { + $root = New-TestProjectRoot -TestDriveRoot $TestDrive -Name 'Recurse' + Write-TestProjectJson -ProjectRoot $root -Options @{ ProjectName = 'Recurse'; BuildRecursiveFolders = $true; FailOnDuplicateFunctionNames = $false } + + Set-Content -LiteralPath (Join-Path $root 'src/classes/nested/Thing.ps1') -Value 'class NestedThing { [string]$Name }' -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'src/private/a/PrivateA.ps1') -Value 'function Invoke-NestedPrivateA { }' -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'src/public/nested/PublicNested.ps1') -Value 'function Invoke-NestedPublic { }' -Encoding utf8 + + Set-Content -LiteralPath (Join-Path $root 'src/public/PublicTop.ps1') -Value 'function Invoke-PublicTop { }' -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'src/private/PrivateTop.ps1') -Value 'function Invoke-PrivateTop { }' -Encoding utf8 + + $ast = Invoke-BuildAndParsePsm1Ast -ProjectRoot $root + + $typeNames = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.TypeDefinitionAst] }, $true) | ForEach-Object Name) + $typeNames | Should -Contain 'NestedThing' + + $fn = @(Get-TopLevelFunctionAstFromAst -Ast $ast) + $fnNames = @($fn | ForEach-Object Name) + + $fnNames | Should -Contain 'Invoke-NestedPrivateA' + $fnNames | Should -Contain 'Invoke-PublicTop' + $fnNames | Should -Contain 'Invoke-PrivateTop' + $fnNames | Should -Not -Contain 'Invoke-NestedPublic' + + $classOffset = ($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.TypeDefinitionAst] -and $n.Name -eq 'NestedThing' }, $true) | Select-Object -First 1).Extent.StartOffset + $publicOffset = ($fn | Where-Object Name -eq 'Invoke-PublicTop' | Select-Object -First 1).Extent.StartOffset + $privateOffset = ($fn | Where-Object Name -eq 'Invoke-PrivateTop' | Select-Object -First 1).Extent.StartOffset + + $classOffset | Should -BeLessThan $publicOffset + $publicOffset | Should -BeLessThan $privateOffset + + # Deterministic sort within private: a/* comes before b/* + Set-Content -LiteralPath (Join-Path $root 'src/private/b/PrivateB.ps1') -Value 'function Invoke-NestedPrivateB { }' -Encoding utf8 + $ast2 = Invoke-BuildAndParsePsm1Ast -ProjectRoot $root + $fn2 = @(Get-TopLevelFunctionAstFromAst -Ast $ast2) + $aOffset = ($fn2 | Where-Object Name -eq 'Invoke-NestedPrivateA' | Select-Object -First 1).Extent.StartOffset + $bOffset = ($fn2 | Where-Object Name -eq 'Invoke-NestedPrivateB' | Select-Object -First 1).Extent.StartOffset + $aOffset | Should -BeLessThan $bOffset + } + + Context 'Invoke-MTTest discovery for BuildRecursiveFolders=' -ForEach @( + @{ Name = 'TestsTopOnly'; BuildRecursiveFolders = $false; ExpectedNestedMarker = $false } + @{ Name = 'TestsRecursive'; BuildRecursiveFolders = $true; ExpectedNestedMarker = $true } + ) { + It 'runs the expected set of top-level and nested tests' { + $project = New-TestProjectWithMarkerTests -TestDriveRoot $TestDrive -Name $_.Name -BuildRecursiveFolders $_.BuildRecursiveFolders + $result = Invoke-TestProjectTests -ProjectRoot $project.Root -ModulePath $distModuleDir + + $result.ExitCode | Should -Be 0 -Because ($result.Output -join [Environment]::NewLine) + (Test-Path -LiteralPath $project.TopMarker) | Should -BeTrue + (Test-Path -LiteralPath $project.NestedMarker) | Should -Be $_.ExpectedNestedMarker + } + } + + It 'FailOnDuplicateFunctionNames=true fails when built psm1 contains duplicate top-level function names' { + $root = New-TestProjectRoot -TestDriveRoot $TestDrive -Name 'DupFail' + Write-TestProjectJson -ProjectRoot $root -Options @{ ProjectName = 'DupFail'; BuildRecursiveFolders = $false; FailOnDuplicateFunctionNames = $true } + + Set-Content -LiteralPath (Join-Path $root 'src/public/Dup.ps1') -Value 'function Invoke-Dup { }' -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'src/private/Dup.ps1') -Value 'function Invoke-Dup { }' -Encoding utf8 + + { + Push-Location -LiteralPath $root + try { + Invoke-MTBuild + } + finally { + Pop-Location + } + } | Should -Throw + } + + It 'FailOnDuplicateFunctionNames=false allows duplicates (last wins) for backward compatibility' { + $root = New-TestProjectRoot -TestDriveRoot $TestDrive -Name 'DupAllowed' + Write-TestProjectJson -ProjectRoot $root -Options @{ ProjectName = 'DupAllowed'; BuildRecursiveFolders = $false; FailOnDuplicateFunctionNames = $false } + + Set-Content -LiteralPath (Join-Path $root 'src/public/Dup.ps1') -Value 'function Invoke-Dup { "first" }' -Encoding utf8 + Set-Content -LiteralPath (Join-Path $root 'src/private/Dup.ps1') -Value 'function Invoke-Dup { "second" }' -Encoding utf8 + + $ast = Invoke-BuildAndParsePsm1Ast -ProjectRoot $root + $fnNames = @(Get-TopLevelFunctionAstFromAst -Ast $ast | ForEach-Object Name) + $fnNames | Should -Contain 'Invoke-Dup' + } +}