主線 pipeline:detect → extract → build → cluster

資料流主幹逐函數講解
檔案:detect.py(1996 行)· extract.py(5993 行)· build.py(1943 行)· cluster.py(320 行)

大方向

這四支模組就是「把檔案變成圖」的資料流:detect 找出有哪些檔案 → extract 把每個檔案抽成 {nodes, edges}build 併成一張 NetworkX 圖 → cluster 用 Leiden 把節點分成社群。每一步的介面都很乾淨:dict 進、dict 出,只在 graphify-out/ 寫檔。


1. detect.py — 收集與分類檔案

classify_file(path) Path → FileType | None detect.py:491決定一個檔案的「類型」:程式碼、文件、論文、圖片、影片,或無法定義。

這是整個管線的「分類開關」。它依序檢查、先比對特殊情況、再比對副檔名

  • 套件 manifest 優先pyproject.tomlgo.modpom.xml 等被導向 CODE(用確定性的 manifest 解析,而不是丟給 LLM)——否則一個 apm.yml 會被當成「文件」送 LLM,把套件拆成重複節點。
  • 複合副檔名先查.blade.php 要在查 .php 之前判斷。
  • 接著依序查 CODE_EXTENSIONSPAPER_EXTENSIONS(PDF 在 Xcode asset catalog 裡是向量圖示,要排除)、IMAGE_EXTENSIONSDOC_EXTENSIONS(再檢查是否長得像論文)、office、Google Workspace、video。
  • 無副檔名時退回 _shebang_file_type()#!/usr/bin/env bash 的 CLI 也是程式碼)。

這就是「程式碼零 LLM、文件要 LLM」的源頭——類型決定後,走哪條抽取路徑就定了。

491def classify_file(path: Path) -> FileType | None:
496 from graphify.manifest_ingest import is_package_manifest_path
497 if is_package_manifest_path(path):
498 return FileType.CODE # 套件 manifest 走確定性解析
500 if path.name.lower().endswith(".blade.php"):
501 return FileType.CODE # 複合副檔名先比
505 if ext in CODE_EXTENSIONS: return FileType.CODE
512 if ext in IMAGE_EXTENSIONS: return FileType.IMAGE
523 if ext in VIDEO_EXTENSIONS: return FileType.VIDEO
525 return None # 無副檔名 → 靠 shebang 判斷
detect(root, *, follow_symlinks, …) Path → dict detect.py:1288掃描整棵目錄樹,回傳分類好的檔案清單與語料統計。

detect() 是管線的第一站,也承擔最多「別掃到不該掃的」責任:

  1. 前置:解析 root、處理已移除的 .graphifyinclude(印警告)、讀取 .graphifyignore 合併 .gitignore 規則。
  2. 走訪os.walk(followlinks=…, onerror=…) 遞迴掃描。onerror 確保中途的 PermissionError 不會變成「靜默地少掃一個資料夾」。
  3. 逐目錄修剪:遇到 noise 目錄(node_modules.next 等)、被 ignore 規則排除的目錄,直接 dirnames[:] = kept_dirs 就地修剪,os.walk 就不會往下走。
  4. 逐檔過濾:跳過 _SKIP_FILES、自己輸出的 converted/ sidecar、symlink 逃出掃描根目錄的、敏感檔。
  5. 特別處理:Google Workspace shortcut 與 Office 檔轉成 Markdown sidecar;每個非影片檔統計字數(有 stat 快取)。
  6. 回報:回傳 {files, total_files, total_words, needs_graph, warning, skipped_sensitive, unclassified, ignored, …}——所有「被排除的」都有記錄,不會默默消失。

注意回傳結構:needs_graphwarning 會告訴上層「語料太小,可能不需要圖」或「語料太大,語意抽取會很貴」。

1288def detect(root: Path, *, follow_symlinks=None, google_workspace=None,
1289 extra_excludes=None, cache_root=None, gitignore=True) -> dict:
1377 for dirpath, dirnames, filenames in os.walk(
1378 scan_root, followlinks=follow_symlinks, onerror=_on_walk_error
1408 for d in dirnames:
1429 dirnames[:] = kept_dirs # 就地修剪:不掃 noise 目錄
1468 ftype = classify_file(p) # 決定走哪條路徑
1534 return { "files": …, "warning": warning, "skipped_sensitive": … }
設計重點:這個函數把「掃描」與「分類」拆開,並把所有排除事件都記錄下來——這在實際 repo 上很重要,否則使用者根本不知道某個目錄為什麼沒進圖。
_is_noise_dir · _is_sensitive · _is_ignored 系列 detect.py 各處過濾的三大支柱:雜訊目錄、敏感檔、ignore 規則。

三個段落式講解的過濾器:

  • _is_noise_dir(part, parent) — 判斷目錄名是不是框架快取/產物(.next.nuxtnode_modules…)。注意:dot 目錄(.github.claude)是允許的,使用者常常想把它們畫進圖。
  • _is_sensitive(path) — 環境變數檔(.env 等)與金鑰相關檔案不進圖,避免把密鑰抽成節點。
  • _is_ignored(path, root, patterns, _cache=…) — 核心 ignore 引擎,支援 gitignore 語法(! 否定、anchored patterns、.gitignore 合併、last-match-wins)。_cache 在整個掃描過程共享,避免每個檔重算。

2. extract.py — 把檔案抽成圖的片段

extract(paths, *, root, parallel, …) list[Path] → dict extract.py:4688兩遍處理:逐檔 AST 抽取 → 跨檔 import 解析(產生 INFERRED 邊)。

這是程式碼抽取的入口。docstring 講得很清楚,兩遍:

  1. Phase 1 — 逐檔結構抽取:每個程式碼檔用 tree-sitter 解析,抽出類別、函數、import。
  2. Phase 2 — 跨檔解析:把「檔案級 import」變成「類別級 INFERRED 邊」——例如 DigestAuth --uses--> Response

實作上最關鍵的是「快取與平行化」:

  • 先依 _get_extractor(path) 找出每個檔的抽取器;沒有抽取器的檔直接給空結果。
  • 非 bypass 快取的檔先查 SHA256 內容快取(load_cached),命中就直接用。
  • 剩下的 uncached_work 若超過平行門檻(_PARALLEL_THRESHOLD)就用 ProcessPoolExecutor 平行抽取(_extract_parallel,繞過 GIL);池中途壞掉時,_extract_sequential 只重抽沒完成的(issue #2444)。
  • 抽取結果後段有一大串「醒目標記」:零節點檔(#1666)、無抽取器但歸類為 code 的副檔名(#1689)、缺相依套件的副檔名(#1745)——每一種都印出清楚警告,並附上解決提示(例如 pip install "graphifyy[sql]")。
閱讀提示:這 5993 行的檔案裡,最值得看的是 4688 的 extract() 主流程、後段的 ID 正規化/重映射(為了跨機器與 AST↔semantic 一致),以及語言的 _import_* 家族。這裡只帶你看主流程;語言的通用抽取邏輯在 extractors 頁。
4700Two-pass process:
47011. Per-file structural extraction (classes, functions, imports)
47022. Cross-file import resolution: file imports -> class-level INFERRED edges
4786 for i, path in enumerate(paths):
4787 if _get_extractor(path) is None: # 無抽取器 → 空結果
4790 bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
4792 cached = load_cached(path, root, …) # SHA256 內容快取
4796 uncached_work.append((i, path))
4801 if parallel and len(uncached_work) >= _PARALLEL_THRESHOLD:
4802 ran_parallel = _extract_parallel(…) # 平行多處理
4898 all_nodes, all_edges, all_raw_calls = [], [], [] # 合併所有結果
設計重點extract() 對「失敗」極度敏感——零節點、無抽取器、缺相依,每一種都明確印出警告。理由是:一個檔默默消失是最難除錯的資料問題。
_get_extractor(path) Path → Any | None extract.py:4413依副檔名/檔名/shebang 選出對的抽取器。

dispatch 邏輯的「動腦」所在:

  • 檔名優先.blade.php、MCP config、套件 manifest 都先於泛型副檔名比對。
  • 歧義副檔名特判.h 可能是 C/C++/ObjC——用內容 sniff(_is_objc_header_is_cpp_header)決定;.m 可能是 Objective-C 或 MATLAB——只有真的像 ObjC 才走 extract_objc,否則寧可沒抽取器(會被警告)也不硬解析。
  • 無副檔名:靠 shebang 判斷直譯器(#!/usr/bin/env bashextract_bash)。
  • 最後才查 _DISPATCH 表。
extract_python(path) Path → dict extract.py:1190Python 抽取的入口——通用抽取 + rationale(docstring)抽取。

語言的抽取函數其實都很薄——真正的邏輯在 _extract_generic(path, config)(來自 extractors/engine.py,見 extractors 頁)與一顆 LanguageConfig

def extract_python(path: Path) -> dict:
    result = _extract_generic(path, _PYTHON_CONFIG)   # 通用 tree-sitter 走訪
    if "error" not in result:
        _extract_python_rationale(path, result)       # 補 docstring / # NOTE 節點
    return result

這說明了 0.3.0 那次「2527 → 1588 行」重構的成果:12 份複製貼上的語言抽取器收斂成「設定 + 通用引擎」。

_file_node_id(rel_path) Path → str extract.py:177檔案節點 ID 的規範形式:{parent_dir}_{stem}。

節點 ID 是 graphify 最容易踩雷的地方。規範:{parent_dir}_{stem}——只取一層父目錄、去掉副檔名。頂層檔收斂成裸 stem(setup.pysetup)。

docstring 強調 這必須跟語意子代理產生的 ID 一致,否則 AST 與 semantic 抽取會把同一檔案拆成兩個不相連的「ghost 節點」。

_import_* 家族與 rationale 抽取 extract.py 各處各語言的 import 處理與「為什麼」節點。

段落式說明:

  • _import_python / _import_js / _import_java … — 每種語言的 import 語句怎麼變成邊。簽名幾乎一致:(node, source, file_nid, stem, edges, str_path, scope_stack)
  • _extract_python_rationale — 把 docstring 與 # NOTE: / # WHY: 註解抽成「rationale 節點」並連回它們說明的程式碼——這是 GRAPH_REPORT 裡「the why」的來源。
  • _shorten_rationale_label — 過長註解截斷成 80 字元。
  • _is_autogenerated_python — 偵測自動產生的 Python(避免把產物當原始碼抽)。

3. build.py — 併成 NetworkX 圖

build(extractions, *, directed, dedup, …) list[dict] → nx.Graph build.py:1264把多份抽取結果合併成單一圖;預設先做實體去重。

這是「進料 → 一張圖」的合併點:

  1. 把每份 extraction 的 nodesedgeshyperedges、token 統計全部併進一個 combined dict。
  2. 預設 dedup=True:先做型別強制(_coerce_non_string_ids)與欄位別名整併(_fold_node_aliases),再呼叫 deduplicate_entities()(見 ops · dedup),處理同實體不同 label 的問題。
  3. 最後交給 build_from_json() 真正組圖。
介面哲學build() 只管「合併 + 可選去重」,真正組圖的 dirty work 在 build_from_json——這讓測試可以分層:測合併邏輯 vs 測組圖邏輯。
1288 combined: dict = {"nodes": [], "edges": [], "hyperedges": [],
1289 "input_tokens": 0, "output_tokens": 0}
1295 if dedup and combined["nodes"]:
1307 combined["nodes"], combined["edges"] = deduplicate_entities(…)
1311 return build_from_json(combined, directed=directed, root=root)
deduplicate_by_label(nodes, edges) → (nodes, edges) build.py:1322依正規化 label 併節點——但這是「dormant」程式碼

一個很有趣的「活化石」:docstring 很誠實地承認——這個函數沒有接進 build()。真正在用的是 deduplicate_entities()(dedup.py)。它只依 label 合併、沒有 file_type 保護,所以不能給 code 節點用(兩個不同檔案的 Account 型別會被錯誤併成一個)。

它是「label-only 去重」的對照組:合併時偏好無 chunk 後綴(_c\d+)的短 ID,並丟掉合併造成的自環。

教學價值:一份開源專案保留「被取代的舊實作」並用 docstring 說明為什麼不用,勝過直接刪掉——這份歷史讓新讀者理解去重演進。

build_from_json(extraction, *, directed, root) dict → nx.Graph build.py:741真正把 nodes/edges/hyperedges 組進 NetworkX 圖,含大量 ID 正規化與防護。

這是最髒也最重要的組圖函數(741–1264 約 500 行),它處理一大堆邊角:

  • ID 正規化:非字串 ID 轉字串、legacy ID 遷移(_semantic_id_remap)、doc/ AST twin 重映射。
  • 檔名消歧:不同目錄同名檔案標籤加上最短唯一後綴(_disambiguate_file_node_labels)。
  • 超邊G.graph["hyperedges"] 的寫入與正規化。
  • 防護:shrink guard(不讓更小的圖覆蓋)、錯誤標記、prune。

實際看 graph 屬性時,節點會帶 _community_source_file_degree 等前綴屬性——那是在 export 前由 cluster/analyze 加上去的。

build_merge / merge_raw_extraction build.py:1547 / 1426增量更新的合併路徑——把新抽取併入既有圖、剪除已刪檔。

這是 增量更新設計 的實作點。與 build()(從零合併)不同,build_merge() 讀取既有的 graph.json,把這次的 new_chunks 併進去,並用 prune_sources 把已刪除檔案產生的節點剪掉。它處理跨檔邊的存活(changed file 指到 unchanged target 的邊要保留)、shrink guard、與 manifest 的一致性。


4. cluster.py — 社群偵測(小而精,320 行全文)

_partition(G, resolution) nx.Graph → {node: cid} cluster.py:22先試 Leiden(graspologic),缺套件就退回 Louvain(networkx)。

「最佳品質優先、退化優雅」的代表作:

  • 先把節點/邊排序後重組stable 圖——讓分群輸入確定性(同樣的圖一定同樣的輸入順序)。
  • from graspologic.partition import leiden,透過 inspect.signature 動態傳 random_seed=42trials=1resolution(相容不同版本的 graspologic)。
  • 呼叫期間把 stdout/stderr 導到 devnull——graspologic 會印 ANSI 進度條,在 Windows PowerShell 5.1 會毀掉 scroll buffer(issue #19,一個很實用的雷)。
  • ImportError 就退回 networkx 的 Louvain,同樣用 inspect.signature 相容版本差異(max_level 只傳給支援的版本)。
47 try:
48 from graspologic.partition import leiden
51 if "random_seed" in lsig: kwargs["random_seed"] = 42
61 with _suppress_output(): # 擋 ANSI 逃逸碼
67 except ImportError:
76 return {node: cid for cid, nodes in enumerate(communities) for node in nodes}
cluster(G, resolution, exclude_hubs_percentile) nx.Graph → {cid: [nodes]} cluster.py:134完整分群流程:isolate 處理、hub 排除、過大社群拆分、凝聚力再拆分、ID 穩定化。

這是 cluster.py 的主角,把「分群」這件事補齊到實用:

  1. 空圖/無邊:0 節點回空;無邊時每個節點自成一群。
  2. 有向轉無向:Leiden/Louvain 需要無向輸入。
  3. hub 排除exclude_hubs_percentile):度數超過百分位的「超集線器」不參與分群(避免把無關子系統拉進同群),之後依鄰居社群多數決重新掛回。
  4. isolate:0 度的節點各自成單節點社群。
  5. 過大社群拆分:> 25% 節點(至少 10 個)的社群,在子圖上再跑一次 _partition 拆開。
  6. 凝聚力再拆分:doc-hub 節點(如 CLAUDE.md 連到所有東西)會製造低凝聚力社群——凝聚力 < 0.05 且 ≥ 50 節點的再拆一次。
  7. ID 穩定化:社群依大小降序重新編號,同大小用 tuple(sorted(nodes)) 當總序 tiebreak——否則每次跑,那些等大小的社群 ID 會亂跳,造成偽「社群 churn」(issue #1090)。
一個很好的「工程課」:分群演算法(Leiden)本身只是第一步;真正讓它可用的是圍繞它的工程——確定性、isolate、hub、過大/低凝聚力拆分、ID 穩定。演算法 20 行,工程 200 行。
175 isolates = [n for n in G.nodes() if G.degree(n) == 0 …]
186 for node in isolates: raw[next_cid] = [node]; next_cid += 1
210 max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * 0.25))
222 if len(nodes) >= 50 and cohesion_score(G, nodes) < 0.05:
235 final_communities.sort(key=lambda n: (-len(n), tuple(sorted(n))))
236 return {i: sorted(nodes) for i, nodes in enumerate(final_communities)}
label_communities_by_hub(G, communities) → {cid: label} cluster.py:86不用 LLM 的社群命名:取社群內度數最高的成員當名字。

社群名稱的「零成本」方案:直接用最高度數成員的 label(去掉尾綴 ())。所以報告讀起來是 auth / log_action 而不是 Community 70。平手時用節點 ID 保證跨跑穩定。當有設定 LLM 時,這個預設命名會被更豐富的 LLM 命名覆蓋。

cohesion_score · score_all · community_member_sigs cluster.py 各處凝聚力計算、全社群評分、成員指紋。

段落式說明:

  • cohesion_score(G, nodes) — 實際社內邊數 / 最大可能邊數(n(n-1)/2)。這就是社群「緊密度」的量化。
  • score_all(G, communities) — 一行迴圈,把每個社群算一遍凝聚力,回傳 {cid: score}
  • community_member_sigs(communities) — 每個社群算「成員 ID 排序後的 SHA256 指紋」,存到 .graphify_labels.json 旁。之後 cluster-only 可以判斷哪個社群真的變了——成員不再雜湊一致的社群是「不同社群」,沿用舊 label 就是「stale label」bug。
看完這頁你應該能說出detect 怎麼決定一個檔案走 AST 還是 LLM;extract 的快取與平行策略;build 的 dedup 在哪一步;cluster 為了確定性做了哪三件事。接著讀 analyze + report 看「圖建好之後怎麼分析」。

相關:extractors 語言抽取器 · 增量更新設計 · 概念地圖

教學解說

主線 pipeline 是 Graphify 的脊椎:detect → extract → build → cluster。每個階段都是自己模組裡的一個函數,用純 Python dict 與 NetworkX 圖溝通。

detect.py 負責收集檔案並過濾(1996 行)、extract.py 負責 tree-sitter AST 抽取(5993 行,最大模組)、build.py 負責併入 NetworkX 圖(1943 行)、cluster.py 負責 Leiden 社群偵測(320 行,最簡單)。

關鍵設計:沒有共享狀態,除了 graphify-out/ 之外沒有副作用。這讓每個模組都可以獨立測試。

Worked Example:觀察 pipeline 的中間產物

  1. 執行 detectgraphify detect my-project/,查看 .graphify_detect.json 的內容。
  2. 執行 extractgraphify extract my-project/ --verbose,觀察每個階段的輸出。
  3. 檢查中間檔:查看 graphify-out/ 下的暫存檔(如果有的話)。
  4. 比較前後:比較 detect 前後的目錄結構。
關鍵觀察:每個階段的輸出都是下一個階段的輸入,形成清晰的資料流。

常見錯誤與診斷

錯誤訊息原因解決方式
detect: permission denied無法讀取某些目錄檢查檔案權限或用 sudo 執行
extract: tree-sitter parse error語法錯誤或不支援的語法檢查檔案是否完整,或語法是否在支援列表中
build: duplicate node ID多個檔案產生相同的節點 ID這是 0.9.0 修掉的問題,升級 graphify
cluster: too few nodes圖中節點太少無法分群這是正常的,小圖可能只有 1 個社群

練習與驗收清單

① 進階真實情境 Worked Example:debug「某些語言抽不出節點」

前面的 Worked Example 看 pipeline 中間產物;這次是真實 debug 劇本:你的專案有 Python + TypeScript + Rust,跑完 graphify extract . 後 GRAPH_REPORT 顯示 Rust 檔案「零節點、零邊」。

  1. 先確認 detect 分類graphify detect ..graphify_detect.json——Rust 的 .rs 是否被歸類為 CODE?(可能是副檔名沒註冊進 CODE_EXTENSIONS)。
  2. 確認 dispatch:檢查 _get_extractor(path).rs 是否回傳非 None;確認 _DISPATCH 表有 rust 且 tree-sitter-rust 有安裝。
  3. 看警告輸出graphify extract . --verbose——extract 對「零節點檔 / 無抽取器 / 缺相依套件」會各印一種醒目標記(issue #1666/#1689/#1745),錯誤訊息通常直接告訴你答案。
  4. 試單檔:拿一個 fixture Rust 檔(tests/fixtures/sample.rs)單獨跑,對照 test_languages.py 的預期節點數——若 fixture 正常、你的檔不行,就是「語法太新/太怪」。
  5. 補測試後再修:把這個案例加進 fixtures + test_languages.py,確認修復被鎖住,避免回歸。
為什麼選這條路徑:「零節點」是 pipeline 最容易默默發生的資料問題——如果只用「輸出看起來對」當驗收,某個語言整批消失也不會被發現。這條路徑教的是「沿資料流逐站檢查」:detect 分類 → dispatch → 抽取 → 比對 fixture——每一站都有可印出的中間產物,把「猜測」變成「定位」。

② 深入原理擴充:cluster 的「演算法 20 行、工程 200 行」

這頁最容易被忽略的是 cluster.py 那些「圍繞演算法的工程」——它們才是社群結果可用的原因:

大家以為建圖正確、但其實有誤的案例:以為「Leiden 是隨機的,所以社群結果不可重現」。其實 graphify 花了大量工程讓它可重現——固定 seed、排序、穩定 ID。但真正的陷阱是:「社群穩定」不代表「社群正確」。stable 只保證「同一輸入 → 同一輸出」;如果輸入的邊就是錯的(例如跨檔解析漏接),你會得到一個「可重現但結構錯誤」的分群。判讀社群前,先確認邊的品質(EXTRACTED/INFERRED 比例),再信分群。

③ 診斷式疑難排解表

症狀可能原因解決方案
某語言整批零節點副檔名未註冊 / dispatch 缺項 / tree-sitter grammar 沒裝逐站查 detect→dispatch→抽取;用 --verbose 看警告類型
同一次跑內社群數不穩定(重跑就變)輸入順序被 dict 順序影響,或隨機種子沒固定確認走排序重組的 stable 圖;確認 seed=42;不要手動改節點順序
整個 repo 幾乎一個大社群檔案級 hub / doc-hub 把所有節點拉在一起檢查 exclude_hubs_percentile;看凝聚力分數,低凝聚力大社群會被再拆
分群結果每次跑編號不同社群 ID 穩定化沒生效(舊版 bug)或等大小社群 tiebreak 衝突升級到含 issue #1090 修復的版本;比成員集合而非編號
build 階段 ghost 節點(同節點兩份)AST 與 semantic 抽取的 ID 慣例不一致(_file_node_id 規則沒對齊)確認語意子代理遵循 {parent_dir}_{stem} 慣例;檢查 _semantic_id_remap 是否被繞過

④ 進階挑戰題

  1. 「確定性」與「分群品質」可能衝突——固定 seed 可能鎖住一個次佳解。請設計一個實驗,比較「固定 seed 一次」與「多 seed 取眾數」在社群品質(以凝聚力衡量)上的差異,並討論確定性值不值得那個代價。
  2. cluster 排除 hub 用「度數百分位」。請指出這個規則在「星型圖」與「均質圖」上的失敗模式,並提出一個比「百分位」更好的 hub 判據。
  3. 跨檔解析(Phase 2)依賴 resolution 支援。若某語言無 resolution,其 import 邊停留在檔案級。請提出「偵測這種降級」的啟發式,並設計它在報告中該怎麼呈現(而非默默輸出錯誤圖)。