Coding Agent向けにソースコード構造解析を試す:Call Graph、CFG/DFG/PDG、Ansible、Provenanceの使い分け
Posted:
Coding Agent向けにソースコード構造解析を試す:Call Graph、CFG/DFG/PDG、Ansible、Provenanceの使い分け
Coding Agentに大きなrepositoryを読ませるとき、単純にsource fileを大量にcontextへ投入する方法には限界がある。
大きなmodelならかなり読めるが、それでも「どのfileから読むべきか」「どのfunctionが中心なのか」「値がどこから来ているのか」「複数repositoryをまたぐ処理は何なのか」を毎回sourceから探索するのは高コストである。特に小型のlocal LLMでは、repository全体をcontextへ入れる方法は現実的ではない。
そこで、sourceから決定論的に構造情報を抽出し、
- Coding Agentが読むべきsourceを絞る
- 人間がarchitectureを理解する材料にする
- refactoring前後の構造差を確認する
- 小型LLMにはboundedなcontextだけを渡す
という用途に使えないか、homeclusterのPython / Ansible中心のcodebaseでいくつかの解析方法を試した。
今回試したものは次の通りである。
- Symbol Index
- Call Graph
- module / type / provenance
- receiver method resolution
- Control Flow Graph(CFG)
- Data Flow Graph(DFG)
- Program Dependence Graph(PDG)
- Ansible domain graph
- inventory provenance graph
- cross-repository contract graph
- bounded structural slice
結論から言うと、どれか1つが勝者というより、それぞれ違う層で有用だった。
さらに実際に作ってみると、すべてを同じ頻度で更新するより、
普段は軽量なstructural indexを継続的に最新化し、難しい調査やarchitecture reviewのときだけDeep解析を実行する
という二層構成が良さそうだと分かった。
この記事では、解析手法の一般論から入り、実際に試して分かったメリット・デメリット、Coding Agentへ任せる場合の設計、自動化で気を付ける点、他言語へ広げるときの考え方まで整理する。
ソースコード解析にはいくつかの「深さ」がある
「source codeを解析する」と言っても、何を知りたいかによって必要な解析は違う。
最も単純なのは、fileやsymbolを探すindexである。
repository
|
+-- module A
| +-- function foo
| +-- class Bar
|
+-- module B
+-- function baz
ここから一段進むと、function同士の呼び出し関係を調べるCall Graphになる。
foo
|
+--> validate
|
+--> build_plan
|
+--> execute
さらに、条件分岐やloopを含めた「実行経路」を見るのがCFGである。
start
|
v
validate
/ \
pass fail
| |
v v
execute return
|
v
end
値の由来や利用先を見るのがDFGである。
source_revision
|
v
validation
|
v
receipt
|
v
authorization
制御依存とデータ依存を重ねるとPDGになる。
そして実システムでは、Pythonだけでは完結しない。
Pi UI
|
v
Python controller
|
v
Ansible launcher
|
v
playbook / role
|
v
inventory
このようなrepositoryや言語をまたぐ関係には、Call Graphだけでは足りない。
そこで、Ansible用のdomain graph、inventory provenance、明示的なcross-repository contractを別に持つ必要が出てきた。
今回試した解析手法の比較
| 手法 | 主に分かること | メリット | デメリット | 日常更新 | 人間への説明 |
|---|---|---|---|---|---|
| Symbol Index | 定義の位置、module構造 | 安価、確実、navigationに強い | 処理の意味までは分からない | ◎ | ○ |
| Call Graph | 誰が誰を呼ぶか | 低コストで構造が見える | dynamic callが難しい | ◎ | ◎ |
| type/provenance | receiverや値の由来 | Call Graph精度が上がる | 推論を深くすると複雑 | ◎ | ○ |
| CFG | 分岐、loop、return、exception | 実行経路の説明に強い | 言語意味論への追従が必要 | △ | ◎ |
| DFG | 値の定義と利用 | 「この値はどこから来たか」に強い | aliasやruntime値が難しい | △ | ○ |
| PDG | control + data dependency | refactor調査に強い | graphが大きくなる | △ | ◎ |
| Ansible graph | play/role/task/when/register等 | IaCの構造を自然に表せる | Jinja等のdynamic部分は難しい | ◎ | ◎ |
| Inventory provenance | source→render→consumer | provenance説明に非常に強い | 対象domainは狭い | ◎ | ◎ |
| Contract Graph | repo間の意味的な関係 | architectureの骨格になる | 完全自動化しにくい | ◎※ | ◎ |
| Bounded Slice | taskに必要な部分だけ抽出 | 小型LLMに向く | seed/edge選択設計が必要 | 都度 | ○ |
※既存contractのsource evidence validationは自動更新し、新しいcontractのauthority登録は半自動にする。
Symbol IndexとCall Graphは費用対効果が高い
最初に残すべきものを1つ選ぶなら、Symbol IndexとCall Graphである。
ASTから、
- module
- class
- function
- method
- call site
を抽出するだけでも、repositoryを読む順番がかなり分かる。
例えば大きなcoordinator functionがあった場合、
run_operation
-> validate_source
-> load_state
-> make_plan
-> authorize
-> launch
-> persist_result
のようにfan-outが見える。
今回の最新Deep snapshotでは、Python graphの中で大きなrun_operationがdistinct local fan-out 62を持つことが分かった。
sourceを読めば当然分かる情報ではあるが、「どこから読むべきか」を最初に機械的に絞れる点が大きい。
Call Graphには弱点もある。
Pythonでは次のようなcallは簡単ではない。
handler = registry[name]
handler.run()
あるいは、
value.get()
のvalueの型が静的に確定しない場合、method targetを断定できない。
ここで無理にedgeを作るとfalse positiveになる。
そのため今回は、
local_same_modulelocal_cross_modulereceiver_methodimported_externalbuiltindynamicunresolvedrepo_local_unindexed
のように分類し、分からないものは分からないまま残した。
これはCoding Agent用の解析では重要だと思う。
間違った確信を与えるgraphより、unknownを明示したgraphの方が安全である。
type / provenanceはCall Graphの補助として有用だった
Call Graphの精度を上げるため、module binding、constructor、annotation、self / clsなどを使ってreceiver resolutionも試した。
例えば、
client = ControllerClient(...)
client.observe()
でclientがControllerClientだと分かれば、observe()のtargetを絞れる。
この層は単体で人間が見ることは少ないが、Call Graphの品質にかなり効く。
一方、ここを本格的な型推論器にしようとすると急激に難しくなる。
- union
- generic
- runtime factory
- monkey patch
- dependency injection
- dynamic import
- descriptor
- metaprogramming
まで追い始めると、小さな自作解析器の範囲を超える。
今回の目的はcompilerを作ることではない。
そのため、
一意に静的解決できるところだけ解決し、曖昧ならdynamic/unresolvedへ落とす
という方針が良かった。
CFGは「直接見る成果物」より基礎データとして価値がある
CFG(Control Flow Graph)は、statement間の制御の流れを表す。
典型的には、
- if / else
- loop
- break / continue
- return
- raise
- try / except / finally
- with
- match
などをnodeとedgeへ落とす。
CFGがあると、「どの条件の下で処理されるか」が見える。
ただし、CFGを実装してみると、言語仕様への追従が必要になることも分かった。
特にPythonのtry/finallyでは、
try:
return result
finally:
release_lock()
のreturnはfinallyを飛び越えてはいけない。
初期実装ではこのようなabrupt completionを正しくfinalbodyへ流せないケースがあり、fixtureを追加して修正した。
この経験から、CFGには次の特徴があると感じた。
- 人間への説明には非常に分かりやすい
- PDG/DFGを作る基礎として重要
- ただし言語仕様の細部にバグが入りやすい
- 「完全な実行意味論」と主張しない方がよい
つまり、日常的なindexとして毎回人間が読むより、Deep解析の基盤として維持するのが良さそうである。
DFGは小型LLMとの相性が良い
DFG(Data Flow Graph)は、「値がどこで定義され、どこで使われるか」を表す。
例えばsource codeが数百行離れていても、
source_revision
|
v
validation_result
|
v
receipt
|
v
authorization
という形へ圧縮できる。
小型LLMへsource全体を渡して「authorizationに影響する値を探して」と頼むより、候補となるDATA_DEP edgeを先に渡した方が楽である。
今回の実装では、関数内のreaching definitionsを中心に扱い、exactに解決できたcallについてargument→parameter、return→callも結んだ。
ただしDFGにも限界がある。
- alias
- mutable object
- global state
- arbitrary interprocedural flow
- runtime data
- side effect
を完全に扱うのは難しい。
ここでも、compiler-gradeの完全性より、
source navigationに使える保守的な近似
を目標にする方が現実的だった。
PDGはDeep解析として特に有望だった
PDG(Program Dependence Graph)は、Control DependencyとData Dependencyを合わせる。
これは「なぜこの処理がここで実行されるのか」を調べるときに強い。
最新のDeep snapshotでは、大きなcontroller functionについて、
- 約3,000行
- distinct local fan-out 62
- 最大CONTROL_DEP 433
という集中が見えた。
これだけで、
このfunctionは単なるwrapperではなく、state、validation、authorization、launch、terminal処理が集まるcoordinator hotspotらしい
という調査仮説を立てられる。
もちろん、「CONTROL_DEPが多いから悪いcode」と自動判定するわけではない。
graphは候補を示すだけで、最終判断はsourceとfixtureへ戻る。
PDGの難点はgraphが急速に大きくなることだ。
最新のPython Deep graphは約19,000 node、67,000 edge規模になった。
この規模をそのままLLMへ渡すのは意味がない。
PDGはDeep解析用のmachine-readable substrateとして保存し、taskごとにsliceするのが良い。
AnsibleはPythonと同じgraphに無理やり入れない方が良かった
今回特に有用だったのがAnsible domain graphである。
Ansibleを普通のprogramming languageのASTとして扱うより、
- play
- role
- task
- handler
- include/import
- when
- register
- notify
- template/copy source
- variable reference
というdomain固有のrelationとして表現した方が自然だった。
例えば、
Playbook
|
v
Role
|
+--> Task A
| |
| +--> WHEN
| +--> REGISTER
|
+--> Task B
|
+--> NOTIFY
|
v
Handler
という形である。
これは人間にも読みやすい。
また、Coding AgentがAnsibleを変更するときも、「このtaskを消すとどのhandlerやregister利用へ影響するか」というnavigationに使える。
一方でJinjaやdynamic includeを完全に評価しようとすると危険である。
今回はJinjaを実行せず、静的に読めるrelationだけをgraph化した。
この経験から、他言語へ広げるときも、
すべてを1つのgeneric graphへ押し込むのではなく、domain固有の意味を持つgraphを作る
方が良さそうだと思う。
Inventory Provenanceは小さいgraphでも価値が高い
inventory provenance graphはnode数としては小さい。
しかし、人間への説明という点では非常に強かった。
encrypted source
|
| READS
v
renderer
|
| PRODUCES
v
generated inventory
/ | \
v v v
validator controller Ansible
SREやInfrastructure as Codeでは、
- authoritative sourceは何か
- derived fileは何か
- 誰が生成するか
- 誰が検証するか
- 誰がconsumerか
が非常に重要である。
これは単なるCall Graphでは表しにくい。
特にsecretを扱うsystemでは、「値そのもの」をgraphへ入れなくても、pathとprovenanceだけで十分役に立つ。
今回もencrypted contentを解析対象へ入れず、source path、renderer、generated inventory、validator、consumerという関係だけを保持した。
Cross-repository Contract Graphはarchitectureの骨格になる
今回もっとも「人間への説明」に効いたものの1つがcross-repository contract graphだった。
実際のhomeclusterでは処理が1 repositoryで閉じない。
概念的には、
Pi command
|
+--> interaction
| |
| +--> status
|
+--> progress
| |
| +--> advance state / operation spec
|
+--> act
|
+--> interaction
|
+--> advance
|
+--> authorization
+--> launcher
+--> acceptance
という流れがある。
Python Call Graphだけでは、repository間やAnsibleとの境界を十分に説明できない。
そこで、source evidenceを伴う明示的なcontract edgeを別に持った。
このgraphはarchitecture diagramの「骨格」になる。
ただし、ここは完全自動化しすぎない方がよい。
file名やfunction名が似ているだけで意味的なcontractを作るのは危険である。
そのため、
- 既存contractがsource上でまだ成立しているかは自動検証する
- 新しいcontract候補を機械的に提示するのはよい
- authority edgeへ昇格するのはsource evidence確認後
という半自動が適している。
実運用では、最初はcontract evidenceを path + line + contains で固定していた。しかし、sourceにコメントや処理が追加されただけでもlineがずれ、意味的な関係は変わっていないのにDeepがfail-closedするケースが出た。
そこでregistry側のauthorityをline番号ではなく、次のようなsemantic selectorへ移した。
unique_containspython_containspython_call
現在lineはpinned sourceから毎回導出する。これにより単なるline relocationは自動追随できる。
一方で、0 matchや想定外の複数matchはfail-closedする。operationや責務そのものが変わった場合も自動修復しない。
実際、Pi commandが旧来の status / advance 直呼びから interaction / progress / act facadeへ変わっていたケースでは、単なるline driftとして追随させず、contract topologyそのものをsource reviewして更新した。
つまり、
同じ意味の関係を一意に再発見できる変更は自動追随し、意味が変わった可能性がある変更は止める
という境界にした。
fuzzy matching、embedding、LLMの推測をauthority edgeの自動更新には使っていない。
Bounded SliceがCoding Agentとの接点になる
大きなgraphを作っても、そのままLLMへ渡してはいけない。
今回のPython graphは約17,000 node、60,000 edge規模になった。
小型LLMはもちろん、大きなLLMでもこのまま渡す意味は薄い。
そこでtaskごとにseedを決め、
- hop数
- node数
- edge数
- rendered byte数
を制限してbounded sliceを作る。
例えば、
seed: authorization contract
max_hops: 3
max_nodes: 80
max_edges: 160
max_render_bytes: 4096
のようにする。
するとCoding Agentには、
このtaskで読むべきsymbol
この周辺のCALLS
この周辺のDATA_DEP
関連するcontract
source path / line
だけを渡せる。
ここで重要なのは、graphそのものに答えを出させないことである。
graph
|
v
読むべきsourceを絞る
|
v
Coding Agentがsourceを確認する
という順番にする。
source remains authoritativeという境界は崩さない。
実際に試して分かったこと
「全部Deep」は不要だった
すべてのsource変更でCFG、DFG、PDGまで再生成する必要はない。
普段のnavigation用途では、
- Symbol
- Call
- type/provenance
- receiver resolution
- Ansible graph
- inventory provenance
- contract evidence validation
があればかなり役に立つ。
これらは軽量なcurrent structural indexとして、日常的に更新したい。
Deep解析はarchitecture reviewに向く
CFG / DFG / PDGは、
- 大規模refactor
- state machine変更
- authorization変更
- acceptance変更
- coordinator分割
- incident後のdependency調査
のような場面で価値が高い。
日常indexではなく、必要時に昇格する解析として扱う方が良い。
Domain Graphは汎用graphより説明力が高い
AnsibleやinventoryをPython風のgeneric graphへ無理に変換するより、domain用relationを持たせた方が使いやすかった。
これはKubernetes、Terraform、GitHub Actionsなどにも当てはまりそうである。
unresolvedを残すこと自体が品質になる
Coding Agent向けの補助情報では、間違ったedgeが非常に危険である。
静的に断定できないものをdynamicやunresolvedとして明示する方が良い。
coverageを100%に見せることより、confidence boundaryを守ることを優先する。
graphは人間向け資料の材料にもなる
当初はlocal LLM向けcontext圧縮を主目的に考えていたが、実際には人間への説明にもかなり有効だった。
特に、
- Cross-repository Contract Graph
- Inventory Provenance
- Ansible Domain Graph
- CFG / PDGのbounded slice
は図を作るときの下調べとして強い。
図を毎回sourceから手作業で起こすより、
contract graph
+
domain graph
+
必要箇所のPDG slice
|
v
architecture / sequence / provenance diagram
という流れの方が作りやすい。
ただし図そのものをauthorityにはしない。
将来自動図生成する場合も、
source
|
v
structural graph
|
v
view model
|
v
diagram
というderived chainにしたい。
実験から継続運用へ移して分かったこと
最初の実験では「graphを作れるか」「Coding Agentのnavigationに使えるか」を中心に見ていた。
その後、Deep解析を固定procedureとして何度か実際の5 repositoryへ適用し、Lightweight current indexまで運用化したことで、いくつか追加の知見が得られた。
構造解析は実際のcross-repository source gapを見つけられた
contract graphを現行sourceへ再検証したところ、controllerが参照するinfra validator 2件がpinned infra revisionに存在せず、unresolvedとして残った。
最初は「validatorが削除されたのか」と考えたが、Git historyとbranchを確認すると事情は違った。
実装はinfraのstaging branchには存在し、identity probeを含む一連のsource-only mechanismも成立していた。しかしmainへpromoteされていなかった。
つまり問題は、
controller main
|
+--> fixed infra validator path
|
X infra mainには未配置
というcross-repository sourceの不整合だった。
必要なsourceをmainへpromoteし、contract registryにもidentity probeとvalidatorを正式なnodeとして追加した後、Deepを再実行するとcontract graphは、
before: 27 nodes / 28 edges / unresolved 2
after : 31 nodes / 32 edges / unresolved 0
へ収束した。
興味深かったのは、このときPython / Ansible / Inventoryのartifactは前回とbyte単位で一致し、変化したのがcontract layerだけだったことである。
構造解析は単なる可視化ではなく、複数repository間の固定依存が本当に揃っているかを検査する道具としても使えた。
「バグを見つける」以外の結果も重要だった
Deep解析で見つかったものは、すべてがバグではない。
今回のPi Ops世代進行処理では、静的source照合の範囲で新しい明確なlogic bugは見つからなかった。
代わりに結果は大きく4種類へ分けられた。
confirmed source issue
-> cross-repository validator / probeのmain未配置
structural risk
-> advance.run_operationへの責務集中
expected complexity
-> facade関係やinventory provenanceはsourceと一致
analyzer limitation
-> alias resolution等をunknownとして残す
特に大きなcoordinator functionは約3,000行、distinct local fan-out 62、最大CONTROL_DEP 433だった。
これは「バグ」と自動判定する材料ではない。しかし、state transition、authorization、source validation、launchなどが同じ入口へ集中しているため、将来のrefactor候補として監視する価値はある。
逆に、graphが複雑でもsourceと設計が一致しているなら、それはexpected complexityとして扱う。
構造metricから異常を決めつけるのではなく、調査候補を作りsourceへ戻るという使い方が重要だった。
Structural metricは同じ条件で比較して初めて意味が強くなる
最新DeepではPython graphが約19,000 node / 67,000 edgeまで増えた。
しかし、以前のsnapshotより数字が増えたことだけでsource regressionとは判断しなかった。
source revisionやgeneratorの意味論が違えば、graph sizeも変わるからである。
metricを時系列で使うなら、
- 同じgenerator semantics
- 同じprofile
- 同じ分類規則
を揃えたgraph diffが必要になる。
単発の絶対値はhotspot探索には使えるが、品質の上昇・悪化を示すscoreとして扱わない方がよい。
Deep workflowはCoding Agentへ渡せる固定procedureになった
実際の5 repositoryをcleanなdetached checkoutへ固定し、
preflight
-> fixtures
-> build
-> full byte-for-byte reconstruction/check
-> hygiene
-> freshness
を回すと、30秒弱で完了した。
bounded sliceもfull graphをmodelへ投入せずに利用できた。
これにより「Agentに自由に解析手順を考えさせる」のではなく、Agentは固定wrapperを呼び、結果だけを利用する形にできた。
Lightweight current indexも実装した
当初はLightweight / Deepの二層化は設計案だったが、その後current indexまで実装した。
Lightweightでは、
- Python symbol / call / provenance / receiver resolution
- Ansible graph
- inventory provenance
- contract validation
だけをfull regenerateする。
CFG / DFG / PDGはimportすらしない。
またgenerator digestもmode-specificにし、CFGだけを変更した場合はLightweight current indexをstaleにしない。
概念的には、
freshness
|
+-- fresh --> current indexを利用
|
+-- stale --> Lightweight refresh
|
+--> taskが必要ならDeep
となった。
最初からfile-level incremental engineを作らず、軽量domainをfull regenerateする設計でも十分扱いやすい。
Lightweightの境界はdenylistではなくallowlistで定義する
Lightweight実装のreviewでは、最初にDeep専用edgeとして、
HAS_CFGCONTROL_DEPDATA_DEP
だけを禁止していた。
しかしCFGは NEXT や分岐edgeなど他のtypeも生成できるため、「既知のDeep edgeを禁止する」だけでは不十分だった。
そこでPython Lightweight graphでは、base graphとして許可するedgeを、
CONTAINS
HAS_CALL
CALLS
の3種類へ明示的に限定した。
未知edgeが追加されたらfail-closedする。
これは一般化すると、
Lightweightとは「Deepでないもの」ではなく、Lightweightとして許可した意味論の集合で定義する
という設計原則になる。
parserやschemaが将来拡張されても、意図しない解析結果がcurrent indexへ静かに混入しにくい。
LightweightとDeepの二層構成
現在は、次の二層構成まで実装した。
flowchart TD
S[Source change] --> F[Freshness check]
F --> L[Lightweight refresh]
L --> SI[Symbol / Call]
L --> TP[Type / Provenance]
L --> AG[Ansible graph]
L --> IP[Inventory provenance]
L --> CV[Contract evidence validation]
SI --> C[Current structural index]
TP --> C
AG --> C
IP --> C
CV --> C
C --> Q{Deep analysis needed?}
Q -- No --> U[Use lightweight index]
Q -- Yes --> D[Deep analysis]
D --> CFG[CFG]
D --> DFG[DFG]
D --> PDG[PDG]
D --> FC[Full deterministic check]
D --> BS[Bounded slices / summary]
Lightweight refreshを実行する候補は、
- Coding Agentによるsource edit完了時
- PR validation
- main merge後
- Agentがindexを使う直前にstaleを検出した時
である。
常駐daemonにする必要まではない。
Deepは、
- architecture review
- 大きなrefactor前後
- state / authorization / acceptance変更
- cross-repository contract変更
- 人間が明示的に要求した時
に実行する。
CodexやPi Coding Agentへ任せるなら固定procedureにする
解析自体をLLMに自由に組み立てさせるのではなく、決定論的なwrapperへ寄せる方が安全である。
例えばDeepなら、
clean pinned source
|
v
preflight
|
v
fixtures
|
v
build
|
v
full byte-for-byte check
|
v
repository hygiene
|
v
bounded slice / summary
という固定手順にする。
Coding Agentはこのwrapperを呼び、結果を読む。
Agentへ、
好きなcommandで5 repositoryを解析して
と依頼する構造にはしない。
Agentに任せてよいこと
- read-only source checkout
- exact revision固定
- freshness check
- lightweight refresh
- structural fixtures
- Deep build
- full check
- bounded slice
- summary比較
- graph diff
- sourceへのnavigation
任せないこと
structural analysisの名目で、
- live PXE operation
- Ansible apply
- Terraform apply
- SOPS decrypt / mutation
- real inventory mutation
- controller runtime mutation
- arbitrary shell command execution
- operator worktreeのreset / clean / stash
を行わせない。
source analysisとruntime operationのauthorityを分ける。
自動化で一番重要なのはstalenessだった
generated graphは便利だが、古いgraphを最新sourceのものだと思って使うと危険である。
そのためmanifestへ、
- source repository revision
- generator revision
- generator digest
- profile digest
- contract digest
- interpreter version
などを記録し、利用前に再構築して一致を確認する。
概念的には、
source revision changed
|
v
graph is stale
|
v
refresh required
である。
生成物をcommitして人間がreviewできるようにする場合でも、commitされているから正しいとは限らない。
sourceとgeneratorから再現できることが重要である。
今回の実験では同じpinned inputから再生成し、byte-for-byte一致することを確認する方式を採った。
これはCoding Agentへ任せる場合にも相性が良い。
「たぶん同じ」ではなく、machine checkで一致を確認できる。
incremental updateは最初から必要とは限らない
常時更新というと、変更されたfunctionだけ解析するincremental engineを作りたくなる。
しかし最初からそこまで複雑にする必要はない。
まずは、
Lightweight domainだけfull regenerate
が十分速いか測る方が良い。
Symbol / Call / Ansible / provenance程度なら、repository規模によってはfull regenerateでも問題にならない。
incremental cacheには、
- invalidation
- dependency tracking
- partial failure
- cache poisoning
- version mismatch
という別の難しさが入る。
実行時間が本当に問題になってから導入すればよい。
人間向け説明資料として何を残すか
machine graphだけでは人間には読みにくい。
一方で、手作業のarchitecture documentだけではstaleしやすい。
その中間として、次を残すのが良さそうである。
generated/
structural-index
summary
selected slices
docs/
architecture interpretation
design decision
limitations
つまり、
- raw graphはmachine用
- summary/sliceは人間とAgentの共有層
- design documentは意味づけ
と分ける。
人間向けsummaryには、
- largest functions
- high fan-in/fan-out
- unresolved count
- control/data dependency concentration
- important contract edges
- provenance chains
などがあると便利だった。
他言語へ広げる場合
今回の実験はPythonとAnsibleが中心だったが、考え方自体は他言語でも使える。
Go
Goは静的型付けなので、PythonよりCall Graphやreceiver resolutionを精度高く作りやすい。
go/packagesやcompiler/type情報を使えば、
- symbol
- package dependency
- interface implementation
- call graph
をかなり正確に取れる。
一方、interface経由のdynamic dispatchやreflectionは別扱いにする必要がある。
Rust
Rustもcompiler frontendを利用できれば強い。
- module
- trait
- impl
- ownership
- call relation
- type relation
を取れる。
ただしmacro expansion前後をどう扱うかが重要になる。
source navigation目的なら、compiler内部表現を全部保存するより、source locationへ戻せるbounded IRが良さそうである。
TypeScript / JavaScript
TypeScript Compiler APIを使えば、
- symbol
- import/export
- type
- call
- class/interface
を扱える。
JavaScriptだけになるとdynamic性が上がるので、Python同様にunresolvedを明示する設計が重要になる。
Terraform
Terraformは普通のCall Graphではなくdomain graphが向く。
例えば、
module
resource
data source
variable
output
provider
depends_on
reference
をrelationにする。
stateをsource graphへ直接混ぜず、desired configurationとruntime stateを別domainとして扱う方が安全だと思う。
Kubernetes YAML
Kubernetesもdomain graphとして、
Deployment
-> Service
-> ConfigMap
-> Secret reference
-> PVC
-> ServiceAccount
のようなrelationを作れる。
label selectorやnamespaceを使ったbindingは便利だが、曖昧な場合は推測しない。
GitHub Actions
workflowでは、
workflow
-> job
-> step
-> action
-> reusable workflow
-> artifact
-> environment
のようなgraphが考えられる。
ここでもsecret value自体は扱わず、参照関係だけを保持する。
共通IRを作るなら「全部同じ意味」にしない
他言語へ広げると、1つの巨大schemaに統一したくなる。
しかし、
Python CALLS
Ansible NOTIFIES
Terraform REFERENCES
Inventory PRODUCES
は意味が違う。
共通化するなら、
- node id
- repository
- path
- line
- domain
- edge type
- evidence
- confidence
のような最低限のenvelopeに留め、domain-specific edge vocabularyを残した方がよいと思う。
概念的には、
Canonical envelope
|
+-- Python domain
+-- Ansible domain
+-- Inventory domain
+-- Terraform domain
+-- Kubernetes domain
という形である。
これならbounded slicerやgraph storageは共通化しつつ、domainの意味を失わない。
今回の実験で得た設計原則
sourceをauthorityにする
graphはnavigation dataであり、sourceの代わりではない。
重要な変更判断では必ずsourceへ戻る。
unknownを隠さない
staticに解決できないcallやbindingはunresolvedのまま残す。
false certaintyを避ける。
LightweightとDeepを分ける
毎回PDGまで作らない。
普段はSymbol / Call / provenance / domain graphを最新化し、必要なときだけDeepへ進む。
domain graphを使う
Ansibleやinventoryの意味をgeneric Call Graphへ無理に押し込まない。
full graphをLLMへ渡さない
LLMへ渡すのはbounded slice。
graphは検索indexとして使う。
generated artifactはstaleになり得る
source revision、generator、profile、contractをmanifestで固定し、利用前に検証する。
contractは完全自動化しない
source evidenceの検証は自動化する。
新しい意味的contractのauthority化はreviewを通す。
Agentには自由な解析shellではなくfixed procedureを渡す
CodexやPi Coding Agentが同じwrapperを使えば、再現性と安全境界を保ちやすい。
まとめ
最初は「小型local LLMへ大きなrepositoryをどう読ませるか」という問題から始めた。
しかし、実際にSymbol / Call Graph、CFG、DFG、PDG、Ansible graph、inventory provenance、cross-repository contractを作ってみると、用途はそれだけではなかった。
特に有用だったのは、
- Coding Agentが読むべきsourceを絞る
- architecture hotspotを見つける
- repositoryをまたぐ責務を説明する
- 人間向けの図や設計資料を作る下調べにする
- refactor前後の構造を比較する
という使い方である。
今後は、
普段
-> Lightweight structural indexを継続更新
難しい調査
-> CFG / DFG / PDGを含むDeep analysis
Agentへの入力
-> bounded slice
人間への説明
-> summary + contract/provenance/domain graph
という分担にしていきたい。
Lightweight refreshの固定入口とDeep analysisの固定wrapperまでは実装できた。
次に試したいのは、新しい解析アルゴリズムを増やすことよりも、
- Codex / Pi Coding Agentが利用前にfreshnessを確認する統合
- staleならLightweightを自動refreshする固定flow
- taskの必要性に応じてDeepへ昇格するpolicy
- old/new structural graph diff
- 同一generator/profile条件でのmetric時系列比較
である。
その基盤が安定した後なら、Go、Rust、TypeScript、Terraform、Kubernetesなど別の言語・domainへ同じ考え方を広げて比較してみるのも面白そうである。