Skip to content

perf(analysis): one walk of the syntax tree per script, indexed by node type and command name - #17

Merged
fadwen merged 1 commit into
fix/repair-passes-analysis-optionsfrom
perf/ast-index
Oct 6, 2026
Merged

fadwen merged 1 commit into
fix/repair-passes-analysis-optionsfrom
perf/ast-index

Conversation

@fadwen

@fadwen fadwen commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Summary

Every rule walked the syntax tree on its own through Find-IslAstNode and Find-IslCommand, and the rules that look for several command names walked it once per name: about forty walks for a script, at about 11 ms each for a 95-line detection. That was most of what an analysis cost. The tree is now walked once per script and indexed, and the rules read the index. Findings are unchanged.

Before After
One 95-line detection, warm 233 ms 91 ms
60 such scripts through Test-IntuneScript 14.0 s 5.4 s
Findings on those 60 360 360
Heaviest rule, IslPowerShell7Syntax 77 ms 19 ms

Stacked on #16 (base branch); the diff is this change only. Merge #16 first.

Changes

  • Private/Find-IslAstNode.ps1. Get-IslAstIndex walks a tree once, on first use, and keeps the nodes grouped by type name and the commands by name for the tree's lifetime, in a ConditionalWeakTable keyed on the AST object. Find-IslAstNode and Find-IslCommand read it; their parameters, results and result order are unchanged, so no rule changes. The walk and the grouping stay in .NET: FindAll takes BlockingCollection.TryAdd as its always-true predicate, and Enumerable.ToLookup groups the nodes with Object.GetType as the key selector, because a script block called once per node cost more than the walk itself (8 ms for the walk, 21 ms for a grouping loop in PowerShell; 5 ms for both in .NET).
  • Tests. Several types come back in document order and the same tree is indexed once; the index holds what FindAll finds, in FindAll's order; several command names come back in document order whatever the order asked for.

Verification

  • Unit and integration suites pass on PowerShell 7.6.6; the helper, rule, Test-IntuneScript and Repair-IntuneScript tests pass on Windows PowerShell 5.1 (152 of 152). PSScriptAnalyzer (Error and Warning) is clean; no line over 115 characters.
  • The node order of the index was compared against FindAll on both hosts for the benchmark script: identical, 767 nodes.

@fadwen fadwen left a comment

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.

Notes on the lines whose reason the diff does not show.

# 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.

[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.

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.

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.

…de type and command name

Every rule walked the syntax tree on its own through Find-IslAstNode and
Find-IslCommand, and the rules that look for several command names
walked it once per name: about forty walks for a script, at about 11 ms
each for a 95-line detection. That was most of what an analysis cost.

Get-IslAstIndex walks a tree once, on first use, and keeps the nodes
grouped by type name and the commands by name for the tree's lifetime,
in a ConditionalWeakTable keyed on the AST object. Find-IslAstNode and
Find-IslCommand read the index; their parameters, results and result
order are unchanged, so no rule changes. The walk and the grouping stay
in .NET: FindAll takes a BlockingCollection.TryAdd delegate as its
always-true predicate, and Enumerable.ToLookup groups the nodes with
Object.GetType as the key selector, because a PowerShell script block
called once per node cost more than the walk itself.

Measured on a 95-line detection, warm: 91 ms a script against 233 ms,
60 scripts in 5.4 s against 14.0 s, the same 360 findings. The index
itself takes 5 ms.
@fadwen
fadwen added this pull request to stack #24 October 6, 2026 16:17
@fadwen
fadwen merged commit 836f806 into main Oct 6, 2026
4 checks passed
@fadwen
fadwen deleted the perf/ast-index branch October 6, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant