PowerShellで、PowerShellの解析ツールを作る ── AST・代入追跡・定数伝播でスクリプトを読む

· 更新日: · · PowerShell, AST, 静的解析, 開発ツール

更新履歴(初版のみ・2026年09月13日公開)
初版公開

PowerShellスクリプトからWrite-Hostを呼んでいる場所だけを一覧にしたいとします。単語で検索すると、コメントや代入する文字列まで見つかります。ASTで呼び出しを探しても、$command = 'Write-Host'; & $commandのように変数を経由すると、名前は空欄になります。

この記事では自分で管理する.ps1を実行せずに読み、コマンド呼び出しの名前・解決方法・行・列を取り出すツールを作ります。標準パーサーのASTで呼び出しを一覧にし、文の順序に沿って変数への代入を追跡します。対応した文字列の式だけを定数として評価し、一つの文字列に確定しない名前は、人が調べられるように空欄のまま残します。GetCommandName()に代入の追跡を期待せず、値の表と更新の規則を解析する側に足す設計です。[S6] CommandAst.GetCommandName Method

完成したツールに次のdataflow-demo.ps1を読ませると、直接の名前、変数から解決した名前、未解決の呼び出しを区別できます。これは解析する対象の中身で、実行するための手順ではありません。

Get-Process
$command = 'Get-Service'
& $command
$command = $env:COMMAND_NAME
& $command

未確定の値を表示のときだけ<unknown>にすると、次のように分かれます。環境変数の実際の値は読みません。この表は期待する内容を示したもので、表示幅による空白の違いは比べません。

CommandName Resolution
----------- ----------
Get-Process Direct
Get-Service VariableConstant
<unknown>   Dynamic

対象は自分で管理していて読み取り権限のある.ps1ファイルです。掲載する関数はWindows PowerShell 5.1とPowerShell 7系を対象にし、標準のSystem.Management.Automation.Language APIだけを使います。追加のモジュールは要りません。PesterとPSScriptAnalyzerは、配布サンプルのテストと品質検査を動かすときにだけ必要です。例の相対パスはWindowsの作業フォルダーを前提にしています。CSVを作るときは保存先への書き込み権限も必要です。管理者権限への昇格や実行ポリシーの変更は前提にしません。

解析する文法は関数を動かしているPowerShellのバージョンに従います。7系で解析できても、5.1で読めるとは限りません。5.1で日本語を含むファイルを読むときは、UTF-8 BOM付きなど文字コードを確かめてください。[S10] about_Character_Encoding

定数の追跡は通常のローカル変数として読めるコードのための限定したモデルです。呼び出し元が先に設定した型、変換属性、AllScope、デバッガーのフックまでは再現しません。対応しない構文は「解析で分からないこと」にまとめます。既存のルールによる品質検査や警告の管理は、PSScriptAnalyzerの解説で扱います。ASTの読み方は、その結果や独自ルールを理解する手がかりにもなります。

すぐ使う場合は「解析関数を作る」の完成コードと配布ZIPへ進めます。仕組みから知りたい場合は、次のASTの観察から順に読んでください。

単語検索とASTの違い

文字列検索や正規表現は、指定した文字の並びがあるかを調べる方法です。今回知りたいのは、その文字がコマンド名として使われている場所です。単語検索だけではコメント・文字列・呼び出しを同じように拾います。

そこで、PowerShellの標準パーサーに構文を区別させます。ASTはAbstract Syntax Tree(抽象構文木)の略で、コードを代入・パイプライン・コマンド呼び出し・関数定義などの構文ごとのノードで表します。PowerShellから見ると各ノードはプロパティとメソッドを持つオブジェクトで、子のプロパティをたどると別の構文のオブジェクトへ進めます。[S2] PowerShellのASTの利用

ノードは構文木を作る一つひとつの要素です。PowerShellではAstを基底とするオブジェクトで、親を示すParentと、元のコードの範囲を示すExtentを持ちます。たとえばCommandAstはコマンド呼び出しを表すノードです。[S14] Ast Class

対象のコードを実行せず、書かれた構造から性質を調べる方法を静的解析と呼びます。この記事ではASTを読み、呼び出しと、対応した構文から分かる文字列を調べます。ASTは実行結果でも実行履歴でもありません。GetType().NameとExtent.Textを使うと、構文上の役割と元のコードを対応させられます。[S7] Parser.ParseFile Method [S5] IScriptExtent Interface

単語検索とASTで見つかる行の違いWrite-Hostを含む3行は単語検索ではすべて一致しますが、ASTで構文の役割を区別すると、呼び出しは3行目だけです。Write-Hostを含む3行単語検索:3行とも一致ASTで構文の役割を区別呼び出しは3行目だけ

図1: 単語検索は文字の並びを探し、ASTは構文の役割を区別します。

3行のコードを解析する

ここからの観察例は同じPowerShellセッションで上から順に実行します。解析する対象はヒアストリングに閉じ込め、その中のコードは実行しません。まず、コメント、文字列への代入、呼び出しを含む3行を対象にします。単語検索では3行とも一致しますが、呼び出しは3行目だけです。

$source = @'
# Write-Host は画面への表示に使う
$message = 'Write-Host'
Write-Host 'こんにちは'
'@

@' ... '@は単一引用符のヒアストリングです。内側の3行を実行せず、$messageも展開しない文字列にしています。行番号は内側の文字列で数えます。[S3] about_Quoting_Rules

文字列の解析にはParser.ParseInputを使います。入力全体のASTを返すAPIで、トークンと構文エラーは出力引数で受け取ります。[S1] Parser.ParseInput Method

$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

戻り値は入力全体を表すScriptBlockAstで、コードの実行結果ではありません。[ref]で渡した変数にはトークンと構文エラーが返ります。[S1] Parser.ParseInput Method

トークンは変数や文字列などコードを字句として区切った要素です。種類を示すKind、元の文字列を示すText、位置を示すExtentを持ちます。構文の親子関係を表すASTのノードと、字句の一覧は別の情報です。[S15] Token Class 構文エラーはパーサーがコードを読み取るときに見つけた文法上の問題です。ParseErrorの配列として返り、ErrorId、Message、Extentなどで確かめられます。対象を実行したときのエラーとは別のものです。[S16] ParseError Class

ParseInputが返す情報ヒアストリングの文字列をParseInputに渡すと、戻り値のScriptBlockAstと、出力引数のトークンと構文エラーを返します。ヒアストリングの文字列Parser.ParseInputASTとトークンと構文エラーを返す

図2: ParseInputは対象を実行せず、構文の木と字句の一覧と構文エラーを返します。

この例はbegin・process・endを明示していないので、通常の文はEndBlock.Statementsにあります。[S11] NamedBlockAst Class

$statements = $ast.EndBlock.Statements
$statements | ForEach-Object {
    [pscustomobject]@{
        Type = $_.GetType().Name
        Text = $_.Extent.Text
    }
}
Type                   Text
----                   ----
AssignmentStatementAst $message = 'Write-Host'
PipelineAst            Write-Host 'こんにちは'

文は2件です。コメントはこの一覧に入らず、必要ならトークンで調べます。代入はAssignmentStatementAst、呼び出しを含む文はPipelineAstです。|を書かなくても、コマンドが一つだけのパイプラインとして表されます。[S12] PipelineAst.PipelineElements Property

元のコードと構文オブジェクトの対応スクリプト全体のEndBlockに、2行目の代入と3行目のパイプラインが入り、後者にコマンド呼び出しが含まれます。ScriptBlockAstEndBlock代入文:2行目パイプライン:3行目CommandAst

図3: 3行目の呼び出しはパイプラインの中にあります。

呼び出しのノードを確かめる

配列の添字は0始まりです。2件目の文から、パイプラインの先頭要素へ進みます。

$pipeline = $statements[1]
$call = $pipeline.PipelineElements[0]
$call.GetType().Name
$call.Extent.Text
$call.GetCommandName()
CommandAst
Write-Host 'こんにちは'
Write-Host

コマンド呼び出しを表すノードがCommandAstです。GetCommandName()は呼び出しから静的に分かる名前を返すメソッドです。[S6] CommandAst.GetCommandName Method 同じオブジェクトから元のコードと呼び出し名を得られます。引数を含む内訳も確かめます。

$call.CommandElements | ForEach-Object {
    [pscustomobject]@{
        Type = $_.GetType().Name
        Text = $_.Extent.Text
    }
}
Type                        Text
----                        ----
StringConstantExpressionAst Write-Host
StringConstantExpressionAst 'こんにちは'

CommandElementsには名前や引数の構文要素が入っています。名前と引数はどちらも文字列のノードですが、GetCommandName()が返すのは名前の位置にあるWrite-Hostです。代入文の右辺にある同じ綴りの文字列は、この呼び出しに属しません。文字の一致ではなく構文上の役割を見ることが重要です。[S13] CommandAst.CommandElements Property

CommandAstの要素とGetCommandNameCommandAstのCommandElementsには名前と引数の要素が並び、GetCommandNameが返すのは名前の要素のWrite-Hostです。CommandAstCommandElements:名前と引数名前の要素:Write-HostGetCommandNameの戻り値

図4: 名前と引数はどちらも文字列のノードで、GetCommandNameが返すのは名前の要素です。

呼び出しを型で探す

実際のスクリプトでは「2件目の文」と決め打ちできません。関数や条件分岐の中にも呼び出しがあるので、型を条件に検索します。条件に一致するノードを探すAPIがAst.FindAllです。[S4] Ast.FindAll Method

$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 'こんにちは'

-isで型を判定し、CommandAstだけを残します。最後の$trueは入れ子の関数やスクリプトブロックも探す指定です。関数を実行せずに、その本体にある呼び出しを取得できます。FindAllは構文木をたどって各ノードを判定用のスクリプトブロックへ渡しますが、ここで実行するのは解析する側の判定処理です。解析する対象の関数やスクリプトは実行しません。[S4] Ast.FindAll Method

関数の中まで型を調べて検索するスクリプトに含まれる関数の本体までたどり、CommandAstに一致する呼び出しを検索結果へ含めます。スクリプト関数の定義関数の本体CommandAst型が一致:結果に残る

図5: 入れ子の深さを数えずに、型に一致するノードを取得できます。

見つけた呼び出しの位置はExtentから取り出します。Extentはノードに対応する元のコードの範囲で、Textでコードを、StartLineNumberとStartColumnNumberで開始位置を得られます。[S5] IScriptExtent Interface

$commands | ForEach-Object {
    [pscustomobject]@{
        Name   = $_.GetCommandName()
        Line   = $_.Extent.StartLineNumber
        Column = $_.Extent.StartColumnNumber
    }
}
Name       Line Column
----       ---- ------
Write-Host    3      1

行と列は1始まりです。同じノードが位置を持つので、文字列検索で探し直す必要はありません。& 'Write-Host'の位置は名前の文字列だけでなく演算子を含む呼び出し全体の開始位置です。名前が分からない呼び出しでも位置は残せるので、名前と位置の一覧から修正する箇所へ戻れます。[S5] IScriptExtent Interface

見つけた呼び出しから修正する箇所へ戻るFindAllでCommandAstを見つけ、Extentから行と列を取り出すと、名前が分からない呼び出しでも一覧から修正する箇所へ戻れます。FindAllでCommandAstを見つけるExtentから行と列を取り出す一覧から修正する箇所へ戻る

図6: 名前が分からなくても、Extentの行と列から修正する箇所へ戻れます。

変数経由の呼び出し

まずAPIだけの結果を確かめます。直接の名前、呼び出し演算子に渡す文字列、変数を経由する呼び出しを比べます。

$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として見つかっています。名前がnullなのは、ASTが代入を失ったからではなくGetCommandName()が代入を追跡しないからです。このメソッドは名前を決められないときに$nullを返し、変数の代入をさかのぼって値を計算することはしません。[S6] GetCommandName

代入文を表すAssignmentStatementAstも別にあります。Leftに代入先、Rightに右辺、Operatorに代入演算子を持ちます。解析する側でこの代入と呼び出しを結びます。[S17] AssignmentStatementAst

同じ呼び出しノードでも名前の取得結果が違うGetCommandName単体では文字列の呼び出し名を取得できますが、変数の呼び出し名はnullです。これは追加の代入追跡前の状態です。CommandAst名前の要素が文字列名前の要素が変数名前:Write-HostAPIだけでは名前:null

図7: これはAPI単体の結果です。完成ツールでは4行目も代入から解決します。

行 GetCommandNameだけ 完成ツール
1・2 Write-Host Write-Host / Direct
3 代入文であり呼び出しではない commandの値を表へ登録
4 null Write-Host / VariableConstant

代入を追跡して名前を決める

ここまでのAPIはPowerShellの機能です。このあとの定数表と解決区分は、この記事の解析器が足す機能です。

どの代入から得た値がどの使用箇所へ届くかを調べる解析をデータフロー解析と呼びます。この記事では文の順序に沿って「この時点で既知の文字列になっている変数」を表に記録します。ソース全体から最後の代入を一つ探す方法とは違い、呼び出しより後の代入や別の関数の代入を混ぜません。

変数に既知の定数が入っていると分かるとき、その情報を後ろの式や使用箇所へ伝えることを定数伝播と呼びます。この記事で扱う値は文字列だけです。変数のコピーと、両辺が既知の文字列である+を扱い、任意のPowerShellの式を実行して答えを得ることはしません。

解析の流れ

解析は「AST解析 → 変数代入の追跡 → 定数伝播」の順に進み、各段階で分からないものはUnknownとして残します。まずすべてのCommandAstを一覧に入れ、直接の名前があればDirect、なければDynamicにします。そのあとで文を順に読み、対応した式から名前が決まった行だけをVariableConstantに更新します。

解決できた行だけを出すと、対応していない呼び出しを見落とします。呼び出しを一覧に入れる処理と値の解析を分けるので、対応していないループや例外処理の中でも、呼び出しそのものは消えません。値の追跡ではFindAllの結果とは別にStatementsを順に読みます。木の検索結果を位置で並べただけでは、分岐の意味を扱えないためです。出力の並びはソース上の位置の順で、プログラムの実行順ではありません。

呼び出しの一覧を先に作ってから名前を更新するすべてのCommandAstをDirectかDynamicで一覧に入れ、そのあと文を順に読んで代入を追跡し、名前が決まった行だけをVariableConstantに更新します。すべてのCommandAstを一覧へDirectかDynamicで登録文を順に読み代入を追跡決まった行をVariableConstantへ

図8: 呼び出しを先に一覧へ入れるので、値を追えない場所の呼び出しも消えません。

定数表と解決区分

定数表は変数名から既知の文字列への対応です。未確定の値は空文字列として登録せず、表から外します。この記事では、この状態をUnknownと呼びます。

Unknownは一つの文字列に確定できない状態です。未代入、外部入力、対応していない構文、分岐で異なる値になった場合などを含みます。呼び出しが存在しないという意味でも、あらゆる静的解析が不可能という意味でもありません。出力ではCommandName = $nullとResolution = 'Dynamic'で表します。

出力のDirectはAPIで得た名前、VariableConstantは追加した文字列の解析で得た名前、Dynamicは未解決です。VariableConstantには変数を介さない括弧内の文字列連結も含めます。

名前は二段で取得します。直接の文字列ならGetCommandName()を使い、戻り値がnullなら、名前の位置の式を限定した評価器へ渡します。評価器が読むのは、ASTの種類とリテラルの値、定数表にある文字列だけです。Invoke-Expressionで対象の式を実行したり、メソッドを呼んで戻り値を調べたりはしません。

呼び出しの名前と解決区分の決め方GetCommandNameが名前を返せばDirectで終わり、nullのときだけ評価器で調べて、文字列に決まればVariableConstant、決まらなければDynamicにします。名前null決まる決まらないGetCommandNameを呼ぶDirect評価器で調べるVariableConstantDynamic

図9: APIで名前が得られなければ評価器で調べ、決まらなければDynamicのまま残します。

代入で表を更新する

$cmd = 'Get-Process'なら、表に文字列を登録します。$copy = $cmdはその時点の文字列をコピーします。あとで$cmdだけを上書きしても、過去の呼び出しや$copyの値は置き換えません。右辺が未確定なら、代入先の以前の値を消します。

右辺に対応していない副作用があり得るときは、代入先だけでなく表全体を消します。ほかの変数が変更されないと確かめられないためです。通常の未知の変数を読むだけの代入は、代入先だけをUnknownにします。

コピーした値は元の変数を上書きしても変わらないcmdにGet-Processを代入してcopyへコピーしたあと、cmdをGet-Serviceで上書きしても、copyはGet-Processのままです。cmd = Get-Processcopy = cmdcmd = Get-ServicecopyはGet-Processのまま

図10: コピーはその時点の文字列を写すので、元の変数を上書きしても変わりません。

次の内容を解析すると、最後の呼び出しはGet-Process / VariableConstantになります。実行して文字列を得るのではなく対応したノードの値だけを組み合わせます。

$a = 'Get-'
$b = 'Process'
$command = $a + $b
$copy = $command
& $copy
読んだ文 表に追加する既知の文字列
aへの代入 a → Get-
bへの代入 b → Process
commandへの代入 command → Get-Process
copyへの代入 copy → Get-Process
copyの呼び出し 登録値から名前を解決し、呼び出し後は表を消す

変数名は大文字と小文字を区別せずに扱い、文字列そのものは書かれた値を保ちます。空文字列を知っている場合と、値を知らない場合も区別します。空文字列を解決できても、有効なコマンドが存在するという意味ではありません。

分岐の合流

ifでは状態をそれぞれの分岐へ複製します。合流するときは、すべての経路にあって文字列が完全に一致する変数だけを残します。elseがなければ、何も代入しない経路も比べる対象です。条件は実行せず、到達できない経路も除きません。条件に対応していない式があるときは、先に入力の表を消してから、分岐の中の新しい代入を追います。

if ($flag) {
    $command = 'Get-Service'
} else {
    $command = 'Get-Service'
}
& $command

どちらの経路も同じ文字列なので、合流後はGet-Service / VariableConstantです。else側をGet-Processに変えるとDynamicになります。どちらが実行されるかを推測して、一方を採用することはしません。elseを消し、分岐前の値も分からないときは、代入しない経路があるのでやはりDynamicです。

分岐ごとに表を複製して合流で比べる分岐前の表をif側とelse側に複製し、合流ではすべての経路で同じ文字列になっている変数だけを残します。分岐前の表if側の表else側の表合流:全経路で同じ値だけ残す

図11: 分岐ごとに表を複製し、合流ではすべての経路で一致した値だけを残します。

候補がGet-ProcessとGet-Serviceに分かれたとき、今の実装は候補の集合を持たずにUnknownにします。候補の列挙やループの固定点の計算は、別の拡張です。

$command = Read-Host; & $commandもDynamicです。外部からの入力は解析のときに与えられないので、一つの文字列に決められません。Read-Hostの呼び出しそのものはDirectとして別の行に残り、解析のときに入力を求めることはありません。

呼び出しの後は表を消す

コマンド名が分かっても、呼び出しの後には表を消します。同じ名前のユーザー関数や呼び出し先が変数を変更しないことを、この解析器は確かめないためです。そのため$cmd = 'Get-Process'; & $cmd; & $cmdの1回目はVariableConstant、2回目はDynamicです。間で代入し直せば、また追跡できます。

この慎重さは精度と引き換えです。よく知っている名前だから副作用はない、と推測して追跡を続けることはしません。引数の部分式、メソッド呼び出し、ドットソース、複数要素のパイプラインなどでも、古い定数を残しません。

呼び出しのあとに表を消すので2回目は決まらないcmdに代入した直後の1回目の呼び出しはVariableConstantですが、呼び出しのあとに表を消すので、2回目の呼び出しはDynamicです。cmd = Get-Process1回目の呼び出し:VariableConstant呼び出しのあとに表を消す2回目の呼び出し:Dynamic

図12: 呼び出し先が変数を変えないとは確かめないので、2回目は名前を決めません。

解析関数を作る

ファイルを読むときは、ParseInputの代わりにParser.ParseFileを使います。ファイルを解析し、ASTと、出力引数のトークン・構文エラーを返すAPIです。完成した関数には読み取るファイルのフルパスを渡します。[S7] Parser.ParseFile Method

解析APIが返すルートのScriptBlockAstの下には、関数やスクリプトブロックが入れ子になっています。完成した関数は入れ子のスクリプトブロックもすべて見つけ、それぞれを独立した状態で解析します。[S11] NamedBlockAst Class

パーサーがASTの一部を返しても、構文エラーがあればそのファイルの一覧は作りません。読めなかった場合と、正常に読めて呼び出しが0件だった場合を区別するためです。

解析関数がファイルを処理する順序Get-Itemで確かめたファイルをParseFileで解析し、構文エラーがあれば位置付きのエラーで止まり、なければ呼び出しを一覧に入れてスコープごとに代入を追跡し、位置の順に出力します。構文エラーなしGet-Itemでファイルを確かめるParseFileでASTを得る停止する呼び出しを一覧へスコープごとに追跡位置の順に出力

図13: 構文エラーのあるファイルは、一覧を返す前に止めます。

配布サンプルにも同じ関数があり、配布版にはヘルプコメントがあります。読み込む前に、解析ツールの内容を確かめます。これは対象のスクリプトを実行・ドットソースして読み込む手順ではありません。

  1. 次の関数をGet-ScriptCommand.ps1として保存します。

    function Get-ScriptCommand {
        [CmdletBinding()]
        [OutputType([pscustomobject])]
        param(
            [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
            [Alias('FullName')]
            [ValidateNotNullOrEmpty()]
            [string[]] $LiteralPath
        )
        
        begin {
            function Get-VariableKey {
                param($Node, [hashtable] $Blocked)
                if ($Node -isnot [System.Management.Automation.Language.VariableExpressionAst]) { return $null }
                if ($Node.Splatted -or -not $Node.VariablePath.IsUnqualified) { return $null }
                $key = $Node.VariablePath.UserPath.ToLowerInvariant()
                if ($key -notmatch '^[a-z_][a-z0-9_]*$' -or $Blocked.ContainsKey($key)) { return $null }
                $automatic = @(
                    '_', 'args', 'error', 'event', 'eventargs', 'eventsubscriber',
                    'executioncontext', 'false', 'foreach', 'home', 'host', 'input',
                    'lastexitcode', 'matches', 'myinvocation', 'nestedpromptlevel',
                    'null', 'ofs', 'pid', 'profile', 'pwd', 'sender', 'shellid',
                    'stacktrace', 'switch', 'this', 'true')
                if ($key -match '^ps|preference$' -or $key -in $automatic) { return $null }
                return $key
            }
        
            function Read-ConstantString {
                param($Node, [hashtable] $Bindings, [hashtable] $Blocked, [int] $Depth = 0)
                $unknown = @{ Known = $false; Value = $null; Pure = $false }
                if ($null -eq $Node -or $Depth -gt 32) { return $unknown }
                switch ($Node.GetType().Name) {
                    'StringConstantExpressionAst' {
                        if ($Node.Value.Length -gt 4096) { return $unknown }
                        return @{ Known = $true; Value = $Node.Value; Pure = $true }
                    }
                    'ConstantExpressionAst' {
                        return @{ Known = $false; Value = $null; Pure = $true }
                    }
                    'VariableExpressionAst' {
                        $key = Get-VariableKey $Node $Blocked
                        if ($null -eq $key) { return $unknown }
                        if ($Bindings.ContainsKey($key)) {
                            return @{ Known = $true; Value = $Bindings[$key]; Pure = $true }
                        }
                        return @{ Known = $false; Value = $null; Pure = $true }
                    }
                    'CommandExpressionAst' {
                        if ($Node.Redirections.Count -ne 0) { return $unknown }
                        return (Read-ConstantString $Node.Expression $Bindings $Blocked ($Depth + 1))
                    }
                    'PipelineAst' {
                        $background = $Node.PSObject.Properties['Background']
                        if ($null -ne $background -and $background.Value) { return $unknown }
                        if ($Node.PipelineElements.Count -ne 1) { return $unknown }
                        return (Read-ConstantString $Node.PipelineElements[0] $Bindings $Blocked ($Depth + 1))
                    }
                    'ParenExpressionAst' {
                        return (Read-ConstantString $Node.Pipeline $Bindings $Blocked ($Depth + 1))
                    }
                    'BinaryExpressionAst' {
                        if ($Node.Operator -ne [System.Management.Automation.Language.TokenKind]::Plus) { return $unknown }
                        $left = Read-ConstantString $Node.Left $Bindings $Blocked ($Depth + 1)
                        $right = Read-ConstantString $Node.Right $Bindings $Blocked ($Depth + 1)
                        if ($left.Known -and $right.Known -and ($left.Value.Length + $right.Value.Length -le 4096)) {
                            return @{ Known = $true; Value = $left.Value + $right.Value; Pure = $true }
                        }
                        return $unknown
                    }
                    default { return $unknown }
                }
            }
        
            function Read-StatementList {
                param([object[]] $Statements, [hashtable] $Bindings,
                    [hashtable] $Blocked, [hashtable] $Rows, [int] $Depth = 0)
                if ($Depth -gt 32) { $Bindings.Clear(); return }
                foreach ($statement in $Statements) {
                    if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) { continue }
                    if ($statement -is [System.Management.Automation.Language.AssignmentStatementAst]) {
                        $key = Get-VariableKey $statement.Left $Blocked
                        if ($null -eq $key -or $statement.Operator -ne [System.Management.Automation.Language.TokenKind]::Equals) {
                            $Bindings.Clear(); continue
                        }
                        $value = Read-ConstantString $statement.Right $Bindings $Blocked
                        if (-not $value.Pure) { $Bindings.Clear() }
                        if ($value.Known) { $Bindings[$key] = $value.Value }
                        else { $Bindings.Remove($key) }
                        continue
                    }
                    if ($statement -is [System.Management.Automation.Language.IfStatementAst]) {
                        # Conditions are not executed or used to prune branches.
                        # An opaque condition may change any tracked variable.
                        foreach ($clause in $statement.Clauses) {
                            $condition = Read-ConstantString $clause.Item1 $Bindings $Blocked
                            if (-not $condition.Pure) { $Bindings.Clear() }
                        }
                        $paths = [System.Collections.Generic.List[hashtable]]::new()
                        foreach ($clause in $statement.Clauses) {
                            $branch = $Bindings.Clone()
                            if ($null -ne $clause.Item2.Traps -and $clause.Item2.Traps.Count -gt 0) { $branch.Clear() }
                            else { Read-StatementList $clause.Item2.Statements $branch $Blocked $Rows ($Depth + 1) }
                            $paths.Add($branch)
                        }
                        $otherwise = $Bindings.Clone()
                        if ($null -ne $statement.ElseClause) {
                            if ($null -ne $statement.ElseClause.Traps -and $statement.ElseClause.Traps.Count -gt 0) { $otherwise.Clear() }
                            else { Read-StatementList $statement.ElseClause.Statements $otherwise $Blocked $Rows ($Depth + 1) }
                        }
                        $paths.Add($otherwise)
                        $Bindings.Clear()
                        foreach ($key in @($paths[0].Keys)) {
                            $same = $true
                            foreach ($branch in $paths) {
                                if (-not $branch.ContainsKey($key)) { $same = $false; break }
                                $equal = [string]::Equals($paths[0][$key], $branch[$key], [System.StringComparison]::Ordinal)
                                if (-not $equal) { $same = $false; break }
                            }
                            if ($same) { $Bindings[$key] = $paths[0][$key] }
                        }
                        continue
                    }
                    if ($statement -is [System.Management.Automation.Language.PipelineAst]) {
                        $background = $statement.PSObject.Properties['Background']
                        if (($null -ne $background -and $background.Value) -or $statement.PipelineElements.Count -ne 1) {
                            $Bindings.Clear(); continue
                        }
                        $command = $statement.PipelineElements[0]
                        if ($command -is [System.Management.Automation.Language.CommandAst]) {
                            if ($command.Redirections.Count -gt 0 -or
                                $command.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Dot) { $Bindings.Clear() }
                            foreach ($argument in ($command.CommandElements | Select-Object -Skip 1)) {
                                if ($argument -is [System.Management.Automation.Language.CommandParameterAst]) {
                                    if ($null -eq $argument.Argument) { continue }
                                    $argument = $argument.Argument
                                }
                                $value = Read-ConstantString $argument $Bindings $Blocked
                                if (-not $value.Pure) { $Bindings.Clear() }
                            }
                            $row = $Rows[$command.Extent.StartOffset]
                            if ($null -eq $row.Name) {
                                $value = Read-ConstantString $command.CommandElements[0] $Bindings $Blocked
                                if ($value.Known) {
                                    $row.CommandName = $value.Value
                                    $row.Resolution = 'VariableConstant'
                                }
                            }
                            # Even a familiar command name can denote a user function.
                            $Bindings.Clear()
                        } else {
                            $value = Read-ConstantString $statement $Bindings $Blocked
                            if (-not $value.Pure) { $Bindings.Clear() }
                        }
                        continue
                    }
                    # Loops, try/catch, switch, compound writes and other syntax
                    # remain in the inventory, but cannot carry constant state.
                    $Bindings.Clear()
                }
            }
        }
        
        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)
                }
                $rows = @{}
                $commands = $ast.FindAll({
                        param($node)
                        $node -is [System.Management.Automation.Language.CommandAst]
                    }, $true)
                foreach ($command in $commands) {
                    $name = $command.GetCommandName()
                    $rows[$command.Extent.StartOffset] = [pscustomobject]@{
                        Path = $file.FullName
                        Line = $command.Extent.StartLineNumber
                        Column = $command.Extent.StartColumnNumber
                        CommandName = $name
                        Resolution = if ($null -eq $name) { 'Dynamic' } else { 'Direct' }
                        Text = $command.Extent.Text
                        NameKind = if ($null -eq $name) { 'Unresolved' } else { 'Static' }
                        Name = $name
                    }
                }
                $scopes = $ast.FindAll({
                        param($node)
                        $node -is [System.Management.Automation.Language.ScriptBlockAst]
                    }, $true)
                foreach ($scope in $scopes) {
                    $blocked = @{}
                    # Type/validation attributes and parameters persist beyond a
                    # later bare assignment. Never treat those names as plain slots.
                    foreach ($attribute in $scope.FindAll({
                                param($node)
                                $node -is [System.Management.Automation.Language.AttributedExpressionAst]
                            }, $false)) {
                        $child = $attribute.Child
                        while ($child -is [System.Management.Automation.Language.AttributedExpressionAst]) { $child = $child.Child }
                        if ($child -is [System.Management.Automation.Language.VariableExpressionAst]) {
                            $blocked[($child.VariablePath.UserPath -replace '^.*:', '').ToLowerInvariant()] = $true
                        }
                    }
                    if ($null -ne $scope.ParamBlock) {
                        foreach ($parameter in $scope.ParamBlock.Parameters) { $blocked[$parameter.Name.VariablePath.UserPath.ToLowerInvariant()] = $true }
                    }
                    if ($null -ne $scope.Parent) {
                        $parameters = $scope.Parent.PSObject.Properties['Parameters']
                        if ($null -ne $parameters) {
                            foreach ($parameter in $parameters.Value) { $blocked[$parameter.Name.VariablePath.UserPath.ToLowerInvariant()] = $true }
                        }
                    }
                    $blocks = @($scope.DynamicParamBlock; $scope.BeginBlock; $scope.ProcessBlock; $scope.EndBlock)
                    $clean = $scope.PSObject.Properties['CleanBlock']
                    if ($null -ne $clean) { $blocks += $clean.Value }
                    foreach ($block in $blocks) {
                        if ($null -eq $block -or ($null -ne $block.Traps -and $block.Traps.Count -gt 0)) { continue }
                        Read-StatementList $block.Statements @{} $blocked $rows
                    }
                }
                foreach ($offset in ($rows.Keys | Sort-Object)) { $rows[$offset] }
            }
        }
    }
    

Get-Item -LiteralPathでファイルを取得し、フルパスをパーサーへ渡します。draft[1].ps1のような名前をワイルドカードとして扱いません。存在しないパスとディレクトリーはエラーになります。

構文エラーがあれば、そのファイルの結果を出す前に停止します。エラーにはファイル、行、列、ErrorIdを含めます。すでに別のファイルから返した結果を取り消す処理ではありません。結果は表示済みの表ではなくオブジェクトなので、呼び出し側で絞り込みやCSVへの保存を選べます。

出力列 意味
CommandName 新しい名前の解析結果。Unknownならnull。空文字列とは区別する。
Resolution Direct / VariableConstant / Dynamic。
Path / Line / Column / Text ファイル、1始まりの開始位置、元の呼び出しコード。
Name / NameKind 旧版との互換列。従来どおりGetCommandNameの値とStatic / Unresolvedを表す。新しい検索にはCommandName / Resolutionを使う。

互換列の意味は変えていません。変数から新たに解決できた行でも、Nameはnull、NameKindはUnresolvedのままです。新しい結果とAPI単体の結果を混同しないでください。

ファイルの呼び出しを一覧にする

保存した関数でファイルの呼び出しを一覧にします。画面で確かめてから、複数ファイルの結果をCSVへ保存します。

呼び出し位置を抽出する

解析ツール・サンプル・テスト一式(ZIP)を展開し、Get-ScriptCommand.ps1とdemo.ps1のあるフォルダーへ移動します。手で用意するときは、「変数経由の呼び出し」のヒアストリングの内側4行だけをdemo.ps1へ保存します。囲みや解析の処理は保存しません。

ドットソースするのは、内容を確かめた解析ツールだけです。demo.ps1やdataflow-demo.ps1は、実行もドットソースもしません。このツールは安全性を保証するサンドボックスではありません。

  1. 解析ツールを読み込み、直接と変数経由の呼び出しをまとめて検索します。

    . .\Get-ScriptCommand.ps1
    $calls = @(Get-ScriptCommand -LiteralPath .\demo.ps1)
    $calls |
        Where-Object CommandName -eq 'Write-Host' |
        Format-Table Line, Column, CommandName, Resolution -AutoSize
    
    Line Column CommandName Resolution
    ---- ------ ----------- ----------
       1      1 Write-Host  Direct
       2      1 Write-Host  Direct
       4      1 Write-Host  VariableConstant
    

    API単体では空欄だった4行目も得られます。代入の3行目は呼び出しとして出ません。

  2. 外部入力を含むサンプルを読み、未解決の位置を確かめます。

    記事冒頭の5行がdataflow-demo.ps1です。配布版には保存済みです。

    $calls = @(Get-ScriptCommand -LiteralPath .\dataflow-demo.ps1)
    $calls | Where-Object Resolution -eq 'Dynamic' |
        Format-Table Line, Column, Resolution, Text -AutoSize
    
    Line Column Resolution Text
    ---- ------ ---------- ----
       5      1 Dynamic    & $command
    
  3. 未確定の値だけを表示用の文字列に置き換えます。

    $calls | Format-Table @{ Label = 'CommandName'; Expression = {
        if ($null -eq $_.CommandName) { '<unknown>' } else { $_.CommandName }
    } }, Resolution -AutoSize
    

    記事冒頭の3行の表になります。元のオブジェクトのCommandNameは変えていないので、nullを独自の文字列としてCSVへ保存してしまうこともありません。

名前の分かる呼び出しと、人が確かめるべき未解決の呼び出しを区別できました。対象のファイルはParseFileで読んだだけで、実行していません。

表示だけを置き換えて結果のオブジェクトは変えないFormat-Tableで置き換えるのは画面の表示だけで、結果のオブジェクトのCommandNameはnullのままです。結果:CommandNameはnullFormat-Tableの表示だけ置き換えるオブジェクトの値はnullのまま

図14: 置き換えるのは画面の表だけで、結果のオブジェクトの値は変わりません。

結果をCSVに保存する

Get-ScriptCommandを読み込んだのと同じセッションを使い、調べるps1を.\scriptsに用意します。保存先への書き込み権限も確かめます。

-NoClobberで既存のCSVへの上書きを防ぎます。途中のファイルで失敗すると、それより前の結果がCSVに残る場合があります。ファイルの存在だけを全件成功の証拠にしないでください。

  1. 対象を列挙して解析関数へ渡し、新しい解析結果の列をCSVへ保存します。

    Get-ChildItem -LiteralPath .\scripts -Filter *.ps1 -File -Recurse |
        Get-ScriptCommand |
        Select-Object Path, Line, Column, CommandName, Resolution, Text |
        Export-Csv -LiteralPath .\commands.csv -NoTypeInformation -Encoding UTF8 -NoClobber
    
  2. 処理中のエラーの有無と、CSVに入った対象ファイルと列を確かめます。

    パス、読み取り、構文エラーを直して再実行するときは、不完全な出力を確かめてから別の保存名を使うなど、既存の結果を誤って採用しないようにします。

Path・Line・Column・CommandName・Resolution・Textを保存できます。CSVではnullと空文字列が同じ見た目になり得るので、名前の空欄ではなくResolutionで未解決を判定します。

複数ファイルの解析結果をCSVへ保存する流れGet-ChildItemでps1を列挙し、Get-ScriptCommandで解析し、Select-Objectで列を選んでから、Export-Csvで上書きせずに保存します。Get-ChildItemでps1を列挙Get-ScriptCommandで解析Select-Objectで列を選ぶExport-Csvで保存(上書きしない)

図15: 解析結果はオブジェクトなので、列を選んでからCSVへ保存できます。

結果を確かめる

期待結果の照合

小さな入力例で型・位置・件数・解決区分を確かめます。結果が違うときは、保存したファイルにヒアストリングの囲みや解析のコードまで含めていないか、エンジンのバージョンと構文エラーを見直します。

確認対象 期待する状態
コメント・文字列・呼び出しの3行 文は2件、CommandAstは1件。Write-Hostは3行1列。
4行のdemo.ps1 呼び出しは1・2・4行目。Directが2件、VariableConstantが1件。
dataflow-demo.ps1 1・3・5行目がDirect・VariableConstant・Dynamic。
再代入と分岐 以前の値を使い回さない。異なる分岐値や欠けた経路はUnknown。
構文エラーのあるファイル そのファイルの行を返す前に位置付きエラーで停止。
正常に読めて呼び出しがないファイル エラーやDynamicの1件ではなく、0件。
CSV保存 意図した列と対象があり、処理中のエラーがない。既存CSVは上書きしない。

配布サンプル

配布ZIPには完成した関数、2種類の入力例、Pesterのテストを入れています。関数本体は掲載したコードと同じで、配布版にはヘルプコメントがあります。ZIPを取得できないときも、本文の完成コードを保存して使えます。

回帰テストの対象

従来の構文・パス・位置・複数ファイル・非実行・互換列のテストに加え、定数の代入、変数のコピー、連結、上書き、分岐の合流、状態の無効化、スコープの分離、対応していない構文を検査します。対象のファイル書き込みやメソッドが実行されないことも確かめます。リポジトリのテストでは本文の完成した関数と配布した実装が一致することも確かめます。

追加したテストはConstantPropagation.Tests.ps1です。共通のInvoke-SampleTests.ps1で構文解析、既存設定のPSScriptAnalyzer、Pesterを実行できます。検査項目を増やしたことと、すべてのPowerShellプログラムに対して正しいと証明したことは別です。

Windowsでの検証記録

配布サンプルのREADMEには旧版を2026年9月13日・14日にWindowsで検証した記録を履歴として残しています。9月14日の記録はWindows PowerShell 5.1.26100.33296とPowerShell 7.6.5で、本文の12ブロック・13項目と旧26ケースを確かめたものです。

定数伝播はその検証のあとに加えた別の実装変更です。旧版の成功を今の実装の成功とみなさず、定数伝播を加えたPRの実行結果と対象コミットを照らし合わせてください。そのPRでは、PowerShell AST sampleが5.1と7系を検査し、DITA article validationとBuild and testが生成物とサイトを検査しました。大規模なリポジトリでの性能、実機でのGUI操作、信頼できないコードの隔離まで検証したという意味ではありません。

解析で分からないこと

名前を多く解決できることより、何を根拠に解決したかを説明できることが重要です。別の構文へ対応を広げるときも、短い入力でノードの型とExtent.Textを観察し、うまくいく例だけでなく追跡を無効にすべき例もテストへ加えます。

実行経路と実行回数

検索結果は実行履歴ではありません。使われない関数やif ($false)の中の呼び出しも一覧に入ります。実行の順序・回数・到達できるかどうかは判定しません。定数の解決区分も、その行が実行される保証ではありません。

コマンド名と実体

DirectやVariableConstantは「名前の文字列を取得した」という印で、コマンドが実在することや、その実装を保証しません。echoはそのまま、Microsoft.PowerShell.Utility\Write-Hostは修飾付きのまま返します。Write-Hostの完全一致検索には修飾付きの名前は入りません。別名・修飾付きの呼び出し・同じ名前の関数まで調べるには、検索条件と実行環境を別に確かめます。[S8] about_Command_Precedence

解析で分かる名前と実行時に選ばれるコマンド解析で分かるのは名前の文字列です。実際に選ばれるコマンドは、別名や同じ名前の関数を含めて、実行環境で別に確かめます。解析で分かる:名前の文字列別に確かめる:検索条件と実行環境分かる:実際に選ばれるコマンド

図16: 解析が返すのは名前の文字列で、どのコマンドが選ばれるかは実行環境で確かめます。

定数追跡の対応範囲

追跡するのは、修飾のない通常のローカル変数への単純な代入、文字列リテラル、変数のコピー、文字列同士の連結、括弧、ifの合流です。変数名はASCIIの英字またはアンダースコアで始まる英数字・アンダースコアに限ります。パラメーター、型・検証属性の付いた変数、スプラッティング、スコープ修飾付きの変数、環境変数、主な自動変数、PSで始まる名前、Preferenceで終わる名前は除きます。

関数とスクリプトブロック、begin・process・endなどは、それぞれ空の状態から解析します。親の値や別ファイルの値は持ち込みません。ループ・switch・try/catch・trap・複合代入・任意の演算子を、一般的な制御フロー解析として解くこともしません。対応していない場所の呼び出しは、DirectまたはDynamicのまま残ります。

文字列を評価するときの再帰の深さは32、保持する文字列は4096文字までです。これは限定した評価器の上限で、パーサー全体の実行時間やメモリ使用量を保証する上限ではありません。PowerShellの意味を完全に再現する道具でも、安全性を検証する道具でもありません。

文字列内の部分式

'Write-Host'と"Today: $(Get-Date)"は違います。後者には埋め込み式があるので、その中のGet-Dateは構文上の呼び出しとして見つかります。ただし、展開可能文字列そのものの値はこの評価器では解決しません。通常の文字列に入ったコードを、別のスクリプトとして解析し直すこともしません。[S3] about_Quoting_Rules

.NETメソッド呼び出し

[Console]::WriteLine(...)や$obj.ToString()のような静的メソッド・インスタンスメソッドの呼び出しは、CommandAstではなくInvokeMemberExpressionAstとして表されます。そのため、Get-ScriptCommandの一覧には入りません。Staticプロパティは静的メソッドの形式かどうかを表します。[S9] InvokeMemberExpressionAst Class

別に一覧にするなら、次のようにその型を検索します。入力のメソッドや$objは実行も参照もされません。

$methodSource = @'
[Console]::WriteLine('hello')
$obj.ToString()
'@
$methodTokens = $null
$methodErrors = $null
$methodAst = [System.Management.Automation.Language.Parser]::ParseInput(
    $methodSource, [ref] $methodTokens, [ref] $methodErrors)
if ($methodErrors.Count -gt 0) { throw $methodErrors[0].Message }
$methodAst.FindAll({
    param($node)
    $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst]
}, $true) | ForEach-Object {
    [pscustomobject]@{
        Static = $_.Static
        Text = $_.Extent.Text
    }
}
Static Text
------ ----
  True [Console]::WriteLine('hello')
 False $obj.ToString()
メソッド呼び出しが一覧に入らない理由メソッドの呼び出しはInvokeMemberExpressionAstとして表されるので、CommandAstの検索に一致せず、Get-ScriptCommandの一覧に入りません。メソッドの呼び出しInvokeMemberExpressionAstCommandAstの検索に一致しないGet-ScriptCommandの一覧に入らない

図17: メソッドの呼び出しは別の型のノードなので、コマンドの一覧には入りません。

この追加の一覧もオブジェクトの実際の型や選ばれるオーバーロード、内部の依存関係までは決めません。また、メソッドを一覧の対象にしないことと、副作用がないとみなすことは別です。定数の追跡では対応していないメソッド呼び出しで状態を消します。どちらの一覧でも呼び出しが見つからないことは安全性の証明になりません。

関連記事

参考資料

APIと文法の一次資料です。定数表、合流、無効化の規則はこの記事の実装方針であり、GetCommandNameが提供する機能としては扱いません。

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全5件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

PowerShellのASTとは何ですか?
コードを、代入・コマンド呼び出し・関数定義などの構文ごとのノードで表した抽象構文木です。同じ文字がコメントや文字列にある場合と、呼び出しの名前として書かれている場合を区別できます。
解析対象のps1を実行する必要はありますか?
この記事のツールはParser.ParseFileで読み、対象を起動・ドットソースしたり、任意の式を実行したりしません。ただしサンドボックスや安全性の証明ではなく、自分で管理するスクリプトの呼び出しを一覧にする用途を想定しています。
変数を使って呼び出すコマンド名も分かりますか?
通常のローカル変数への文字列代入、変数コピー、文字列同士の連結など、対応した構文なら追跡できます。GetCommandNameだけでは分からない名前を、追加の定数伝播で解決します。外部入力や分岐で異なる値になる場合、未対応の副作用がある場合はDynamicとして残します。
Direct・VariableConstant・Dynamicの違いは何ですか?
DirectはGetCommandNameで直接得た名前、VariableConstantは追加した限定的な文字列解析で得た名前、Dynamicはこの解析で名前を確定できなかった行です。DynamicのCommandNameはnullで、<unknown>は表示用です。コマンドの実在や実行成功は保証しません。
Windows PowerShell 5.1でも使えますか?
掲載ツールは5.1と7系を対象にしています。ただし解析に使う文法は、ツールを動かしているPowerShellのものです。7系で解析できたことは5.1互換の証明ではありません。日本語を含むファイルを5.1でも読む場合は、UTF-8 BOM付きなど文字コードにも配慮してください。
PSScriptAnalyzerとはどう使い分けますか?
この記事のツールは呼び出しの名前・解決区分・位置を一覧にするためのものです。既存ルールで品質を検査して警告を管理したい場合はPSScriptAnalyzerを使います。ASTの読み方は、静的解析の結果や独自ルールを理解する手がかりになります。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る