Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
temp/
run.ps1
dist/
justfile
justfile
testResults.xml
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Project>/<Project>.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
Expand Down
66 changes: 54 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,23 @@ 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

```PowerShell
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

To ensure this module works correctly, you need to maintain the folder structure and the `project.json` file path. The best way to get started is by running the `New-MTModule` command, which guides you through a series of questions and creates the necessary scaffolding.

## 📂 Folder Structure

All the Module files should be in inside `src` folder
All module files should be inside the `src` folder.

```
 .
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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/<Project>/<Project>.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

Expand All @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/ModuleTools/Invoke-MTTest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion project.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
30 changes: 30 additions & 0 deletions src/private/AssertBuiltModuleHasNoDuplicateFunctionNames.ps1
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 4 additions & 18 deletions src/private/BuildModule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/private/FormatDuplicateFunctionErrorMessage.ps1
Original file line number Diff line number Diff line change
@@ -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")
}
12 changes: 12 additions & 0 deletions src/private/GetDuplicateFunctionGroup.ps1
Original file line number Diff line number Diff line change
@@ -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 }
)
}
24 changes: 24 additions & 0 deletions src/private/GetDuplicateFunctionSourceLine.ps1
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 22 additions & 0 deletions src/private/GetFunctionSourceIndex.ps1
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions src/private/GetNormalizedRelativePath.ps1
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions src/private/GetOrCreateHashtableList.ps1
Original file line number Diff line number Diff line change
@@ -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]
}
Loading