1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「解析できなかった」を「何も無い」に見せない。静的コード索引 omitnix を出しました

1
Last updated at Posted at 2026-09-12

コードベースの目録を作る道具は、いくつもあります。作ること自体は難しくありません。

難しいのは、その目録が「全量です」と言い切れる状態を保つことのほうでした。

パーサが理解できないファイルが 1 つ足されると、たいていの生成器はそれを黙って飛ばします。行が消えるだけなので、出てきた一覧を見ても気づきません。そして行が無いことは「これは存在しない」と読まれます。人にとってもそうですし、その一覧を読む AI にとってはもっとそうです。

2026-09-12 に、そこを成果物の側で解こうとした CLI を出しました。v0.1.3 です。

omitnix について

自作で omitnix という静的コード索引の CLI を作っています。発見したファイルを全部解析できたかを毎回数え、できなかったものを理由付きで残します。

同じ悩みを持っている方は、下記で入ります。

pip install "omitnix[all]"

すでに入れている方は、下記で v0.1.3 に上がります。

pip install --upgrade "omitnix[all]"

入っている版の確認は omitnix --version です。初期化コマンドはありません。設定ファイルも無しで動きます。

使い方は、調べたいリポジトリの中で omitnix と打つだけです。

cd ~/code/your-repo
omitnix

常駐するプロセスはないので、止める操作もありません。Windows / macOS / Linux で挙動は同じで、Python 3.11 と 3.14 の両方を 3 OS ぶん CI で回しています(11 ジョブ)。データベースには接続しません。スキーマが要るときだけ、別のツールが出力した JSON を読みます。

出てくるのは JSON ファイルが 1 つだけ

走らせると .omitnix/index.json が 1 つできて、画面にこの行が出ます。

Coverage: 202/255 analyzed, 8 unresolved, 0 unknown, 45 unclaimed

この 1 行がこの道具の主題です。 255 個見つけて、202 個を解析し、8 個は一部を追えず、0 個は読めず、45 個は担当するパーサがいなかった。

足すと 255 に戻ります。

発見数 == 解析済み + 未解決 + 読めなかった + 担当なし

戻らない実行は失敗させます。 内部の不変条件として持っていて、合計が合わない限り成果物を書きません。落とすのは失敗ではなく、嘘だと考えているからです。

見つけた数が 4 つに分かれ、その合計が発見数と一致しなければ実行が落ちる、という構造です。読めなかったものは結果の外ではなく、式の中の項として扱います。

「読めなかった」と「そもそも読んでいない」は別

解析できなかったファイルは 2 つに分けています。ここを 1 つにしていた時期があって、分けた理由がはっきりしているので書いておきます。

unknown は、アダプタがその拡張子を担当すると宣言していながら記録を作れなかったものです。読めないバイト列、UTF-8 でない、正しいはずのソースを文法が拒んだ、アダプタが落ちた。何かがおかしいので、1 件ずつパスと理由を名指しします。

unclaimed は、その拡張子を担当するアダプタがいないので、そもそも何も試していないものです。文章、設定、画像、書庫、まだアダプタを書いていない言語。何もおかしくないので、列挙せず拡張子ごとに数えます。

omitnix: 20 discovered file(s) are claimed by no adapter, so nothing tried to read them.
  .md x5, .scm x5, .yml x4, (no extension) x3, .yaml x2, .toml x1

同じ状態にしていたころ、実際のリポジトリでは 192 ファイルが 1 行ずつ並びました。.md が 95、.json が 45、.png が 7。本物の失敗はその途中のどこかに埋もれていました。

もっと悪かったのは、unclaimed が実行を落としていたことです。緑にするには、どのアダプタも担当しないものそれぞれについて、設定ファイルへ理由の 1 文を書くしかありませんでした。実際のリポジトリで測ったら、52 件の除外設定のうち 36 件はそのためだけに存在していました。「PNG は program source ではない」は誰かが下した判断ではなく、ツールが要求した事務作業です。

分けた日に、このリポジトリ自身の設定も 16 行減りました。

さらに、リポジトリ側では閉じようのない欠けもあります。自分のアダプタが担当すると言っている言語の、正しいソースを読めない文法はこのツールの不具合です。それでビルドを落とすのは、正しいコードを書き換えるか、嘘の理由を書くかの二択を迫ることになります。

いま失敗させるのは unknown だけで、しかも既定では失敗させません(fail_on_unknown: true で有効)。欠けが隠れないことを担保しているのは、生成物がそう言っていることであって、実行が落ちることではありません。

空に見える値が、4 つの別の意味を持つ

ファイル単位の各項目(機能概要・認証・認可・読むテーブル・書くテーブル)は state を持ちます。読み手が空欄から意味を推測しなくて済むようにするためです。

state 意味
out_of_scope そのファイルを扱ったアダプタが宣言した能力の外。値の欠落ではない
not_configured 能力は宣言しているが、リポジトリが何を探すかを言っていないので、何も探していない
none_observed 能力を宣言していて、探して、このコミットでは見つからなかった。「未使用」ではない
value 普通の結果。完全だと確信している空リストを含む

not_configured はいちばん新しい状態で、このツールが自分自身についてこれを間違えたので存在します。

.omitnix.yaml の無いリポジトリへ向けたとき、618 ファイル全部に authorization: none_observed と報告しました。これは「618 ファイルを確認し、1 つも認可呼び出しを持っていなかった」と読まれます。

何も確認していませんでした。authorization_functions が空だったので、探索対象が無かったのです。何も探していない空リストは発見ではありません。

そして、このツールは「未使用」とは言いません。言うのは「この解析器が、このコミットで、静的な参照を観測しなかった」です。

逆引きに見えていないもの

テーブルから、それを触っているファイルへの逆引きも出します。ただし read_by / written_by読み取れた範囲であって、リポジトリが実際にしていることではありません。

あるテーブルを触っていて、かつ読めなかった文も持っているファイルは、そのテーブルの unresolved_in に名前が載ります。その項目は下限であって総量ではありません。

やっかいなのは、唯一の言及が誰も読めなかった文だったテーブルです。行がそもそも無いので、印も付けられません。これは実行全体につき 1 回数えて、一覧の横に置きます。

"table_gaps": {
  "files": ["api/orders_export.php"],
  "unresolved_count": 1,
  "note": "1 statement in 1 file could not be read, so the table list may be incomplete: ..."
}

これは実際のリポジトリへ向けて見つかりました。sqlglot が解釈できなかった全文検索の句が 1 つあり、そこがあるテーブルを読む唯一の場所だったのに、逆引きは「何も読んでいない」と言っていました

新しく入るファイルだけ、もっと厳しく見る

全量実行は「全部解析できたか」を問います。--gate は、いちばん必要なところにだけ厳しい問いを立てます。いま追加されようとしているファイルは、自分のアダプタが報告できるものを欠いたまま入ってきてはならない。

新しいコードは目録がいちばん薄いところです。機能概要はまだ無く、認可呼び出しは最も忘れられやすく、パーサが理解できなければ書かれたその日に穴として索引へ入ります。

$ omitnix --gate
omitnix: gate refused 3 files newly added in the working tree
  orders_purge.flow: no authorization call
  broken.flow: unknown (adapter 'flow' raised SyntaxError: unexpected token)
  undocumented.flow: no summary

ここで大事なのは、何が必須かはアダプタの能力宣言から決まり、ファイルの言語からは決まらないことです。認可呼び出しを報告できないアダプタに、それが求められることはありません。スタイルシートのアダプタを足した日が、新しいスタイルシート全部が持ちようのない確認を欠いて落ちる日にならないようにしています。

そして hook だけに頼りません。hook は 1 台ずつ有効にするもので、全員のローカル設定の和集合であるような網羅性は、誰にも言明できません。サーバー側では範囲で問います。

omitnix --gate --since origin/main

merge base と比較するので、ブランチ上では「このブランチが追加したもの」を意味します。参照が解決できないとき(浅い取得が典型)は、合格ではなく誤りを報告して非ゼロで終了します。GitHub Actions では fetch-depth: 0 が要ります。

v0.1.2 で足したもの: どこに配備したか、古くなっていないか

索引は各リポジトリの中に置かれるので、数が増えると「どこに入れたか」「最後に当てたのはいつか」が人の記憶にしか無い状態になります。

omitnix --workspace ~/code --status

何も書かずに、配下のリポジトリを 1 行ずつ表にします。

repository                             index       applied     analyzed    pointed at by
----------------------------------------------------------------------------------------
alpha                                  current     2026-09-11  49/74       CLAUDE.md+AGENTS.md
beta                                   -           -           -           -
gamma (worktree)                       STALE       2026-09-02  314/737     -

最後の列は、CLAUDE.md / AGENTS.md.omitnix/index.json を名指ししているときに出ます。どの指示ファイルにも書かれていない索引は運で見つかるもので、運は網羅性ではありません。

作るときに気をつけたことが 3 つあります。

索引を持たないリポジトリも省かずに並べる。「30 件を調べて 9 件が持っている」と「9 件ありました」は別の主張です。

linked worktree はそれと言う。 別のリポジトリではありません。そこで作り直せと言うのは、内容が普通は merge で届くブランチに修正を向けることになります。

比較は単一リポジトリ実行を再現する。 コミット済みの索引はそのリポジトリの中で走らせて作られたものなので、より狭い実行と比べると全リポジトリが「古い」と出ます。実際、開発中に一度これを踏みました。--check が 0 で終わる 3 リポジトリが、そろって「古い」と報告されました。

generated.commitHEAD と比べることもしません。生成物はコミットされる前に書かれるので、正しく保守されている索引は常に 1 つ前のコミットを指します。その判定はすべてを古いと呼びます。

出した直後に、同じ穴をもう一方向から踏んだ

ここからが v0.1.3 の話です。

v0.1.2 を PyPI へ出した直後、公開した版を素の環境で 1 回動かしましたpip install omitnix だけ、言語の extras 無しです。

索引を持つ 9 リポジトリが、全部 STALE と出ました。それぞれに「作り直せ」と助言つきで。

索引は 1 つも変わっていません。パーサが入っていなくて読めなかっただけです。助言に従えば、良い索引 9 本が、その環境が作れる空同然の索引に置き換わります。omitnix 自身で言えば 59/82 が 2/82 になります。

環境 結果
extras あり(開発機) 0 out of date / 終了コード 0
extras なし(素の install) 9 out of date / 終了コード 3

差は索引ではなく、アダプタの有無だけでした。

左が出したときの挙動、右が直した後です。同じ「食い違い」でも、索引が古いのか、こちらが読めないのかは別の話でした。

原因は単純で、判定が二択しか持っていなかったことです。

state = "current" if payload_for_check(stored) == fresh else "stale"

読めなければ生成物は必ず食い違います。食い違いはすべて stale に落ちました。

この機能は、自分の docstring に「偽の out of date こそが避けるべき失敗」と書いてあります。開発中に一度その罠を踏んで直してもいます。塞いだのは片方向だけでした。

  • 直した方向: 誰も作っていない実行と比べる
  • 残っていた方向: この機械が作れない実行と比べる

どちらも形は同じで、比較の前に、比較できる状態かを問うていなかった。それだけです。

v0.1.3 でやったこと

比較を意味あるものとして扱う前に、この実行が比較できる装備を持っていたかを先に問うようにしました。

コミット済み索引が答えを持っているファイルを、この実行が unknown と報告したなら、劣っているのはこちらです。そのとき状態は stale ではなく unverified になります。

omitnix: 30 repositor(y/ies) surveyed, 9 with a committed index, 0 out of date,
9 could not be checked here. Nothing was written.

omitnix: could not be checked: omitnix
  this run could not read 59 file(s) the committed index has an answer for, so a
  difference would say more about this machine than about the index.
  Install what it needs with: pip install "omitnix[php,python,tsjs]"

決めたことが 4 つあります。

まとめ行に「確認できなかった」を入れる。 0 out of date だけ書けば、全部問題なしと読まれます。

再生成を勧めない。 ここで「作り直せ」と言うのが被害の本体でした。

終了コードは 3 のまま。「確認できなかった」と「確認して問題なかった」が同じ答えでよいのは、誰も何にも繋がない調査だけです。既存の unreadable も 3 なので、扱いも揃います。

必要な extras を全部 1 コマンドで名指しする。 最初の 1 件だけ出すと、v0.1.1 で潰した「案内に従うたびに次が出る」ループが戻ります。実際に素の環境で出したコマンドをそのまま 1 回実行したら、UNVERIFIED 2/82current 59/82 になりました。

テストを 4 件足して 367 件になりました。ガードを外すと新しい 2 件が assert 'stale' == 'unverified' で落ちることも確認しています。

正直に言うと、これは開発機だけ見ていたら永久に出ない欠陥でした。開発機には extras が入っているからです。出したものを素の環境で 1 回動かしたから見つかりました。その 1 回を手順に足しました。

LSP や MCP では駄目なのか

ここは書いておいたほうがいいと思ったので書きます。多くの用途では、駄目ではありません。 置き換えを狙っていません。

分かれ目は 1 つだけで、「0 件」という答えを受け取ったとき、それが何を意味するかです。

orders テーブルを書き換えているのはどのファイルか」と聞いて、何も返ってこなかったとします。

**LSP(言語サーバー)**の「参照 0 件」は、シンボルの参照が見つからなかったという意味です。"UPDATE orders ..." のように文字列の中にあるテーブル名は、そもそも探す対象に入っていません。そして構文解析に失敗したファイルが何本あっても、その数は答えに付いてきません。1 ファイルずつの診断として出るだけで、合計する仕組みがありません。

MCP 越しの検索の「見つかりませんでした」は、どこまで探したかを言いません。それを読んだ AI は、「無い」と「探していない」を区別できず、たいてい「無い」と読みます。

omitnix は同じ 0 件を返したうえで、読めなかったファイルの名前と理由、担当アダプタがいなかった拡張子と件数、そして解釈できなかった文があれば「この一覧は足りていないかもしれない」の一文を、同じ成果物に入れます。

同じ質問に対する 3 者の「0 件」を並べたものです。omitnix が足しているのは答えではなく、答えの範囲のほうです。

取引は双方向です。LSP は型を見た定義・参照・改名をファイルをまたいでやります。omitnix はそれを一切しませんし、間接呼び出しは意図的に 1 段で止めます。MCP のサーバーは自由な質問に対話で答えます。omitnix には質問を受ける口がありません。書くのはファイル 1 つです。

LSP / MCP の道具 omitnix
答え その場限り。セッションが終われば消える コミットされるファイル。生成元のコミットが刻まれ、差分が見える
要るもの 言語ごとのサーバー、多くは依存が解決済みであること Python 3.11 と pip だけ。ビルドもデータベースも常駐も不要
単位 1 ファイル・1 シンボル・1 プロジェクト リポジトリ全体、またはディレクトリ配下の全リポジトリ

競合ではなく、下に敷くものだと思っています。 omitnix は事実とその限界を index.json に書きます。それを AI へ配るのは MCP サーバーの仕事で、人が読む文書にするのは別のツールの仕事です。実際、OpenWiki が index.json を読んで読める文書を書いています(https://github.com/langchain-ai/openwiki )。

既定が推測ではなく実測である、という話

複数リポジトリを横断するとき、何を見ないかを決める必要があります。そこは 1 台の機械上の 52 リポジトリで測って決めました。

対象 発見数 うち担当アダプタなし
作業ディレクトリ、ツール既定のみ 139,764 96,931
作業ディレクトリ、+横断の既定 67,838 26,408
追跡ファイル、+横断の既定(既定) 36,958 1,129

拡張子の一覧では表現できない最大の塊はビルド出力と取り込んだ依存で、既定がディレクトリから始まるのはそのためです。その次に大きかったのは、あるリポジトリの 24,436 個の無視される作業ファイル(データベースのデータ領域とブラウザのプロファイル)で、既定の対象が git の追跡対象である理由がこれです。

ついでに、パスを混ぜない理由も測りました。52 リポジトリで 937 個の相対パスが複数のリポジトリに存在し、うち 1 つは 22 リポジトリに重複していました。素のパスを鍵にしていたら、36,958 件の記録のうち 1,063 件が黙って互いに統合されていたことになります。テーブル名も同じで、133 個が複数のリポジトリに出現しました。ある系の orders と別の系の orders が静かに同じ行を共有するのは、自信満々で静かな誤りです。だから逆引きは出自のリポジトリの中に留めています。

対応している言語

アダプタは 3 段階に分かれていて、段は宣言であって、黙って適用される制限ではありません。各アダプタは自分が埋められる列を明言し、それ以外は空欄ではなく n/a になります。

アダプタ 報告するもの
完全 php, python, go, tsjs, sql 機能概要・認証・認可・読むテーブル・書くテーブル
逆引き html 機能概要と、画面が要求する宛先
最小 css, rust, shell, powershell, vue ファイルが存在すること、および先頭のコメント

最小の段があるのは、リポジトリが含んでいる拡張子を無視するのが無料ではないからです。アダプタの無い言語は unclaimed になり、1 ファイルについても何も語られません。測った 55 リポジトリのうち .sh は 37 件、.ps1 は 30 件に現れました。何も語ることのない量としては多いと思います。

Rust が最小なのは、測定対象の Rust リポジトリがどれもデータベースの crate を宣言していなかったからです。テーブルの抽出器を書いても、試す相手がありませんでした。本物の試し相手ができたときに上がるのであって、その前ではありません。

言語を足すのはファイルを足すことで、本体は編集しません。omitnix/adapters/ にモジュールを置いて実体を公開すれば、発見の仕組みが見つけます。

from omitnix.adapters.base import Adapter, AnalysisRequest, AnalysisResult
from omitnix.model import Capability


class IniAdapter(Adapter):
    name = "ini"
    extensions = (".ini",)
    capabilities = frozenset({Capability.SUMMARY})

    def analyze(self, request: AnalysisRequest) -> AnalysisResult:
        first = request.text.splitlines()[0] if request.text else ""
        return AnalysisResult(values={Capability.SUMMARY: first.strip("; ")})


ADAPTER = IniAdapter()

omitnix はこんなときに刺さります

  • AI に読ませる索引を置きたい人。欠けを伝えなければ、AI は無い行を「無い」と読みます
  • 新しく入るコードに、その言語が報告できるはずのものが付いているかを機械で止めたい人
  • リポジトリが多くて、どこに何があるかが人の記憶にしか無い人
  • そして今回の v0.1.3 で、どこに配備したか・古くなっていないかを、何も書かずに一覧できるようになりました

いずれかに心当たりがあれば、pip install "omitnix[all]" で試せます。初期化コマンドはなく、設定ファイルも書かずに動きます。

Star をいただけると開発の励みになります。使ってみて「ここが不便」があれば、Issue でも X の DM でも大歓迎です。

おわりに

正直、この記事で書きたかったのは新機能のほうではありませんでした。

読めなかったことを隠さないために作った道具が、その機能自身で同じことをやった。しかも開発機では 1 回も出ませんでした。出してから素の環境で動かして、初めて見えました。

翌日に直しましたが、直したこと自体より、片方向だけ塞いで塞いだつもりになっていたことのほうが残っています。同じ不変条件が破れる経路を、直す前に並べていれば防げた話でした。

成熟度はまだ開発初期で、個人の趣味プロジェクトです。サポートもありません。テストの穴も、変異テストで見つかった素通り 72 件のうち埋めたのは 3 件です。全部は埋まっていません。

小さく。次に何か出すときも、出したものを素の環境で 1 回動かすところまでを、ひと続きにします。


📎 図解版・関連リンクをまとめたページがあります:
https://ishizakahiroshi.com/articles/2026/2026-09-12_omitnix-v013-unreadable-files/

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?