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

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

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

PowerShellのASTで呼び出しを見つけ、変数への代入と文字列の定数伝播を追跡します。直接の名前、変数から解決した名前、未解決の呼び出しを区別し、対象を実行しない解析ツールを作ります。

問題・質問

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

この記事では「自分で管理する.ps1を実行せずに読み、コマンド呼び出しの名前・解決方法・行・列を取得する」という作業を扱います。ASTの観察から始め、通常のローカル変数の代入と限定した文字列の式を追跡するところまで実装します。確定できない呼び出しも、人が調べられるように残します。

対象環境

掲載関数は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やデバッガーのフックまで再現するものではありません。適用範囲と未対応の構文は「制約」で区別します。過去のWindows検証記録と、今回の実装の検証結果も混同しません。

回答・解決方法

ASTで呼び出しを確保し、文の順序に沿って変数の代入を追跡し、対応した文字列の式だけを定数評価します。確定しなければUnknownのまま残します。GetCommandName()に代入追跡を期待するのではなく、解析器側に値の表と更新規則を追加する設計です。標準APIの役割と、追加する解析の役割を分けます。[S6] CommandAst.GetCommandName Method

先に完成形を示します。次は解析対象のdataflow-demo.ps1の中身であり、そのまま実行するための手順ではありません。

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

完成ツールにこのファイルを読ませ、未確定値を表示時だけ<unknown>とすると、次の3件に分かれます。環境変数の実際の値は読みません。

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

すぐ使う場合は「手順」の完成コードと配布ZIPへ進めます。仕組みを理解したい場合は、定義と構成の後にある、ASTの観察・状態表・分岐の例を順に読んでください。結果の表は期待内容を示したもので、表示幅による空白の違いは判定対象にしません。

定義

AST

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

ノード

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

トークン

変数や文字列など、コードを字句として区切った要素です。Tokenは種類を示すKind、元の文字列を示すText、位置を示すExtentを持ちます。構文の親子関係を表すASTのノードと、字句の一覧は別の情報です。[S15] Token Class

構文エラー

パーサーがコードを読み取る際に検出した文法上の問題です。解析APIはParseErrorの配列を出力引数に返し、各エラーはErrorId、Message、Extentなどで確認できます。対象を実行したときのエラーとは区別します。[S16] ParseError Class

静的解析

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

データフロー解析

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

定数伝播

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

Unknown

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

役割

正規表現との使い分け

文字列検索や正規表現は「指定した文字の並びがあるか」を探すために使えます。一方、今回知りたいのは「その文字がコマンド名として使われる箇所」です。単語検索だけではコメント・文字列・呼び出しを同じように拾います。標準パーサーで構文を区別し、必要な値の追跡を別の層として足します。

呼び出しの棚卸し

道具の役割は、名前と位置を棚卸しし、修正箇所へ戻れる一覧を作ることです。解決できた行だけを出すと未対応の呼び出しを見落とすので、Dynamicも残します。既存ルールによる品質検査や警告管理は、PSScriptAnalyzerの解説で扱います。ASTの読み方は、その結果や独自ルールを理解する入口にもなります。

構成

入力を読むAPI、構文の種類を表すノード、値の情報を保持する表を組み合わせます。以下のAPIはPowerShellの機能で、定数表や解決区分は本稿の解析器が追加する機能です。

Parser.ParseInput

文字列を解析し、入力全体のASTを返すAPIです。トークンと構文エラーは出力引数で受け取ります。短い観察例ではヒアストリングを渡します。[S1] Parser.ParseInput Method

Parser.ParseFile

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

ScriptBlockAst

解析APIが返す入力全体のルートです。名前付きブロックを明示しない例ではEndBlock.Statementsに通常の文があります。完成関数では入れ子のスクリプトブロックも見つけ、それぞれ独立した状態で解析します。[S11] NamedBlockAst Class

AssignmentStatementAst

代入文を表し、Leftに代入先、Rightに右辺、Operatorに代入演算子を持ちます。完成関数が追跡するのは、対応する通常の変数を左辺とする単純な=です。+=や添字への代入を同じ処理にはしません。[S17] AssignmentStatementAst Class

CommandAst

コマンド呼び出しを表し、CommandElementsに名前や引数の構文要素を持ちます。このツールの一覧化対象です。[S13] CommandAst.CommandElements Property

Ast.FindAll

型などの条件に一致するノードを探すAPIです。第2引数の$trueで入れ子の関数やスクリプトブロックも検索します。完成関数では、最初に呼び出しをすべて確保する走査に使います。[S4] Ast.FindAll Method

GetCommandName

呼び出しから静的に分かる名前を取得するメソッドです。名前を決められないときは$nullを返します。変数の代入をさかのぼって値を計算するAPIではありません。この戻り値だけで終了せず、追加の解析へ渡します。[S6] CommandAst.GetCommandName Method

Extent

ノードに対応する元のコードの範囲です。Textでコード、StartLineNumberとStartColumnNumberで開始位置を取得します。名前が不明でも位置は残せます。[S5] IScriptExtent Interface

定数表と解決区分

定数表は変数名から既知の文字列への対応です。未確定の値を空文字列として登録せず、表から除外します。出力のDirectはAPIで得た名前、VariableConstantは追加した文字列解析で得た名前、Dynamicは未解決です。VariableConstantには変数を介さない括弧内の文字列連結も含めます。

仕組み

解析の流れ

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

呼び出しの確保と値の解析を分けることで、未対応のループや例外処理の中でも、呼び出し自体が消えません。出力の並びはソース位置順であり、プログラムの実行順ではありません。

構文エラーの扱い

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

ノードの走査

FindAllは構文木をたどり、各ノードを判定用のスクリプトブロックへ渡します。型がCommandAstなら結果に残します。ここで実行するのは解析ツール側の判定処理であり、解析対象の関数やスクリプトではありません。値の追跡では、別途Statementsを順に読みます。木の検索結果を位置で並べただけでは、分岐の意味までは扱えません。[S4] Ast.FindAll Method

コマンド名の取得

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

代入による状態の更新

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

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

分岐の合流

ifでは状態を各分岐へ複製します。合流時は、すべての経路に存在し、文字列が完全に一致する変数だけを残します。elseがなければ、何も代入しない経路も比較対象です。条件は実行せず、到達不能な経路の除去もしません。条件に未対応の式がある場合は、まず入力の表を消してから分岐内の新しい代入を追います。

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

呼び出しと未知の副作用

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

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

具体例

最初のAST観察例は、同じPowerShellセッションで上から順に実行します。解析対象はヒアストリングに閉じ込め、その中のコードは実行しません。後半の代入例は解析対象の内容と期待する解析結果を示しています。

コメント・文字列・呼び出し

コメント、文字列への代入、呼び出しを含む3行を解析対象にします。単語検索では3行とも一致しますが、呼び出しは3行目だけです。

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

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

$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

この例はbegin・process・endを明示していないので、EndBlock.Statementsから文を取り出します。

$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で調べます。代入はAssignmentStatementAst、呼び出しを含む文はPipelineAstです。|を書かなくても、コマンドが一つだけのパイプラインとして表されます。[S12] PipelineAst.PipelineElements Property

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

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

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

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

同じオブジェクトから元のコードと呼び出し名を得られます。引数を含む内訳も確認します。

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

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

FindAllの検索結果

実際のスクリプトでは「2件目の文」と決め打ちできません。関数や条件分岐の中にも呼び出しがあるので、型を条件に検索します。

$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は、入れ子の関数やスクリプトブロックも探す指定です。関数を実行せずに、その本体にある呼び出しを取得できます。[S4] Ast.FindAll Method

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

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

$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

変数経由の呼び出し

まず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が代入追跡をしないからです。AssignmentStatementAstも別に存在するので、解析器側で両者を結びます。[S6] GetCommandName [S17] AssignmentStatementAst

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

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

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

変数コピーと文字列連結

次の内容を解析対象にすると、最後の呼び出しは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 ($flag) {
    $command = 'Get-Service'
} else {
    $command = 'Get-Service'
}
& $command

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

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

手順

ファイルではParseInputの代わりにParseFileを使います。完成関数の保存、直接・変数・未解決の比較、複数ファイルのCSV保存の順に進めます。

解析関数を保存する

配布サンプルにも同じ関数があり、配布版にはヘルプコメントがあります。5.1でも日本語を読むファイルは文字コードに注意してください。

CAUTION:

読み込む解析ツールの内容を確認します。対象のスクリプトを実行・ドットソースして読み込む手順ではありません。

  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単体の結果を混同しないでください。

呼び出し位置を抽出する

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

CAUTION:

ドットソースするのは内容を確認した解析ツールだけです。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で読んだだけで、実行していません。

結果をCSVに保存する

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

CAUTION:

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で未解決を判定します。

確認

期待結果の照合

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

確認対象 期待する状態
コメント・文字列・呼び出しの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を取得できない場合も本文の完成コードを保存して使えます。

回帰テストの対象

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

追加テストは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の実行結果と対象コミットを照合してください。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

定数追跡の対応範囲

追跡するのは、修飾のない通常のローカル変数への単純代入、文字列リテラル、変数コピー、文字列同士の連結、括弧、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()

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

関連記事

参考資料

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

ブログ一覧に戻る