Semantic search over a Markdown knowledge base, served over MCP.
.kb-mcpignore は索引の境界であってアクセスの境界ではない / ignore はマッチャとしてのみ使うv0.21.0 より前、索引から何かを外す手段は exclude_dirs だけだった。これは
ディレクトリ basename の完全一致リストなので、「drafts/*.md だけ外す」
「*.tmp.md を外す」「archive/2024/** を外す」が書けない。素直な解は
KB に gitignore 構文のファイルを置くことで、Cursor も ripgrep も大半の開発者向け
ツールもそうしている。
実装に入る前に答えを出す必要がある問いが 2 つあり、どちらも好みで決められない。
1 つ目: このファイルは何を境界にするのか。 kb-mcp には exclude_dirs に
ついて既に意図的な答えがあり、テストで pin までされている — 「索引しない」
であって「読ませない」ではない。除外ディレクトリ配下のファイルは search に
出ないが、パスを知っている呼び出し元には get_document が返す
(validate_get_document_path は除外引数を受け取らず、
document_in_excluded_dir_is_still_readable がそれを pin している)。新しい
ignore ファイルはこの契約を踏襲することも破ることもできる。業界も割れており、
Cursor はまさにこの区別のために 2 枚のファイルを持っている —
索引用の .cursorindexingignore とアクセス用の .cursorignore。
2 つ目: ignore crate をどこまで使うか。 この crate はマッチャ
(Gitignore) だけでなくディレクトリ walker (WalkBuilder) も提供する。
kb-mcp は既に walkdir で歩いており、しかもその判断が 3 箇所に分かれていて、
過去に 2 度食い違っている。
validate walk (binary target 側にいる)、live watcher
— が同じ問いに違う答えを返せてはならない。AU-03 は watcher だけ hardcoded
denylist を持たずに出荷され、BU-19 は walk 側だけ大文字小文字を無視するように
なって「full index は Build/ を skip するのに watcher は index し続ける」状態で
出荷された。どちらもリリース後に見つかっている.kb-mcpignore を置かない KB の挙動は 1 バイトも変わってはならない/ を含むかで変わり、
! は非対称にブロックし、** は 3 通りの意味を持ち、[B-a] はバイト順の範囲)。
各言語の独立実装は実際に互いに食い違っているファイルの効果範囲
exclude_dirs 契約を踏襲するget_document / get_best_practice も拒否する実装
ignore::WalkBuilder に乗り換え、walkdir を置き換えるignore::gitignore::Gitignore をマッチャとしてのみ使い、walkdir は残すglobset の上に gitignore セマンティクスを自作する索引のみ (選択肢 1)、ignore はマッチャとしてのみ (選択肢 2)。
効果範囲は「このファイルが実際に保証できること」から決まる。KB に書ける者は
.kb-mcpignore を消すこともできる以上、木の中に置いたルールがその木を守る
境界にはなり得ない。ignored なパスに対して get_document を拒否すると、
「任意の書き手が消せるファイル」の上に立ちながら見た目はアクセス制御になる —
BU-20 が訂正させられたのと同じ形である。2 つの除外機構で契約が 1 つに揃うのは
説明としても単純だ: 除外されたものは決して索引されない。そして索引されるか
どうかは読めるかどうかの境界ではない。 読ませたくないものは kb_path の外 —
README がずっとそう書いている。
選択肢 3 は「見返りの無い概念の増加」として却下した。ファイル・ドキュメント・ テストがすべて倍になるが、表現しようとしている区別の強い方の半分は、いま 提供しないと決めたばかりのものである。
実装側の決定は実測が出した結論だ。WalkBuilder の既定は、既存 KB の挙動を
目に見えない形で変える: hidden() が既定 true で、しかも Windows の
「hidden」は「dot 始まり または FILE_ATTRIBUTE_HIDDEN を持つ」なので、
ユーザがエクスプローラで隠したノートが黙って index から消える。add_ignore は
walk root ではなくプロセスのカレントディレクトリ基準で解決する (サービスと
して入れた daemon の cwd は任意)。require_git / parents / git_ignore /
git_global はすべて既定 on なので、KB がたまたま git リポジトリかどうかで挙動が
変わる。そして filter_entry は述語を 1 個しか取れず、ignore 判定との評価順序が
docs に書かれていない — kb-mcp は既にそこで hardcoded denylist・Office lock
ファイル・symlink・hardlink を判定している。
マッチャだけを取れば既存の walk は無傷で、より重要なことに、3 面すべての 除外判定を 1 つの関数にできる。この失敗形態には既に 2 回の代償を払っている。
選択肢 3 (globset で自作) は、独立実装同士が「誰もテストしない edge case」で 実際に食い違っているという証拠と、内部の先行知見 — 「手書きのマッチャにレビューが edge case を当て続けるのは library に委譲する サイン」— の 2 点で却下した。
ignore crate が新規の直接依存になる。11 の推移的依存はすべて既に
Cargo.lock にあり (globset と walkdir は直接依存)、純増は 1 crate と
regex-automata の 0.4.14 → 0.4.18 のみmatched_path_or_any_parents は使わない。まさに欲しい API に見えるが、
実測では ["logs/", "!logs/important.md"] に対して logs/important.md に
Whitelist を返す。walk は logs/ で止まってそのファイルに到達しない。
watcher で使えば walk/watcher の drift が「どの API を呼んだか」のレベルで
復活していた。祖先ループを自分で書き、最初の除外された祖先で打ち切るexclude_dirs と hardcoded denylist が既にそうであり (BU-19)、1 つの設定の中で
2 つの除外機構が Build と build について食い違う方が、どちらの規則よりも悪いkb_path より上も、
.gitignore も見ない。階層ファイルは「walk と単発パス判定を一致させ続ける」のを
難しくする当のものであり、.gitignore を尊重すると既存 KB の索引内容が黙って変わるvalidate_get_document_path の doc コメントに書く