コードをセマンティックに分割するライブラリcode-chopperの紹介と3つの作例
概要
コードを意味的に分割するTypescriptライブラリcode-chopperを作ってみました。
主な応用として
- レポジトリの関数・クラス宣言を一覧として表示する(ctags/Aiderのrepomapに近い)
- 関数・変数間の依存関係をKatz中心性で解析して、コードベース中でどのエンティティが重要かを調べる
- 関数の実装を順にとってきて、それぞれのドキュメントをLLMに自動で書かせる
といったことが実装できます。具体的な実装は以下の作例集にまとまっています。
作例:
インストールと使い方
導入
npm/bunなどで導入できます。bunがおすすめです。
$ npm install code-chopper
# or
$ bun add code-chopper
DeepWikiとllms-full.mdもあります。
例
Pythonコードを解析する例
import { createParserFactory, parseCodeAndChunk } from "code-chopper";
/**
* 解析対象のPythonコード
*/
const pythonCode = `
class MyClass:
def __init__(self, name):
self.name = name
def greet(name):
print(f"Hello, {name}!")
greet("World")
`;
const factory = createParserFactory();
// parseCodeAndChunkを使用してPythonコードを解析
const chunks = await parseCodeAndChunk(pythonCode, "python", factory, { filter: () => true });
// 解析結果をそのままコンソールに出力
console.log(chunks);
実行結果
[
{
content: "class MyClass:\n def __init__(self, name):\n self.name = name",
startOffset: 1,
endOffset: 70,
filePath: "",
boundary: {
type: "class_definition",
name: "MyClass",
parent: [],
docs: "",
},
}, {
content: "def __init__(self, name):\n self.name = name",
startOffset: 20,
endOffset: 70,
filePath: "",
boundary: {
type: "function_definition",
name: "__init__",
parent: [ "MyClass" ],
docs: "",
},
}, {
content: "self.name = name",
startOffset: 54,
endOffset: 70,
filePath: "",
boundary: {
type: "assignment",
name: "name",
parent: [ "MyClass", "__init__" ],
docs: "",
},
}, {
content: "def greet(name):\n print(f\"Hello, {name}!\")",
startOffset: 72,
endOffset: 117,
filePath: "",
boundary: {
type: "function_definition",
name: "greet",
parent: [],
docs: "",
},
}
]
モチベーション
LLMにコードを解析させるとき、どのように分割するか悩むことが多いです。最近のLLMはコンテキスト長が長いのでファイル全体を読み込ませてもよいのですが、RAGの実装などでは短く切りたい場合も多いです。自然言語であれば、「。」「、」などで区切ればいいのですがコードの場合その区切りは曖昧です。
上記のことに悩んでいるとき、次の記事に出会いました。 これこそまさに欲しかったものだ!と思って勢いそのままで、分割部分を改造してライブラリとして切り出して公開しました。
機能
元記事のgistdexと同じく、与えられたコードをtree-sitterで解析して
- 関数宣言
- 変数・定数宣言
- クラス・構造体宣言
のような意味的な区切りで抽出します。
オリジナルと違う点として
- npmパッケージとして、外部からライブラリとして利用可能
- optionにfilter関数を設定することでさらに絞り込むことができる
- ファイル、ディレクトリを探索する補助関数が定義されている
の3つが挙げられます。
filter関数
code-chopperでは各ノードを再帰的にスキャンして、意味的な区切り(チャンク)であると判断した場合、リストに追加していきます。filter関数はこの追加時に呼ばれる関数で
- true: そのままノードを追加
- false: ノードをリストに追加しない
という動作を行います。これによって、不要なnodeを省きます。
filter関数にはlang,nodeという2つの引数があります。langは解析対象の言語でnodeは今まさに追加しようとしているチャンクを表現するSyntaxNode(tree-sitterの型)です。
下記の例では
- nodeがimportに関連しているノード
- TS/JSの関数宣言以外の変数・定数ノード
をチャンクの対象外としています。
filter関数の例
const options: Options = {
filter: (lang, node) => {
// Exclude import statements
if (node.type.includes("import")) {
return false;
}
if (lang === "typescript" || lang === "javascript") {
if (node.type === "variable_declaration" || node.type === "lexical_declaration") {
// Filter out variables that are not arrow functions
const isArrowFunction = node.children.find(c => c.type === "variable_declarator")?.childForFieldName("value")?.type === "arrow_function";
return isArrowFunction;
}
}
return true;
}
}
応用例
repo_summary
repo_summaryはAiderのrepomapを模した機能で、プロジェクトの関数・変数の宣言を一覧化できます。実装を省くことで、トークン数を節約しつつLLMにプロジェクトの全体像を伝えることができます。
エージェントなどにrepo_summaryの出力を渡すことで、検索やコード編集時にどのコードをコンテキストに追加するかの判断精度を高められます。
実行結果(抜粋)
repo_summary.ts:
|...
|const factory = createParserFactory();
|const options: Options = {
|...
|const arg2 = process.argv.at(2);
|let p = arg2 ? path.join(process.cwd(), arg2) : path.dirname(fileURLToPath(import.meta.url));
|const res = await readDirectoryAndChunk(factory, options, p);
|const eachIndent = ' ';
|const indentFormat = (str: string, indentLevel: number): string => {
|...
|let filename = "";
|const content = r.content.split("\n");
entity_rank
entity_rankは変数や関数などのエンティティの参照関係をグラフで表現し、それぞれのエンティティのKatz中心性を求めます。Katz中心性はグラフ理論における指標の一つで節点同士の参照関係から、それぞれのノードの重要度をランク付けします(PageRankと近い概念です)。 例の実装はかなり簡易的ですが、どの変数や関数が重要かがある程度把握できると思います。実用上では限られたコンテキストの中でどのコードが重要かを取捨選択・ランク付けするために応用できます。
実行結果
KatzCentrality for entities:
{
DocumentBuilder: 1,
calculateEntityKatzCentrality: 0.8574053182328238,
code: 0.5117333765301518,
content: 0.42439776933810786,
createDirectoryAndWriteFile: 0.42335815793538895,
directory: 0.3822163525174164,
res: 0.36775998923926045,
dir: 0.3424546193939786,
ranks: 0.31008530276398377,
docs: 0.275160760645688,
filePath: 0.27016151755225937,
factory: 0.2539661970592181,
options: 0.2497061364864843,
prompt: 0.23188480640750184,
chunks: 0.22588580616913195,
document: 0.19576253230064086,
resGroupedByFilename: 0.1896462278841631,
isArrowFunction: 0.1832851977911665,
detailsOfEachFile: 0.16992973410334117,
boundaryStack: 0.15126347039941312,
text: 0.13628701470244253,
client: 0.13628701470244253,
setFilePath: 0.13628701470244253,
addText: 0.13628701470244253,
callAPI: 0.13628701470244253,
writeDocument: 0.13628701470244253,
clearText: 0.13628701470244253,
builder: 0.13628701470244253,
callAPIStub: 0.13628701470244253,
justPrint: 0.13628701470244253,
parent: 0.12202755000975517,
entities: 0.12202755000975517,
graph: 0.12202755000975517,
callerName: 0.12202755000975517,
pageRanks: 0.12202755000975517,
topics: 0.10039521569690825,
arg2: 0.07450866467331281,
sorted: 0.06729556231914319,
language: 0.05947551759819827,
pythonCode: 0.058875616119083325,
filename: 0.05328001251069328,
topicsStack: 0.04632656256373787,
eachIndent: 0.04031893560869481,
indentFormat: 0.04031893560869481,
p: 0,
}
doc_generator
doc_generatorはコードからドキュメントを自動で作成します。code-chopperによりエンティティを抽出し、それぞれについて指示を与えることで、ドキュメントの粒度とコードの粒度に対応関係を持たせることができます。
サンプルコードでは関数・変数の参照関係を一切与えていないため、コンテキストに含まれない情報について、しばしばハルシネーションを起こします。実応用においては、それらを与えることでより高精度でドキュメント生成が可能になると思います。
実行結果(抜粋)
## readDirectoryAndChunk
`readDirectoryAndChunk` は、指定されたディレクトリ内のすべてのサポートされているファイルを再帰的に読み込み、チャンクに分割する関数です。
### 宣言
```ts
const readDirectoryAndChunk = async (
factory: ParserFactory,
options: Options,
baseDirPath: string,
): Promise<BoundaryChunk[]> => { ... }
```
### 使用例
```ts
const allChunks = await readDirectoryAndChunk(factory, options, "/path/to/project");
```
`/path/to/project` ディレクトリ内のすべてのファイルを再帰的に読み込み、サポートされているファイルであれば構文解析してチャンクに分割します。
doc_generator_batchはLLMの呼び出しをエンティティごとからファイルごとにすることで性能を維持しつつコストを下げることを狙っています。実際には同一ファイルの他のエンティティが含まれることで性能もある程度向上すると考えています。
Discussion