# FR-092 第 6 棒 盤點報告 — pyproject 沒人 import 的依賴

> 卡號 CM-1709（母卡 CM-1703）。**只盤不修**：未改任何一行程式碼、未刪任何檔、
> **未動 `pyproject.toml` 與 `poetry.lock`**（連下面 C 類「套件該補宣告」也只寫在這裡），
> 未連任何資料庫。

## 範圍與方法

盤點對象是主專案 `pyproject.toml` 的 `[project].dependencies`——**取 HEAD 版本**
（`git show HEAD:pyproject.toml`），共 **62 支**未註解的宣告（39 支第三方 ＋ 18 支 jedi ＋
gunicorn 等承載）。

⚠️ **為什麼不用工作目錄那份**：開工時（2026-09-12）工作目錄的 `pyproject.toml` 有
FR-080 未 commit 的 path override，把 11 支 jedi 套件的 pin 改成註解、另加 dev group
的 worktree 指向，檔頭自己註明「不 commit」。那是開發期暫態，不是宣告事實，
拿它當基準會把 11 支 jedi 套件全誤判成「沒宣告」。

判斷「有沒有人 import」不用 grep 逐支掃，改成**一次性建 AST import 索引**：

1. 用 `ast` 解析主專案與 jedi monorepo 全部 `.py`，抓 `Import`／`ImportFrom`
   的頂層模組名，外加 `importlib.import_module("x")` 與 `__import__("x")` 的字串引數。
2. **AST 會抓到縮排內的 lazy import**（函式內 `import`），`grep '^import'` 這種行首
   錨定的寫法抓不到——而本 codebase 的 AI client、PDF 後端都是函式內 lazy import，
   純靠行首 grep 會直接誤判成零使用。
3. 依路徑切 scope：主專案執行期（`api app common config core di_containers domain infra main.py`）／
   `scripts/`／`test/`／其他（`bin content docs examples`）／各 jedi 套件 src／各 jedi 套件自己的 tests。
4. 反查「誰把某套件當相依帶進來」用 `importlib.metadata`，區分硬相依與 `extra ==` 選用相依。

索引共 17,777 筆（去掉 vendored 目錄前是 30,803 筆）。

工具：**deptry 0.25.1**（裝在 scratchpad，未進 `pyproject.toml`）、Python 3.11.9、
手寫 AST 腳本。deptry 報 1,120 條，其中 DEP002（宣告未用）11 條、DEP001（未宣告）89 條、
DEP003 102 條、DEP004 918 條——**DEP004 幾乎全是雜訊**（它把 dev group 的 path override
當「宣告成 dev 依賴」，於是 `test/` 裡每一支 `import pytest` 都報一條）。結論不採 deptry
原始輸出，每條都另外開檔核對過。

### 一個一定要避開的陷阱：vendored 目錄

第一輪 C 類（套件用了但沒宣告）判了 7 支套件共 20 條，逐一開檔後發現**多數命中落在
`jedi-compliance-audit/.venv-standalone/lib/python3.11/site-packages/` 底下**——那是套件
自己的 standalone 測試環境，裡面的 `flask/app.py`、`jwt/algorithms.py` 當然 import
werkzeug 與 cryptography，但那是**第三方套件自己的原始碼，不是我方寫的**。
沒濾掉會得出「jedi-compliance-audit 用了 PIL／numpy／redis 卻沒宣告」這種完全錯誤的結論
（實際上那三條分別來自 pygments、psycopg、pip 的 vendored 副本）。

濾掉 `.venv*`／`site-packages`／`build`／`dist`／`.tox` 後，C 類從 20 條收斂到 **11 條**。
教訓與第 7 棒的「靜態抓 `__tablename__` 會漏」同型：**機械命中必須開檔看過路徑，
才知道那一行是誰寫的。**

### 統計

| 項目 | 數量 |
|---|---|
| HEAD 宣告的依賴 | 62（第三方 39／jedi 18／其他 5） |
| 主專案執行期 import 數為 0 的候選 | 14 |
| └ 逐一核對後**確定**可拔（A 類） | **3** |
| └ 主專案可拔、但屬套件領域（B 類） | 6 |
| └ 傳遞相依／動態載入，**不可拔**（D 類） | 5 |
| 反向：主專案執行期有 import 但 HEAD 沒宣告 | **7 支**（其中 openpyxl 11 檔為最嚴重） |
| C 類：jedi 套件 src 用了但自己沒宣告 | 11 條（跨 7 支套件） |
| 條件性發現：第 2 棒刪檔後才歸零的 | 2 支（pytesseract／Pillow，結論見下） |

---

## 一、宣告了但沒人用（A-D 分類）

分類定義照卡片：**A** 主專案與套件都零用、build 也沒列 → 可拔；**B** 主專案零用但某
jedi 套件在用且已自行宣告 → 主專案可拔；**C** 套件在用但沒宣告 → 主專案暫不可拔；
**D** build 腳本明列或動態載入 → 不可拔。

| 套件 | 版本約束 | 主專案 import | jedi 套件 import | 套件端有宣告 | build 清單 | 分類 | 建議 |
|---|---|---|---|---|---|---|---|
| **gevent** | `==24.11.1` | **0** | **0**（全 monorepo 零） | — | 未列 | **A** | 可拔，連 zope.event／zope.interface 共約 9.7 MB |
| **flask-marshmallow** | `==1.2.1` | **0** | **0** | — | 未列 | **A** | 可拔（0.03 MB，但屬純噪音宣告） |
| **google-auth-oauthlib** | `>=1.2.0,<2.0.0` | **0** | **0** | — | 未列 | **A** | 可拔，連 requests-oauthlib／oauthlib 一起走 |
| ldap3 | `>=2.9.1,<3.0.0` | 0 | jedi-iam | ✓ `pyproject.toml:41` | 未列 | **B** | 主專案可拔 |
| pyotp | `>=2.9.0,<3.0.0` | 0 | jedi-iam | ✓ `:45` | 未列 | **B** | 主專案可拔 |
| qrcode[pil] | `>=8.2,<9.0` | 0 | jedi-iam | ✓ `:46` | 未列 | **B** | 主專案可拔 |
| pydantic | `>=2.12.4,<3.0.0` | 0 | jedi-notification | ✓ `:19` | 未列 | **B** | 主專案可拔 |
| defusedxml | `>=0.7.1,<0.8.0` | 0 | jedi-flow-engine | ✓ `:30` | 未列 | **B** | 主專案可拔 |
| babel | `>=2.17.0,<3.0.0` | 0 | — | flask-babel 硬相依 `Babel>=2.12` | 未列 | **B'** | 可拔，但它是 flask-babel 的硬相依，拔了照樣會裝 |
| psycopg | `==3.2.4` | 0 | 7 支套件 | ✓ | 未列 | **D** | **不可拔**（理由見下） |
| psycopg-binary | `==3.2.4` | 0（無 import 名） | — | jedi-common 宣告 | 未列 | **D** | **不可拔**（同上） |
| anthropic | `>=0.77.0,<0.78.0` | 0 | jedi-ai-bot／jedi-ai-dashboard | ai-bot ✓／**ai-dashboard 刻意只放 extras** | grpc 相依鏈 | **D** | **不可拔**（理由見下） |
| openai | `>=2.8.1,<3.0.0` | **1**（`api/translate/routes/translate_route.py`） | jedi-ai-dashboard | **刻意只放 extras** | 未列 | **D** | 不可拔 |
| google-generativeai | `>=0.8.6,<0.9.0` | 0 | jedi-ai-dashboard | **刻意只放 extras** | `grpc` 在 EXCLUDE_COMPILE_PKGS | **D** | **不可拔**（理由見下） |
| google-auth-httplib2 | `>=0.2.0,<0.4.0` | 0 | — | google-api-python-client 硬相依 | 未列 | **D** | 可拔但無意義，是 API client 的硬相依 |

### D 類的四個「看起來沒人用、其實動不得」

這四條是本棒最值得記下來的——**零 import 不等於零使用**：

1. **psycopg / psycopg-binary**——主專案執行期一次 import 都沒有，但
   `core/app_factory.py:130` 組的 DB URL 是 `postgresql+psycopg://`，SQLAlchemy
   **靠這個字串在執行期動態載入 DBAPI driver**。拔掉會在連 DB 的第一刻炸
   `ModuleNotFoundError: No module named 'psycopg'`，而且靜態掃描永遠看不到這條線。
   （`psycopg-binary` 是 `psycopg` 的 C 加速輪，本身沒有 import 名。）

2. **三家 LLM SDK（anthropic／openai／google-generativeai）**——`jedi-ai-dashboard`
   **刻意不把它們放進 runtime dependencies**，只放 `[project.optional-dependencies]`
   的 claude／openai／google 三個 extras，檔頭寫明「consumer 依實際要用的供應商自行安裝」。
   而主專案 `di_containers/ai_dashboard/ai_dashboard_containers.py:55-59` 把
   **三家的金鑰全部接上**（`claude`／`openai`／`google`），
   `AIClientFactory.get_client(provider)` 用字串分派、三支 client 都是函式內 lazy import。
   也就是說：**三家都是產品實際供應的功能，主專案就是那個「該裝的 consumer」**。
   這三支宣告不是殘留，是套件契約要求宿主提供的東西，一支都不能拔。

3. **google-auth-httplib2**——`google-api-python-client` 的硬相依，拔掉宣告它照樣會被裝。
   列成 D 只是說「拔它不會有效果」，不是「拔它會壞」。

### A 類三支的來歷（查過 git log）

- **gevent**（`be36928b init`）與 **flask-marshmallow**（同一個 init commit）——
  從專案第一個 commit 就在，**從來沒有任何一行程式碼 import 過它們**。
  gevent 唯一的提及是 `core/scheduler.py:9` 的一句 docstring
  「更嚴謹的做法是改用 `apscheduler.schedulers.gevent`」——那是**假設語氣的建議**，不是使用。
  另已確認 gunicorn 沒有設 `worker_class`（`main.py:236-245` 只設 bind／workers／timeout），
  跑的是預設 sync worker；SocketIO 模式用的是 eventlet（`core/app_factory.py:172`
  `async_mode="eventlet"`）。**兩個承載模式都不碰 gevent。**
- **google-auth-oauthlib**（`90b1039e feat: 新增 Google Drive OAuth 整合模組`）——
  當初為 Drive OAuth 加的，但實作最後**手刻 `requests` 直打 Google HTTP 端點**
  （`infra/cloud_integration/google_drive/google_oauth_client.py`，四個 URL 常數寫死），
  沒走 oauthlib 那套 Flow。宣告從加進來那天就沒被用過。

### 一支已經因為套件搬遷而失效的宣告

**defusedxml** 的來歷值得單獨記：它是 `92145244 fix(security)` 為了防 XXE 加的，
當時改的是主專案 `app/flow_engine/util/bpmn_generator.py`。FR-069 把 BPMN 產生器
搬進 jedi-flow-engine 之後，**主專案那支檔案已不存在**（`find` 零命中），
defusedxml 的使用點跟著走了，套件那邊也正確宣告了（`:30`），只有主專案的宣告留在原地。
這是「套件搬走、宿主宣告沒收」的標準形狀，B 類其餘幾支（ldap3／pyotp／qrcode／pydantic）
同型——MFA、LDAP 登入都還是活功能，只是實作全在 jedi-iam 裡。

---

## 二、反向：主專案有 import 但 HEAD 沒宣告

這一段是卡片指定要附的。**這類比「宣告了沒用」危險得多**：它們現在能跑，純粹因為某個
jedi 套件或第三方套件把它們當相依帶進來了——**那支套件的 pin 一換、或它哪天把該相依
拔掉，主專案就會在執行期炸 ImportError，而 `pyproject.toml` 上完全看不出來。**

| 套件 | 主專案執行期 import | 靠誰帶進來（硬相依） | 風險 |
|---|---|---|---|
| **openpyxl** | **11 檔**（存活）＋2 檔在第 2 棒死檔清單 | jedi-compliance-audit `>=3.1.5,<4`、jedi-survey `>=3.1,<4`、jedi-oscal-v2 `>=3.1.0`、jedi-log `>=3.1.4` | 🔴 **最高**——xlsx 匯入匯出是產品核心功能，卻整支靠套件借來 |
| **werkzeug** | 8 檔（存活）＋1 死檔 | flask `>=3.1.0`（以及 flask-cors／flask-jwt-extended／jedi-file-upload／jedi-log） | 中（Flask 在，werkzeug 就在，但仍該顯式宣告） |
| **cryptography** | 1 檔 `infra/cloud_integration/crypto/fernet_crypto.py` | jedi-license-runtime／jedi-remote-agent `>=42.0,<47`、google-auth、pdfminer.six | 中高——**加密金鑰處理**卻沒自己宣告版本下限 |
| **requests** | 1 檔 `infra/cloud_integration/google_drive/google_oauth_client.py` | jedi-iam／jedi-notification `>=2.31,<3`、google-api-core、pygithub | 中 |
| **httpx** | 1 檔 `infra/upload_file/remote_agent_adapter.py` | jedi-detection／jedi-license-runtime／jedi-remote-agent `>=0.28,<0.29`、anthropic、openai | 中 |
| **PyJWT**（`import jwt`） | 1 檔（在第 2 棒死檔清單內） | flask-jwt-extended `<3.0,>=2.0` | 低（用它的檔要被刪） |
| **tqdm** | 1 檔（在第 2 棒死檔清單內） | jedi-oscal-v2 `>=4.66.0`、openai、google-generativeai | 低（同上） |

### openpyxl 值得單獨開一張修正卡

11 支**存活**的執行期檔案 import 它，全是模組層 `from openpyxl import ...`（已開檔核對，
不是註解也不是字串）：

```
app/auth/service/user_import_template_app_service.py
app/feedback/service/feedback_service.py
app/flow_control/service/job_import_service.py
app/module_frame/excel_template/data_validation_builder.py
app/module_frame/excel_template/generator.py
app/module_frame/excel_template/lookup_builder.py
app/module_frame/excel_template/styles.py
app/module_frame/service/module_frame_template_import_service.py
app/oscal/service/excel_parser/parser.py
app/oscal/service/excel_parser/sheet_handlers.py
app/oscal/service/ssp_control_impl_import_service.py
```

其中 `excel_template/generator.py` 經
`app/module_frame/service/ssp_import_template_app_service.py:77` 被 DI 注入使用，
是**活端點打得到的路徑**。`scripts/build/` 對 openpyxl 零提及，所以它不在
`EXCLUDE_COMPILE_PKGS` 也沒被 `--include-package`，是靠 Nuitka 追 import 圖收進去的——
**只要那四支套件哪天都不再需要 openpyxl，出貨產物會少掉這個套件，而 build 不會報錯。**

---

## 三、C 類：jedi 套件 src 用了但自己沒宣告（供首腦轉給套件端）

**本棒不動套件，只列出來。** 已濾掉 vendored 目錄，每條都開檔確認是套件自己寫的 import。

| 套件 | 用了什麼 | 檔數 | 代表檔案 | 是否被傳遞覆蓋 |
|---|---|---|---|---|
| jedi-integrity | **sqlalchemy** | 1 | `infra/tamper_event_repo.py:25 from sqlalchemy import text` | ⚠️ 只靠 `jedi-common>=0.0.30` 間接帶（該套件 dependencies 只有 jedi-common 一行） |
| jedi-detection | **dependency-injector** | 2 | `api/routes/detection_profile_route.py:17` | ❌ 自己沒宣告，主專案有宣告 → 現在能跑純屬巧合 |
| jedi-detection | flask-babel | 2 | `app/service/detection_orchestration_service.py` | ❌ 自己沒宣告 |
| jedi-detection | python-docx | 1 | `profiles/tools/twgcb2inspec.py`（工具腳本，非執行期） | ❌ |
| jedi-detection | werkzeug | 1 | `app/service/detection_result_handler.py` | ✓ Flask 帶 |
| jedi-compliance-audit | **rapidfuzz** | 1 | `app/service/ar_import/evidence_matcher.py` | ❌ 自己沒宣告，主專案有 |
| jedi-compliance-audit | werkzeug | 2 | `app/service/ap_docx_import_app_service.py` | ✓ Flask 帶 |
| jedi-common | werkzeug | 1 | `handler/handler.py` | ✓ Flask 帶 |
| jedi-iam | werkzeug | 1 | `app/service/import_user_service.py` | ✓ Flask 帶 |
| jedi-issue | werkzeug | 9 | `app/issue/service/issue_service.py` 等 | ✓ Flask 帶 |
| jedi-survey | werkzeug | 1 | `api/routes/task_survey_route.py` | ✓ Flask 帶 |

**優先序**：`jedi-integrity → sqlalchemy`、`jedi-detection → dependency-injector`、
`jedi-compliance-audit → rapidfuzz` 三條最該補——它們**不是靠 Flask 那種必然存在的相依**
帶進來的，而是靠「主專案剛好也宣告了同一支」才活著。werkzeug 那六條屬於
「Flask 在它就在」，優先序低但仍建議顯式化。

FR-069 D9 曾抓到套件端 4 個虛掛依賴，這 11 條是同一類問題的另一面（漏宣告，而非虛掛）。

---

## 四、條件性發現：第 2 棒（CM-1705）刪檔後才成立的

第 2 棒要刪 `common/util/pdf_util.py`、`pdf_converter_v2.py`、`nist171_converter_v1.py`、
`captcha_image_util.py`、`excel_util.py`、`file_util.py` 等。把死檔清單與 import 索引
交叉後，有幾條要提醒首腦：

| 套件 | 現在 | 第 2 棒之後 | 建議 |
|---|---|---|---|
| **pytesseract** | 1 檔（`pdf_util.py`，死檔） | **0** | 可與第 2 棒同批拔；它是 OCR，拔掉連帶 Pillow 需求也少一個 |
| **Pillow** | 2 檔（`captcha_image_util.py`＋`pdf_util.py`，兩支都是死檔） | **0** | ⚠️ **宣告可拔，但套件照樣會裝**——它是 pdfplumber `>=12.2.0`、weasyprint `>=9.1.0`、qrcode[pil] 的硬相依。拔宣告只是讓依賴關係誠實，省不到體積 |
| pdfplumber | 2 檔 | 1（`pdf_backend.py`） | 不可拔 |
| pandas | 3 檔 | 2 | 不可拔（且在 EXCLUDE_COMPILE_PKGS，是編譯時長大戶） |

### 🔴 一個第 2 棒清單的缺口：`common/util/pdf_backend.py`

`pdf_backend.py` **不在 dead-files.txt 上**，但它的全部 consumer 只有三支——
`pdf_util.py`、`pdf_converter_v2.py`、`nist171_converter_v1.py`——**三支都在第 2 棒的刪除清單裡**。
第 2 棒做完之後，`pdf_backend.py` 會變成**零 consumer 的孤兒**（已 grep 全 repo 含
`scripts/`／`test/`／`content/`／`bin/` 確認）。

它是 CM-1235 拔 PyMuPDF(AGPL) 時寫的 fitz 相容層（336 行），**也是 pdfplumber 與
pypdfium2 在主專案的唯一使用點**。要嘛第 2 棒把它一起刪（那 pdfplumber／pypdfium2 也跟著
變成零使用，可一併拔宣告，省下 pypdfium2 綁的 PDFium 原生庫與它在 EXCLUDE_COMPILE_PKGS
的一席），要嘛留著當未來 PDF 匯入的後端——**但留著就是留一支沒人呼叫的 336 行程式碼**。
這個取捨是首腦的決定，本棒只把事實列出來。

### 另一個發現：`captcha_image_util.py` 是「從來就跑不起來」的死碼

它 `from captcha.image import ImageCaptcha`，而 **`captcha` 這個套件**：
主專案沒宣告、任何 jedi 套件都沒宣告、**venv 裡也沒裝**
（`poetry run python -c "import captcha"` → `ModuleNotFoundError`）。
也就是說這支檔案**在目前環境下一 import 就會炸**，不是「沒人用的活碼」而是
「壞掉的死碼」。它已在第 2 棒清單（`dead-files.txt:64`），刪掉就對了；
記在這裡是因為它同時解釋了 deptry 的 DEP001 `'captcha' imported but missing`
那一條不是誤報。

---

## 五、建議的修正卡切法

若要開修正卡，建議切成三張（都很小，可併成一張中卡）：

1. **拔 A 類三支宣告**（gevent／flask-marshmallow／google-auth-oauthlib）——
   純刪三行 ＋ `poetry update`。驗證：`python main.py` 起得來、`RUN_MODE=socketio` 起得來
   （驗 eventlet 路徑沒被影響）。省約 9.8 MB 與三支套件的授權／CVE 面。
2. **補反向 7 支宣告**（openpyxl 為首，＋werkzeug／cryptography／requests／httpx）——
   加宣告不改行為，但把「靠套件借來」變成「自己宣告」。**openpyxl 最該先做。**
   PyJWT／tqdm 等第 2 棒刪完再看還需不需要。
3. **B 類六支**（ldap3／pyotp／qrcode／pydantic／defusedxml／babel）——
   技術上可拔，但它們全都是「套件在用、套件也宣告了」，拔掉屬於清理宣告噪音，
   風險低但收益也低（都會被傳遞裝回來）。**建議與 2 併做或暫緩，不必單獨開卡。**

C 類 11 條屬套件異動，依 CLAUDE.md 外部套件規範要先提醒、由決策者點頭，本棒不動。

## 附錄：命令重現

```bash
S=<scratchpad>
poetry run python -m pip install -q --target "$S/tools" deptry
PYTHONPATH="$S/tools" poetry run python -m deptry . --ignore-notebooks --json-output "$S/deptry.json"
git show HEAD:pyproject.toml > "$S/pyproject.HEAD.toml"   # 基準取 HEAD，不取工作目錄
# AST 索引腳本見報告「範圍與方法」，核心是 ast.walk 抓 Import/ImportFrom + import_module 字串引數，
# 並濾掉 .venv*/site-packages/build/dist/.tox
```
