PowerShellで、PowerShellの解析ツールを作る ── 正規表現ではなく、ASTでスクリプトを読む
· 更新日: · 小村 豪 · PowerShell, AST, 静的解析, 開発ツール
更新履歴(初版のみ・2026年09月13日公開)
- 初版公開
PowerShellのスクリプトをまとめて調べて、Write-Hostを呼んでいる場所を一覧にしたいとします。単語を検索するだけなら簡単ですが、次の3行が全部引っ掛かります。
$source = @'
# Write-Host は画面への表示に使う
$message = 'Write-Host'
Write-Host 'こんにちは'
'@
探したいのは3行目だけです。1行目はコメント、2行目は変数に代入する文字列なので、どちらもWrite-Hostを呼んではいません。
PowerShell自身は、この違いを読み分けてコードを実行しています。その読み分けた結果を、こちらから取り出すこともできます。今回はコードを構文解析し、できたオブジェクトの中身を調べるところから始めます。追加モジュールは使いません。1
上の@' ... '@は単一引用符のヒアストリングです。中の3行を今は実行せず、$messageも展開しない文字列として、$sourceに入れています。2
1. パーサーが返すものを表示する
まず、$sourceをParseInputへ渡します。
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput(
$source, [ref] $tokens, [ref] $parseErrors)
if ($parseErrors.Count -gt 0) {
throw $parseErrors[0].Message
}
$ast.GetType().Name
ScriptBlockAst
$astに入ったのは、文字列でも、スクリプトの実行結果でもなく、ScriptBlockAst型のオブジェクトです。これが入力したコード全体を表しています。ParseInputは、戻り値としてこのオブジェクトを返すほか、[ref]で渡した変数へトークンと構文エラーを返します。1
ASTは「抽象構文木」の略です。名前から特別なデータ構造を想像するかもしれませんが、PowerShellから見ると、まずはプロパティとメソッドを持ったオブジェクトです。そのプロパティをたどると、代入文やコマンド呼び出しを表す別のオブジェクトが出てきます。3
2. 3行のコードは、どんなオブジェクトになったか
今回のようにbegin・process・endを明示していないコードでは、通常の文はEndBlock.Statementsに入っています。種類と元のコードを並べてみます。4
$statements = $ast.EndBlock.Statements
$statements | ForEach-Object {
[pscustomobject]@{
Type = $_.GetType().Name
Text = $_.Extent.Text
}
}
Type Text
---- ----
AssignmentStatementAst $message = 'Write-Host'
PipelineAst Write-Host 'こんにちは'
2件です。コメントの行は、この文の一覧には入りません。コメント自体が必要なら、先ほど受け取った$tokensのほうから取得できます。3
2行目は代入を表すAssignmentStatementAstになりました。3行目はPipelineAstです。|を書いていなくても、コマンドが一つだけのパイプラインとして表されます。Extent.Textは、そのオブジェクトに対応する元のコードです。型名だけで分からなくなったときも、これを見ればどの部分の話か確かめられます。56
flowchart TB
accTitle: 元のコードと構文オブジェクトの対応
accDescr: スクリプト全体のEndBlockに、2行目の代入と3行目のパイプラインが入り、後者にコマンド呼び出しが含まれます。
R["ScriptBlockAst"] --> E["EndBlock"]
E --> A["代入文:2行目"]
E --> P["パイプライン:3行目"]
P --> C["CommandAst"]
図1: 3行目の呼び出しは、パイプラインの中にあります。
配列の添字は0からなので、$statements[1]が3行目のパイプラインです。そこから、先頭の要素を取り出します。
$pipeline = $statements[1]
$call = $pipeline.PipelineElements[0]
$call.GetType().Name
$call.Extent.Text
$call.GetCommandName()
CommandAst
Write-Host 'こんにちは'
Write-Host
CommandAstが見つかりました。元のコードを返すExtent.Textと、呼び出し名を返すGetCommandName()を、それぞれ同じオブジェクトに対して使っています。7
引数も含めた内訳は、CommandElementsに入っています。
$call.CommandElements | ForEach-Object {
[pscustomobject]@{
Type = $_.GetType().Name
Text = $_.Extent.Text
}
}
Type Text
---- ----
StringConstantExpressionAst Write-Host
StringConstantExpressionAst 'こんにちは'
名前と引数は、どちらも文字列を表すノードです。それでもGetCommandName()が返すのはWrite-Hostのほうです。文字列の綴りだけを見て選んでいるのではなく、コマンド呼び出しの中で、名前に当たる要素を見ているからです。2行目の'Write-Host'は代入文の右辺にあり、そもそもこの呼び出しには属していません。78
これが、文字列検索と構文解析の違いです。同じ単語でも、それが入っている構造を調べれば用途を区別できます。
3. 添字でたどる代わりに、CommandAstを探す
中身は確認できました。ただ、実際のスクリプトで「2件目の文の先頭要素」と決め打ちするわけにはいきません。呼び出しは関数の中にも、ifの中にも、パイプラインの途中にもあります。
そのための検索メソッドがFindAllです。
$commands = @($ast.FindAll({
param($node)
$node -is [System.Management.Automation.Language.CommandAst]
}, $true))
$commands.Count
$commands[0].GetType().Name
$commands[0].Extent.Text
1
CommandAst
Write-Host 'こんにちは'
FindAllは構文木をたどり、各ノードを{ ... }の判定処理へ渡します。渡された$nodeの型を-isで調べ、CommandAstなら$trueを返す。そのノードが検索結果に残ります。最後の$trueは、入れ子の関数やスクリプトブロックの中も探す指定です。9
たとえばfunction Show-Message { Write-Host 'hello' }でも、関数の中までたどって呼び出しを取得します。関数を実行する必要はありません。
flowchart TB
accTitle: 関数の中まで型を調べて検索する
accDescr: スクリプトに含まれる関数の本体までたどり、CommandAstに一致する呼び出しを検索結果へ含めます。
R["スクリプト"] --> F["関数の定義"]
F --> B["関数の本体"]
B --> C["CommandAst"]
C --> M["型が一致:結果に残る"]
図2: 入れ子の深さをこちらで数えなくても、検索条件に合うノードを取得できます。
冒頭の3行では、添字でたどったものと同じ呼び出しが1件見つかりました。名前と位置を取り出せば、検索結果として使えます。
$commands | ForEach-Object {
[pscustomobject]@{
Name = $_.GetCommandName()
Line = $_.Extent.StartLineNumber
Column = $_.Extent.StartColumnNumber
}
}
Name Line Column
---- ---- ------
Write-Host 3 1
行と列は1始まりです。名前も位置も同じノードが持っているため、後から別の文字列検索で行を探し直す必要はありません。6
4. 変数経由の呼び出しはどう見えるか
ここで、解析対象を少し変えます。名前を直接書く場合に加え、呼び出し演算子&に文字列や変数を渡す場合も入れます。
$source = @'
Write-Host 'direct'
& 'Write-Host' 'quoted'
$command = 'Write-Host'
& $command 'variable'
'@
$ast = [System.Management.Automation.Language.Parser]::ParseInput(
$source, [ref] $tokens, [ref] $parseErrors)
if ($parseErrors.Count -gt 0) { throw $parseErrors[0].Message }
$commands = @($ast.FindAll({
param($node)
$node -is [System.Management.Automation.Language.CommandAst]
}, $true))
$commands | ForEach-Object {
[pscustomobject]@{
Line = $_.Extent.StartLineNumber
Name = $_.GetCommandName()
Text = $_.Extent.Text
}
}
Line Name Text
---- ---- ----
1 Write-Host Write-Host 'direct'
2 Write-Host & 'Write-Host' 'quoted'
4 & $command 'variable'
4行目もCommandAstとして見つかっています。ただし、GetCommandName()の戻り値は$nullです。
人間がこの4行を読めば、直前の代入から$commandの値を推測できます。しかし、このメソッドは変数への代入をさかのぼって値を計算しません。コードに名前が直接書かれている場合と違い、このAPIだけでは名前を取り出せないのです。7
flowchart TB
accTitle: 同じ呼び出しノードでも名前の取得結果が違う
accDescr: 名前が文字列の呼び出しではWrite-Hostを取得でき、変数の呼び出しでは名前がnullになりますが、両方とも呼び出しの位置を持ちます。
C["CommandAst"] --> L["名前の要素が文字列"]
C --> V["名前の要素が変数"]
L --> N["名前:Write-Host"]
V --> U["名前:null"]
図3: 名前が空欄でも、4行目に呼び出しが書かれていることは分かります。
ファイルを調べるツールでは、この違いをNameKind列に入れておきます。名前の文字列を取得できた行はStatic、取得できなかった行はUnresolvedです。名前のない行も残しておけば、人が確認すべき場所を取りこぼさずに済みます。
5. ファイルからWrite-Hostの場所を取り出す
文字列ではなく.ps1を読むときは、ParseInputの代わりにParseFileを使います。ASTの読み方は変わりません。10
これまでの処理をまとめたGet-ScriptCommandを、記事末尾の完成コードと配布サンプルに用意しています。完成コードをGet-ScriptCommand.ps1として保存し、4章のヒアストリングの内側の4行をdemo.ps1として同じフォルダーに保存してください。配布サンプルには、この2ファイルが入っています。
そのフォルダーで実行します。
. .\Get-ScriptCommand.ps1
$calls = @(Get-ScriptCommand -LiteralPath .\demo.ps1)
$calls |
Where-Object { $_.NameKind -eq 'Static' -and $_.Name -eq 'Write-Host' } |
Format-Table Line, Column, NameKind, Name -AutoSize
Line Column NameKind Name
---- ------ -------- ----
1 1 Static Write-Host
2 1 Static Write-Host
これで、Write-Hostを名前に持つ呼び出しの場所が出せました。3行目の代入は入りません。冒頭で困っていた、コメントや普通の文字列が検索結果に混ざる問題を避けられています。
一方、4行目はこの絞り込みから外れます。そこに呼び出しがないのではなく、名前が未確定だからです。未確定の行は別に確認します。
$calls |
Where-Object NameKind -eq 'Unresolved' |
Format-Table Line, Column, NameKind, Name -AutoSize
Line Column NameKind Name
---- ------ -------- ----
4 1 Unresolved
Staticは「名前の文字列を取得できた」という印であって、そのコマンドが実在することや、どの実装が呼ばれるかの保証ではありません。たとえばechoはechoのまま、Microsoft.PowerShell.Utility\Write-Hostは修飾付きの名前のまま返るので、上の完全一致検索には入りません。別名や修飾付き呼び出しも調査対象なら、検索条件をそれに合わせる必要があります。11
なお、ドットソースしたのは解析ツールのほうです。demo.ps1はParseFileで読むだけで、起動していません。
6. 検索結果は、実行履歴ではない
この検索は「コードにどう書かれているか」を調べています。使われない関数の中や、if ($false) { Write-Host ... }の中にも呼び出しは書かれているので、一覧に入ります。実行順序や回数は分かりません。
文字列についても、'Write-Host'と"Today: $(Get-Date)"は扱いが違います。後者には式が埋め込まれているため、その内側のGet-Dateは見つかります。一方、普通の文字列に入っているコードを別のスクリプトとして再解析することはありません。2
[Console]::WriteLine(...)のような.NETメソッド呼び出しは、CommandAstとは別の種類のノードです。この一覧は全処理を網羅するものでも、安全性を証明するものでもありません。自分が管理するスクリプトの調査に使う道具です。実行時の別名や同名関数を確かめるには、そのスクリプトが動く環境も必要になります。1211
今回使った仕組みの中心は、GetType()で確かめたオブジェクトとExtent.Textです。別の構文を調べたくなったときも、短いコードをParseInputへ渡して、この二つを並べてみるところから始められます。既存ルールで品質を検査する目的なら、PSScriptAnalyzerのほうを使うとよいでしょう。
ファイル検索用の完成コード
以下は、5章で使ったGet-ScriptCommandの全体です。構文を読む部分は3章と同じで、その外側にファイルの取得、構文エラーの処理、複数ファイルの入力を加えています。
function Get-ScriptCommand {
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[ValidateNotNullOrEmpty()]
[string[]] $LiteralPath
)
process {
foreach ($path in $LiteralPath) {
$file = Get-Item -LiteralPath $path -Force -ErrorAction Stop
if ($file -isnot [System.IO.FileInfo]) {
throw "A file is required: $path"
}
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$file.FullName, [ref] $tokens, [ref] $parseErrors)
if ($parseErrors.Count -gt 0) {
$first = $parseErrors[0]
throw ('Parse error: {0}:{1}:{2} ({3})' -f $file.FullName,
$first.Extent.StartLineNumber,
$first.Extent.StartColumnNumber, $first.ErrorId)
}
$commands = $ast.FindAll({
param($node)
$node -is [System.Management.Automation.Language.CommandAst]
}, $true)
foreach ($command in ($commands | Sort-Object { $_.Extent.StartOffset })) {
$name = $command.GetCommandName()
$kind = if ($null -eq $name) { 'Unresolved' } else { 'Static' }
[pscustomobject]@{
Path = $file.FullName
Line = $command.Extent.StartLineNumber
Column = $command.Extent.StartColumnNumber
NameKind = $kind
Name = $name
}
}
}
}
}
Get-Item -LiteralPathで実際のファイルを取得してから、FullNameをParseFileへ渡します。draft[1].ps1のような名前もワイルドカードとして扱いません。ディレクトリーと存在しないファイルはエラーにします。
構文エラーがあれば、そのファイルの結果を出す前に停止します。ASTが一部返ったことをもって解析成功とはしません。正常に読めて0件だった場合と、読めなかった場合を分けるためです。
戻り値は表に整形せず、Path・Line・Column・NameKind・Nameを持つオブジェクトにしています。5章のように絞り込むほか、複数ファイルの結果をCSVに保存できます。FullNameというプロパティ名でも入力を受けるので、Get-ChildItemが返すFileInfoをそのまま渡せます。
Get-ChildItem -LiteralPath .\scripts -Filter *.ps1 -File -Recurse |
Get-ScriptCommand |
Export-Csv -LiteralPath .\commands.csv -NoTypeInformation -Encoding UTF8 -NoClobber
-NoClobberは既存のCSVへの上書きを防ぐ指定です。途中のファイルで失敗した場合、それ以前の結果は既にCSVへ流れていることがあります。ファイルができたことだけで全件成功とは判断せず、エラーも確認してください。
解析する文法は、ツールを動かしているPowerShellのバージョンに従います。7系で構文解析に成功しても、5.1で実行できるとは限りません。5.1でも日本語のファイルを読む場合は、UTF-8 BOM付きなど、文字コードにも配慮してください。13
サンプルと検証
解析ツール・サンプル・テスト一式(ZIP)には、完成版の関数、解析対象の例、Pesterテストを収録しています。関数本体は掲載コードと同じで、配布版にはヘルプコメントを付けています。ZIPを取得できない場合も、上の完成コードを保存して使えます。
完成版の関数と既存26ケースのPesterテストは前稿から変更していません。改稿時には、本文から抽出した12個のPowerShellコードブロックをWindows PowerShell 5.1とPowerShell 7系で実行し、型名・元のコード・呼び出し位置・絞り込み結果を照合しました。詳しいバージョンと検証範囲は、配布サンプルのREADMEに記載しています。
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全5件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
関連記事
参考リンク
-
Microsoft Learn, Parser.ParseInput Method. 文字列からASTを返し、トークンと構文エラーを出力引数に返すAPIについて。 ↩ ↩2
-
Microsoft Learn, about_Quoting_Rules. 単一引用符のヒアストリングと、展開可能文字列の部分式について。 ↩ ↩2
-
Microsoft PowerShell Team, Using abstract syntax trees (ASTs) with ISE to make scripting more productive. PowerShellから構文木へアクセスし、関数定義などのノードを検索する方法について。 ↩ ↩2
-
Microsoft Learn, NamedBlockAst Class. 名前を明示しないブロックと、文を保持するStatementsについて。 ↩
-
Microsoft Learn, PipelineAst.PipelineElements Property. パイプラインを構成する要素について。 ↩
-
Microsoft Learn, IScriptExtent Interface. ソースの範囲、開始位置、行・列が1始まりであることについて。 ↩ ↩2
-
Microsoft Learn, CommandAst.GetCommandName Method. 静的に名前を得られない呼び出しではnullが返ることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, CommandAst.CommandElements Property. 呼び出し名と引数などの構文要素について。 ↩
-
Microsoft Learn, Ast.FindAll Method. 条件に一致するノードの走査と、入れ子の関数・スクリプトブロックを探す指定について。 ↩
-
Microsoft Learn, Parser.ParseFile Method. ファイルを解析してAST、トークン、構文エラーを得るAPIについて。 ↩
-
Microsoft Learn, about_Command_Precedence. 同名コマンド、エイリアス、関数などの実行時の優先順位について。 ↩ ↩2
-
Microsoft Learn, InvokeMemberExpressionAst Constructors. インスタンス・静的メソッド呼び出しを表すノードについて。 ↩
-
Microsoft Learn, about_Character_Encoding. Windows PowerShellのスクリプト読み取りと、UTF-8 BOMの扱いについて。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
PSScriptAnalyzerでPowerShellスクリプトの品質を守る ── ルール選定とCI導入
PowerShellの静的解析ツールPSScriptAnalyzerを社内スクリプトに導入する実務ガイドです。まず有効にすべきルール、設定ファイルの書き方、既存資産への段階導入、GitHub ActionsでのCI化までを解説します。
ネットは使えるのに「インターネットなし」?── WindowsのNCSI・DNS・プロキシ・VPNを切り分ける
ネットは使えるのにWindowsが「インターネットなし」と表示する理由を、NCSIの接続判定から解説します。DNS・プロキシ・VPN・認証Wi-Fiを、設定変更前の確認コマンドとイベントログで切り分けます。
ディスク使用率100%は何を止めれば直る?── SysMain・Windows Search・Defenderの見分け方
Windowsのディスク使用率が100%になる原因を、処理量・応答時間・ファイルから切り分けます。SysMainの一時停止と復帰、Windows Searchの検索範囲の見直し、Defenderを無効化しない調査方法を図解します。
Windowsの名前解決の順序 ── hosts・DNSキャッシュ・LLMNR/mDNS・DoH
「名前解決できない」「一部のPCだけ繋がらない」は、hosts・DNSキャッシュ・DNSサーバー・LLMNR/mDNSのどの層が答えたかで結果が変わります。Windowsの名前解決の順序とDoHが変えるものを仕組みから整理し、層ごとに切り分ける手順を解説します。
高速スタートアップの正体 ── Windowsの「シャットダウン」が再起動と違う理由
Windowsの「シャットダウン」は既定でハイブリッドシャットダウンになり、カーネルとドライバーは休止ファイルへ保存され次回起動で復元されます。再起動でしか直らない理由、稼働時間・更新・Wake on LANへの影響、確認方法と無効化の判断を解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- PowerShellのASTとは何ですか?
- コードを、代入・コマンド呼び出し・関数定義などの構文ごとのノードで表した抽象構文木です。同じ文字がコメントや文字列に書かれている場合と、呼び出しの名前として書かれている場合を区別して調べられます。
- 解析対象のps1を実行する必要はありますか?
- この記事のツールはParser.ParseFileで読み、対象を起動したりドットソースしたりしません。ただし、安全性を保証するサンドボックスではなく、自分で管理するスクリプトの棚卸しを想定しています。呼び出しが見つからないことも、安全性の証明にはなりません。
- 変数を使って呼び出すコマンド名も分かりますか?
- GetCommandNameで静的に名前を得られない場合はnullになります。この記事のツールはその行を捨てず、NameKindをUnresolvedとして残します。変数の代入を追跡したり、式を実行して名前を推測したりはしません。
- Windows PowerShell 5.1でも使えますか?
- 掲載ツールは5.1と7系を対象にしています。ただし解析に使われる文法は、ツールを動かしているPowerShellのものです。7系で構文解析できたことは5.1との互換性の証明ではありません。日本語を含むファイルを5.1でも読む場合は、UTF-8 BOM付きなど文字コードにも配慮してください。
- PSScriptAnalyzerとはどう使い分けますか?
- この記事のツールは呼び出しの名前と位置を一覧にするためのものです。既存ルールで品質を検査して警告を管理したい場合はPSScriptAnalyzerを使います。ここで得たASTの読み方は、静的解析の結果や独自ルールの仕組みを理解する入口になります。