Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ release notes.
The tags counted are the ones the Gallery lists, the manifest's plus `PSModule`, the editions and two per
exported command, so a new command costs about twice its name. The module contract test holds the same
three limits, so a pull request fails before a release does.
- `Test-IntuneScript` runs about two and a half times faster: 91 ms a script against 233 ms for a 95-line
detection, 60 scripts in 5.4 s against 14.0 s. Every rule walked the syntax tree itself, some once per
command name they look for; the tree is now walked once per script and the nodes indexed by type and
the commands by name, and the rules read the index. Findings are unchanged.
## [0.27.0] - 2026-10-05

Fixes to the runtime harness and to four rules, each rule change backed by a tenth validation
Expand Down
94 changes: 81 additions & 13 deletions Private/Find-IslAstNode.ps1
Original file line number Diff line number Diff line change
@@ -1,14 +1,79 @@
# AST helpers shared by the rules. Type names are compared as strings so the module loads on
# Windows PowerShell 5.1, where the PowerShell 7 node types (ternary, pipeline chain) don't exist.

# One walk per tree, shared by every rule that asks about it: the nodes grouped by type name and
# the commands grouped by name. Fifteen rules each walking the tree, some of them once per command
# name they look for, was most of the time an analysis took. The table is keyed on the AST object
# itself, so a sub-tree a caller passes gets an index of its own and a tree that goes out of scope
# takes its index with it.
$script:IslAstIndex = [System.Runtime.CompilerServices.ConditionalWeakTable[object, object]]::new()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ConditionalWeakTable rather than a hashtable so the index does not keep a tree alive: Test-IntuneScript parses thousands of scripts in a CI run and each tree would otherwise stay in memory with its index for the life of the module.


# The walk and the grouping stay in .NET: FindAll calls its predicate once per node, and a script
# block there costs more than the walk itself; BlockingCollection.TryAdd is a Func<Ast, bool> that
# is always true and keeps the nodes in the order they were visited. Object.GetType as an open
# delegate is the key selector for Enumerable.ToLookup, which keeps each group in source order.
$script:IslAstNodeType = [System.Management.Automation.Language.Ast]
$script:IslAstCollectorType =
[System.Collections.Concurrent.BlockingCollection[System.Management.Automation.Language.Ast]]
$script:IslAstGetType = [System.Delegate]::CreateDelegate(
[System.Func[System.Management.Automation.Language.Ast, type]], [object].GetMethod('GetType'))
$script:IslAstToLookup = ([System.Linq.Enumerable].GetMethods() |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflection because Windows PowerShell 5.1 has no syntax for calling a generic method with explicit type arguments; the MethodInfo is resolved once at load and reused.

Where-Object { $_.Name -eq 'ToLookup' -and $_.GetParameters().Count -eq 2 } |
Select-Object -First 1).MakeGenericMethod($script:IslAstNodeType, [type])

function Get-IslAstIndex {
<#
.SYNOPSIS
The node and command index of an AST, built on first use and kept for the tree's lifetime.
#>
[CmdletBinding()]
[OutputType('IntuneScriptLab.AstIndex')]
param(
[Parameter(Mandatory)]
[System.Management.Automation.Language.Ast]$Ast
)
$index = $null
if ($script:IslAstIndex.TryGetValue($Ast, [ref]$index)) { return $index }

# FindAll visits the tree in document order, the root first, and that order is what the groups keep
$collector = $script:IslAstCollectorType::new()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BlockingCollection is the first .NET collection whose add method returns bool and keeps insertion order (ConcurrentQueue underneath). HashSet.Add also returns true but its enumeration order is not guaranteed. The ordering was checked against FindAll on both hosts for a 767-node tree.

$collect = [System.Delegate]::CreateDelegate([System.Func[System.Management.Automation.Language.Ast, bool]],
$collector, $script:IslAstCollectorType.GetMethod('TryAdd', [type[]]@($script:IslAstNodeType)))
$null = $Ast.FindAll($collect, $true)
$lookup = $script:IslAstToLookup.Invoke($null, @([object]$collector.ToArray(), $script:IslAstGetType))

# Keyed by the type's short name, the way the rules ask: a 7-only node type is a key that is
# never there on 5.1, not a type that fails to resolve
$byType = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal)
foreach ($group in $lookup) { $byType[$group.Key.Name] = $group }
$byCommand = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[object]]]::new(
[System.StringComparer]::OrdinalIgnoreCase)
if ($byType.ContainsKey('CommandAst')) {
foreach ($command in $byType['CommandAst']) {
# A command whose name is not a constant (& $tool, "$prefix-Item") has no name to index
$commandName = $command.GetCommandName()
if (-not $commandName) { continue }
if (-not $byCommand.ContainsKey($commandName)) {
$byCommand[$commandName] = [System.Collections.Generic.List[object]]::new()
}
$byCommand[$commandName].Add($command)
}
}
$index = [pscustomobject]@{
PSTypeName = 'IntuneScriptLab.AstIndex'
ByType = $byType
ByCommand = $byCommand
}
$script:IslAstIndex.Add($Ast, $index)
$index
}

function Find-IslAstNode {
<#
.SYNOPSIS
Finds AST nodes by type name, optionally filtered by a predicate.
#>
[CmdletBinding()]
# The parameters are used inside the FindAll predicate; the analyzer can't see through it
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
param(
[Parameter(Mandatory)]
[System.Management.Automation.Language.Ast]$Ast,
Expand All @@ -19,12 +84,15 @@ function Find-IslAstNode {

[scriptblock]$Where
)
$Ast.FindAll({
param($node)
if ($node.GetType().Name -notin $TypeName) { return $false }
if ($Where) { return [bool](& $Where $node) }
$true
}, $true)
$index = Get-IslAstIndex -Ast $Ast
$nodes = foreach ($name in $TypeName) {
if ($index.ByType.ContainsKey($name)) { $index.ByType[$name] }
}
# Each type's list is in document order; several types are merged back into it
if ($TypeName.Count -gt 1) { $nodes = $nodes | Sort-Object -Property { $_.Extent.StartOffset } }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the three callers that ask for several types pay for the sort; a single type comes straight from its group in document order, which is the common case.

foreach ($node in $nodes) {
if (-not $Where -or [bool](& $Where $node)) { $node }
}
}

function Find-IslCommand {
Expand All @@ -33,19 +101,19 @@ function Find-IslCommand {
Finds command invocations by name (case-insensitive), including aliases the caller lists.
#>
[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
param(
[Parameter(Mandatory)]
[System.Management.Automation.Language.Ast]$Ast,

[Parameter(Mandatory)]
[string[]]$Name
)
Find-IslAstNode -Ast $Ast -TypeName CommandAst -Where {
param($node)
$commandName = $node.GetCommandName()
$commandName -and $commandName -in $Name
$index = Get-IslAstIndex -Ast $Ast
$commands = foreach ($commandName in $Name) {
if ($index.ByCommand.ContainsKey($commandName)) { $index.ByCommand[$commandName] }
}
if ($Name.Count -gt 1) { $commands = $commands | Sort-Object -Property { $_.Extent.StartOffset } }
$commands
}

function Test-IslCommandParameter {
Expand Down
26 changes: 25 additions & 1 deletion Tests/Unit/Private/Find-IslAstNode.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,26 @@ Describe 'Find-IslAstNode' -Tag 'Unit', 'Private' {
}
}

It 'returns nodes of several types in document order, and the same tree is indexed once' {
InModuleScope IntuneScriptLab -Parameters @{ Ast = $script:Ast } {
$nodes = @(Find-IslAstNode -Ast $Ast -TypeName ExitStatementAst, FunctionDefinitionAst)
$nodes.Count | Should-Be 4
$nodes[0].GetType().Name | Should-Be 'FunctionDefinitionAst'
$offsets = @($nodes | ForEach-Object { $_.Extent.StartOffset })
$offsets | Should-BeCollection @($offsets | Sort-Object)
$first = Get-IslAstIndex -Ast $Ast
$second = Get-IslAstIndex -Ast $Ast
[object]::ReferenceEquals($first, $second) | Should-BeTrue
# The index lists what FindAll finds, in the order FindAll finds it
$walked = @($Ast.FindAll({ param($node) $node.GetType().Name -eq 'CommandAst' }, $true))
$indexed = @($first.ByType['CommandAst'])
$indexed.Count | Should-Be $walked.Count
for ($i = 0; $i -lt $walked.Count; $i++) {
[object]::ReferenceEquals($walked[$i], $indexed[$i]) | Should-BeTrue
}
}
}

It 'returns nothing for a type that is not in the tree' {
InModuleScope IntuneScriptLab -Parameters @{ Ast = $script:Ast } {
@(Find-IslAstNode -Ast $Ast -TypeName TernaryExpressionAst).Count | Should-Be 0
Expand All @@ -48,7 +68,11 @@ Describe 'Find-IslCommand' -Tag 'Unit', 'Private' {
It 'matches command names case-insensitively, any of several' {
InModuleScope IntuneScriptLab -Parameters @{ Ast = $script:Ast } {
@(Find-IslCommand -Ast $Ast -Name 'get-item').Count | Should-Be 2
@(Find-IslCommand -Ast $Ast -Name 'Get-Item', 'Write-Output').Count | Should-Be 3
$several = @(Find-IslCommand -Ast $Ast -Name 'Write-Output', 'Get-Item')
$several.Count | Should-Be 3
# Document order whatever the order of the names asked for
$names = @($several | ForEach-Object { $_.GetCommandName() })
$names | Should-BeCollection @('Get-Item', 'Get-Item', 'Write-Output')
@(Find-IslCommand -Ast $Ast -Name 'Remove-Item').Count | Should-Be 0
}
}
Expand Down
Loading