A PowerShell Scripting Solution for December 2024
At the end of last month, I left you with another PowerShell scripting challenge. It was admittedly a difficult challenge. But I gave you a few suggestions. And, if you have been keeping up with content this month, you should have recognized concepts that could be applied to the challenge. I can't stress enough the importance of working on these challenges as they will help you grow as a PowerShell scripter. Even if you think you can't complete it, work on it as much as possible. You learn by pushing the boundaries of what you know and trying new things. If these challenges are easy, then you're not learning anything new. I know that easy is a relative term.
So, let's see how I would approach this challenge.
The Challenge
The challenge was to write a PowerShell function or script that will create a JSON comment block for a .ps1 script file that looks like this:
{
"Date": "2024-12-17T17:19:22.1426831-05:00",
"Path": "C:\\scripts\\test-powershellrun.ps1",
"RequiredVersion": "7.4",
"RequiresAdmin": false,
"Commands": [
"Find-PSResource",
"Get-Module"
],
"Tags": [
"PowerShellRun",
"Profile"
]
}
The Commands
property should be a unique list of cmdlets and functions used in the script or function. For bonus points, you can exclude common cmdlets like Select-Object
, Where-Object
, etc.
At a minimum, I wanted you to create a PowerShell solution that creates a single JSON file for all files processed.
The real challenge is to append the JSON block for each file as a multi-line comment to the end of the corresponding script file.
Using the AST
I think the best approach to tackle this challenge is to use the PowerShell Abstract Syntax Tree (AST). The AST is a powerful tool that allows you to parse and analyze PowerShell code. I've written about it several times this month. As a proof-of-concept, I'm going to use this script file.
#requires -version 7.4
#requires -module Microsoft.PowerShell.PSResourceGet
#TestModuleUpdate.ps1
#a function to test if a module can be updated to a newer online version
function Test-ModuleUpdate {
[CmdletBinding()]
[OutputType('bool', 'ModuleUpdateInfo')]
Param (
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = 'The name of the module to check. The module should already be installed.'
)]
[ValidateNotNullOrEmpty()]
[string]$Name,
[switch]$Quiet
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
}
Process {
#get the most recent version of the module
#Sort on version to ensure the latest version is first
Write-Verbose "Getting the most recent installed version of $Name"
$module = Get-Module -Name $Name -ListAvailable |
Sort-Object -Property Version -Descending |
Select-Object -First 1
Write-Verbose "Found module version $($module.version)"
Write-Verbose "Finding $Name in the PowerShell Gallery"
$latest = Find-PSResource -Name $Name -Type Module
$DetailOutput = [ordered]@{
PSTypeName = 'ModuleUpdateInfo'
Name = $Name
InstalledVersion = $module.version
LatestVersion = $latest.version
PSEditions = $module.CompatiblePSEditions
ProjectUri = $latest.ProjectUri
}
if ($latest.version -gt $module.version) {
Write-Verbose "Update available for $Name"
$DetailOutput['Update'] = $True
#this should be handled in a formatting file
$DetailOutput['LatestVersion'] = "$($PSStyle.Foreground.Red)$($latest.version)$($PSStyle.Reset)"
}
Else {
$DetailOutput['Update'] = $False
#this should be handled in a formatting file
$DetailOutput['LatestVersion'] = "$($PSStyle.Foreground.Green)$($latest.version)$($PSStyle.Reset)"
}
if ($Quiet) {
$DetailOutput['Update']
}
else {
[PSCustomObject]$DetailOutput
}
}
End {
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
} #close function
I'll define a variable for the path and get the AST data.
$Path = 'C:\scripts\TestModuleUpdate.ps1'
New-Variable astTokens -Force
New-Variable astErr -Force
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
I can get script requirements:
$RequiredVersion = $AST.ScriptRequirements.RequiredPSVersion.toString()
$RequiresAdmin = $AST.ScriptRequirements.IsElevationRequired
$RequiredModules = $AST.ScriptRequirements.RequiredModules.Name
To exclude common files, I'm going to create a text list.
#commands to exclude from command metadata
Get-Date
Select-Object
Where-Object
ForEach-Object
Sort-Object
Group-Object
Measure-Object
Write-Verbose
Write-Debug
I'll parse the file to only get the commands:
$CmdExclude = Get-Content -Path C:\scripts\cmdexclude.txt | where {$_ -NotMatch "^#" -AND $_ -match "\w+"}
Next, I can use the AST to get the commands:
$cmdGroup = $astTokens.where({ $_.TokenFlags -eq 'CommandName' -eq 'Identifier' -AND $_.Value -NotIn $cmdExclude }) | Group-Object Text
$commands = $cmdGroup.Name
I'll define a few tags:
$tags = 'Module', 'PSResource'
And finally create an object that I can convert to JSON:
$meta = [PSCustomObject]@{
Date = Get-Date
Path = (Convert-Path $Path)
RequiredVersion = $RequiredVersion
RequiresAdmin = $RequiresAdmin
RequiredModules = $RequiredModules
Commands = $commands
Tags = $Tags
} | ConvertTo-Json
This creates the desired result:
{
"Date": "2025-01-25T17:13:50.8183216-05:00",
"Path": "C:\\scripts\\Test-ModuleUpdate.ps1",
"RequiredVersion": "7.4",
"RequiresAdmin": false,
"RequiredModules": "Microsoft.PowerShell.PSResourceGet",
"Commands": [
"Find-PSResource",
"Get-Module"
],
"Tags": [
"Module",
"PSResource"
]
}
I can put this into a function and process multiple files.
Function New-ScriptMetadata {
[cmdletbinding()]
[OutputType('PSScriptMetadata')]
Param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
HelpMessage = 'The path to the .PS1 script file'
)]
[ValidatePattern('.*\.ps1$')]
[ValidateScript({ Test-Path $_ })]
[string]$Path,
[string[]]$Tags
)
Begin {
$CmdExclude = Get-Content -Path C:\scripts\CmdExclude.txt | Where-Object {$_ -NotMatch "^#" -AND $_ -match "\w+"}
New-Variable astTokens -Force
New-Variable astErr -Force
} #begin
Process {
#convert to a file system path
$Path = Convert-Path $Path
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
$RequiredVersion = $AST.ScriptRequirements.RequiredPSVersion
$RequiresAdmin = $AST.ScriptRequirements.IsElevationRequired
$RequiredModules = $AST.ScriptRequirements.RequiredModules.Name
$cmdGroup = $astTokens.where({ $_.TokenFlags -eq 'CommandName' -AND $_.Value -NotIn $CmdExclude }) |
Group-Object Text
$commands = $cmdGroup.Name
[PSCustomObject]@{
PSTypeName = 'PSScriptMetadata'
Date = Get-Date
Path = (Convert-Path $Path)
RequiredVersion = $RequiredVersion -as [string]
RequiresAdmin = $RequiresAdmin
RequiredModules = $RequiredModules
Commands = $commands
Tags = $Tags
}
} #process
End {
#not used
} #end
}
Here's a test of the function:

Because I want to tag, I'll process files in small chunks:
PS C:\Scripts> dir c:\scripts\*bsky*.ps1 | New-ScriptMetadata -Tags Bluesky | ConvertTo-Json | Out-File c:\temp\bskyscripts.json
Which give me sample JSON like this:
{
"Date": "2025-01-26T11:06:43.1086802-05:00",
"Path": "C:\\scripts\\bsky-taskbar.ps1",
"RequiredVersion": "7.4",
"RequiresAdmin": false,
"RequiredModules": [
"PSBluesky",
"PoshTaskbarItem"
],
"Commands": [
"C:\\scripts\\LoadBsky.ps1",
"Clear-TaskbarItemOverlay",
"explorer",
"Get-BskyNotification",
"New-TaskbarItem",
"New-TaskbarItemShortcut",
"Set-TaskbarItemDescription",
"Set-TaskbarItemOverlayBadge",
"Set-TaskbarItemTimerFunction",
"Show-TaskbarItem",
"Start-BSkySession",
"Test-Path"
],
"Tags": [
"Bluesky"
]
},
Appending to the File
It doesn't take that much more work to create a metadata comment block and append it to the end of the file.
dir c:\scripts\*bsky*.ps1 -pipelineVariable pv | Foreach-Object {
$meta = New-ScriptMetadata $_.FullName -Tags Bluesky | ConvertTo-Json
#create a comment block with the metadata
$metaComment = @"
<#
This is script metadata - please do not remove.
$meta
#>
"@
$metaComment | Out-File -FilePath $pv.fullName -APPEND
}
Don't forget to append or you'll end up overwriting the file and have to restore from backup. Yes, that was me.
Reading the Metadata
If you've appended the metadata to the file, you need a command to read it. Originally, I was using regular expressions to parse the file for the JSON comment block. However, this quickly became a difficult approach to take. Trying to match a multi-line pattern is complicated, and becomes even more so the larger the file.
I decided to abandon the regex approach and went another direction using a generic list. This allows me to read the file and create a list of lines. The list object has methods to let me find text. I'm going to test with this file:
$Path = "C:\scripts\Test-ModuleUpdate.ps1"
$content = Get-Content -Path $Path
I'm searching for this comment block at the end of the file:
<#
This is script metadata - please do not remove.
{
"Date": "2025-01-25T17:17:30.7953744-05:00",
"Path": "C:\\scripts\\Test-ModuleUpdate.ps1",
"RequiredVersion": "7.4",
"RequiresAdmin": false,
"RequiredModules": "Microsoft.PowerShell.PSResourceGet",
"Commands": [
"Find-PSResource",
"Get-Module"
],
"Tags": [
"PSResource",
"module",
"PowerShellGet"
]
}
#>