---
title: FR-092 第 8 棒盤點報告——scripts/ 與 test/ 的死物
notion: {card: CM-1711, url: "https://app.notion.com/p/FR-092-8-scripts-test-8-16-3d9346da4cd0814c84f5d4d3d5f1f9fb"}
---

# 第 8 棒：scripts/ 與 test/ 死物盤點

## 範圍與方法

**只盤不修**——本棒沒有改動任何程式碼、沒有刪或搬任何檔案、沒有碰 `pyproject.toml`、沒有連 DB。

| 項目 | 內容 |
|---|---|
| 盤點對象 | `test/` 全部 193 支 `.py`（189 支 `test_*.py` ＋ 4 支輔助檔）；`scripts/` 中 2026-08-16 之後新增的檔 |
| 不動區 | `scripts/sql/`、`scripts/build/`（僅查呼叫端，不判去留）、`scripts/notion_case.py`——照 CM-1236 政策 |
| 基準 commit | `31160034`，branch `feature/FR-075` |
| 工具 | Python 3.11.9、pytest 9.0.3、`ast` 靜態 import 解析、`grep`、`git log --follow` |
| 判定紀律 | 每條工具命中都開檔核對過動態呼叫（`sys.modules` 別名、DI wiring、`importlib`）才列「確定」 |

**結論數字**：確定 **7 條**，疑似 **0 條**，工具誤判排除 **19 條**。

---

## 一、test/ 部分

### 1.1 確定條目

| # | 檔案 | 測的東西 | 該東西現況 | 判定 |
|---|---|---|---|---|
| T-1 | `test/test_ssp_versioning_service.py`（1,178 行、26 個 test） | `SspVersioningService` | **第 1 棒刪除清單內**（`app/oscal/service/ssp_versioning_service.py`） | **隨第 1 棒刪** |
| T-2 | `test/test_clone_ssp_objectives_reference_documents.py`（10 行、0 個 test） | 無——整檔只剩 docstring 說自己被 T-1 取代 | T-1 本身要刪，這份轉介也失去對象 | **隨第 1 棒刪** |
| T-3 | `test/test_decision_merge.py` 第 9 行 | `merge_by_decisions`（**活的**，兩個 app service 在呼叫） | 但它 import 的 `ImportDecisions` 來自第 1 棒要刪的 `domain/oscal/strategy/i_ssp_docx_write_strategy.py` | **第 1 棒必須改這一行，不可刪整檔** |
| T-4 | `test/test_oscal_stage_preconditions.py`（240 行、14 個 test） | `app/flow_control/service/oscal_stage_preconditions.py`（**活的**，DI 有 wiring） | 測試斷言的是被 FR-038 刻意移除的行為 | **獨立可刪**（或重寫） |
| T-5 | `test/decode_salt.py`（10 行） | 無——是一段硬編碼 bcrypt hex 的解碼小工具 | 全 codebase 零引用，最後內容異動 2025-07-20 | **獨立可刪** |
| T-6 | `test/test_bpmn_generator_basic_flow.py` | 無——**0 bytes** | 空殼，2026-01-30 建立後未再動 | **獨立可刪** |
| T-7 | `test/test_nist_parse.py` | 無——**1 byte**（只有一個換行） | 空殼，2026-01-01 建立後未再動 | **獨立可刪** |

### 1.2 三條需要展開說明

**T-3 是本棒最值得注意的一條，因為它會讓第 1 棒踩坑。**
`test_decision_merge.py` 測的 `merge_by_decisions` 是活函式，被 `ssp_excel_import_app_service.py:646` 與 `ssp_docx_import_app_service.py:550` 呼叫，測試本身有 13 個真實斷言且目前全綠。但它從第 1 棒要刪的檔案 import 了 `ImportDecisions` 這個 dataclass。第 1 棒若照清單直接刪檔，這支測試會 collect error。
**可行的修法**：`merge_by_decisions` 內部用 `getattr(decisions, "decisions", None)` 取值，也接受純 dict 和 list（`decision_merge.py:65-77` 的 `_decisions_by_control` 明文支援三種形態），而測試自己第 175 行就有一個「傳純 dict 也要能吃」的案例。所以把 `_decisions()` helper 改成回傳純 dict 即可，不需要保留那個 dataclass。**這個判斷屬第 1 棒的實作決定，本棒只標出風險。**

**T-4 是典型的「綠著但沒在守」。**
這支測試在模組層被 `pytest.skip(allow_module_level=True)` 擋掉，理由寫「FR-038 Wave 2A 停用，2B 重建」。我把 skip 拿掉後實跑，14 個 case **全紅**，錯誤是 `TypeError: check() got an unexpected keyword argument 'ap_uid'`——FR-038 已把介面的參數從 `ap_uid` 改成 `round_uid`，而且四個被測 class 的 `check()` 現在一律 `return PreconditionResult.ok()`（原始碼註解明寫「暫 lenient」）。測試斷言的「未指派時要 fail、要回 `unassigned` 數量」這些行為，產品端已經不存在了。
被測模組本身是活的（`di_containers/flow_control/flow_control_containers.py:21` 有 wiring），所以**刪的是測試不是模組**。要不要改成對 lenient 行為的斷言，屬 FR-038 收口範圍，非本案。

**T-1 的 skip 出於同一個 FR-038 2A 理由**，但和 T-4 不同：它的被測模組確實在第 1 棒刪除清單上，所以測試跟著走，沒有重寫的問題。

### 1.3 檢查過但判定「活的」（不刪）

這些是查過之後排除的，記下來免得下一棒重查：

| 查了什麼 | 結果 |
|---|---|
| 19 支測試 import `app.detection_tools.service.*`，`ast` 判定「模組不存在」 | **工具誤判**。`app/detection_tools/service/__init__.py` 是 FR-069 的 shim，用 `pkgutil.walk_packages` 把 `jedi_detection.app.service` 整棵子樹遞迴掛進 `sys.modules`。實跑 `importlib.import_module` 七支代表模組全部 OK，指向套件實體檔。靜態解析看不到這種動態別名 |
| 4 支「只有 mock 斷言、沒有業務斷言」的檔（`test_fr048_workflow_guard_revival` / `test_fr048_ssp_export_guard` / `test_fr048_job_comment_guard` / `test_workflow_execution_service_revert_job_bypass_gate`） | **活的**。粗篩的 awk 條件只數 `assert x == y`，漏算 `pytest.raises`。開檔看四支都用 `pytest.raises(ForbiddenError)` 驗守門真的擋住，是有牙齒的斷言 |
| docstring 提到 v1 / `jedi_oscal` 的 3 支（`test_manual_assignment_merge` / `test_fr032_excel_device_roundtrip` / `test_party_reconciliation_v2`） | **活的**。那些字句是在聲明「本檔零 v1 import 所以可以直接接 v2」，是說明不是依賴。實際 import 全指向現存 v2 模組 |
| `test/test_stage_advance_service.py` 兩個 `@pytest.mark.skip` | **留著**。skip 理由已寫明是 FR-038 刻意移除的行為、屬另棒處理，且該檔另外 25 個 case 是綠的。不是「等 X 而 X 早已發生」那種爛掉的 skip |
| `test/test_error_code_mirror_parity.py` 的 `skipif` | **留著**。條件是「jedi-compliance-audit 未安裝」，屬正常的環境條件守衛 |
| `test/fixtures/` 兩個 fixture 檔 | **都有人用**。`airasia_cmmc_l1_sample.docx` 被 `test_ap_report_parser_airasia.py:29` 用；`airasia_cmmc_l1_ar.xlsx` 被 `test_fr044_ar_adapter.py` 與 `test_fr044_ar_import_parse.py` 用 |
| `test/fixtures_frozen_error_codes.py` | **活的**。被 `test_module_boundaries.py:229` 用 `spec_from_file_location` 動態載入 |
| `test/cloud_integration/` 21 支 | **全活**。對應的 `app/cloud_integration/` 與 `infra/cloud_integration/` 都在，無一支 import 到刪除清單 |
| 第 2 棒的 17 個 dead file | **零測試牽連**。用 dotted 路徑與檔案路徑兩種形式在 `test/` 全掃，命中 0 |
| 第 3 棒的 12 支端點與 `subtask_status_history` 整模組 | **零測試牽連**。四個 route class 名（`ScheduleReportFileListRoute` / `SspScopedDocxImport` / `JobExecutionDevice` / `SubtaskStatusHistory`）在 `test/` 全無命中 |
| `common/code/event_code.py`、`common/code/local_code.py` 刪除後，`test_error_code_uniqueness.py` 會不會少守 | **不會**。該守衛走訪 `common.code` 全部子模組收 `BaseCode` 子類，實跑確認這兩支各貢獻 0 個 class（`HIT` 的只有 `detection_tools_error_code` / `error_code` / `flow_control_error_code`）。活的那支同名檔是 `common/enum/event_code.py`，四處在用 |

### 1.4 全量測試基準（供刪除後對照）

刪任何檔之前，現況是：

```
2252 collected, 2243 passed, 3 failed, 8 skipped
```

三支既有紅燈，**與本案無關、不要順手修**：

| 紅燈 | 原因 |
|---|---|
| `test_module_boundaries.py::test_upload_file_endpoint_paths_unchanged` | `ImportError: cannot import name 'FROZEN_URLS' from 'jedi_file_upload.api'` |
| `test_error_code_mirror_parity.py::test_mirror_has_same_members` | 鏡像碼表差一個成員 `GRC_REQUEST_BODY_TOO_LARGE` |
| `test_license_cross_tenant_duplicate.py::test_same_tenant_historical_license_replaces` | 測試的 `_FakeDomain` 缺 `replace_current_atomic`，套件端已加該方法 |

八個 skip 之中，四個是「`.env` 沒有 `DB_SECRET` 所以跳過 DB 整合測試」，兩個是 T-1／T-4 的模組層 skip，兩個是 `test_stage_advance_service` 的 case 層 skip。

**刪完 T-1／T-2／T-4／T-5／T-6／T-7 之後的預期**：collected 2252 → 2212（少掉 T-1 的 26 與 T-4 的 14），skipped 8 → 6，failed 維持 3。

---

## 二、scripts/ 部分

### 2.1 2026-08-16 之後新增的檔

`git log --since=2026-08-16 --diff-filter=A --name-status -- scripts/` 列出 73 個新增檔。扣掉不動區（`scripts/sql/` 26 支 migration ＋ 1 支 backup、`scripts/init/detection-profiles/` 與 `framework-pdfs/` 的資料檔），需要查呼叫端的是下列可執行腳本。**每一支都查了，全部有活的呼叫端，零支建議搬 `archive/`。**

| 檔案 | 呼叫端 | 判定 |
|---|---|---|
| `scripts/check_env_scoped_migrations.py` | `scripts/check_migration_manifest.sh:87` 串接執行 | 現役 |
| `scripts/notion_create_case.py` | `CLAUDE.md` 派工鐵則、`big-feature-workflow` 與 `security-scan-lead` skill、`docs/claude/notion-card-templates.md` | 現役 |
| `scripts/gen_postman_collection.py` | `docs/reference/postman/README.md` 明列重產指令，產出物 `endpoint-inventory.md` 檔頭標「本檔由此腳本產出勿手改」 | 現役 |
| `scripts/deliverables/plugin_metrics.py` | `docs/features/architecture-handbook/package-layers.md:156` 定為插件化四指標量尺，FR-080 每棒回寫時重跑 | 現役 |
| `scripts/build/assert_db_current.sh` | build 管線 | 現役（不動區） |
| `scripts/build/build_bundle.sh` | build 管線，`scripts/build/README.md` 為 canonical | 現役（不動區） |
| `scripts/build/build_fe_image.sh` | 同上 | 現役（不動區） |
| `scripts/build/build_init_image.sh` | 同上 | 現役（不動區） |
| `scripts/build/i18n_probe.py` | `scripts/build/smoke_release.sh:320` 直接呼叫（smoke 探針⑦，CM-1419） | 現役（不動區） |
| `scripts/init/gen_schema_sql.sh` / `gen_seed_sql.sh` / `gen_stamp_sql.sh` / `init.sh` / `migrate.sh` | `scripts/init/README.md` 為 canonical，installer 與出貨基線流程依賴 | 現役 |
| `scripts/installer/install.sh` / `guidant` / `install.conf.example` | FR-065 落地版 installer 本體 | 現役 |

### 2.2 CM-1236 README 未收錄的兩支

`scripts/README.md` 是 2026-08-16 的快照，之後新增的檔沒補進去。有兩支值得補：

| 檔案 | 情況 | 建議 |
|---|---|---|
| `scripts/deliverables/plugin_metrics.py` | 8/16 後新增，README 的 `deliverables/` 段落沒提到 | 補一列。**另注意**：`docs/review/2026-09-11-fr080-arc-review.md:449` 記載這支腳本有三個已知缺陷（清單指向已刪的 `infra/asset/user_name_resolver.py`、docstring 說 15 條實際 24 條、預設 monorepo 路徑指向不存在的目錄），採信它的輸出前要先修。**本棒不修，建議首腦另開卡** |
| `scripts/deliverables/render_docx.py` | 早於 8/16 就存在，但 README 的 `deliverables/` 段落只列了 `render_html` / `render_doc` / `render_index`，漏了它 | 補一列。它是活的，`test/test_deliverables_renderer.py` 整支在測它 |

`scripts/README.md` 的分類統計也已過時（寫「現役工具 33 支」，實際根目錄現在是 24 支不變、`deliverables/` 從 6 支變 8 支）。**本棒不改 README，列出來供首腦決定是否開收尾小卡。**

### 2.3 `scripts/archive/` 五支的真實年紀

CM-1236 政策是「只搬不刪，要不要真刪由決策者決定」。`git log` 直接查會全部顯示 2026-08-16（那是搬移的 commit），用 `--follow` 追到搬移前的原始異動才是真實年紀：

| 檔案 | 搬移前最後一次內容異動 | 分類 | 現在還能跑嗎 |
|---|---|---|---|
| `regenerate_reference_templates.py` | 2026-05-29（其中 5/24 兩次是實質改動） | 一次性已完成 | 未驗，import 未查 |
| `2026-05-04-backfill-framework-version-file-uid.py` | 2026-07-25（Sonar 批次清理，非功能改動；實質改動停在 2026-05-04） | 一次性已完成 | 未驗 |
| `backfill_2026-06-17_inject_job_execution_uid.py` | 2026-06-17 | 一次性已完成 | 未驗 |
| `test_compute_engine_integration.py` | 2026-07-25（Sonar 批次；實質改動停在 2026-02-10） | 疑似棄用 | **不能**。見下 |
| `test_metadata_builder_integration.py` | 2026-07-25（同上） | 疑似棄用 | **不能**。見下 |

**後兩支現在可以從「疑似棄用」升格為「確定死透」**：CM-1236 當時的判定依據是「`app/ai_dashboard/service/` 下已無 `compute_engine.py`」。本棒實查發現情況更徹底——**整個 `app/ai_dashboard/` 目錄已經不存在**（`app/` 下現存 25 個子目錄無此項），`importlib.import_module('app.ai_dashboard')` 直接 `ModuleNotFoundError`，也沒有任何 shim 把它導向套件。這兩支永遠不可能再跑起來。

**建議**：其餘三支一次性 backfill 有事後追溯價值，維持 archive；這兩支已無追溯價值（被測模組整個消失），可提請決策者裁真刪。**本棒不刪。**

---

## 三、最值得先處理的三條

1. **T-3（`test_decision_merge.py` 第 9 行）**——唯一會讓第 1 棒直接踩坑的一條。不處理就是 collect error，而且它測的是活功能，不能連檔刪掉。
2. **T-4（`test_oscal_stage_preconditions.py`）**——240 行、14 個 case 假裝在守著六個 precondition 檢查，實際上被測方法全部改成無條件放行了。這種「skip 掛著、看起來有保護」的檔留越久越危險。
3. **archive 兩支 AI 儀表板測試**——不影響任何人，但既然查清楚了被測模組整個目錄都不在了，值得把它們從「疑似」結案掉，免得下次盤點的人再查一次。

## 四、本棒沒做的事

- 沒有刪或搬任何檔案（含 `scripts/archive/` 那兩支確定死透的）
- 沒有改 `scripts/README.md`（過時的統計與兩支漏收錄的腳本，列在 §2.2 供裁決）
- 沒有修 `plugin_metrics.py` 的三個已知缺陷（arc-review 已記載，建議另開卡）
- 沒有碰三支既有紅燈
- 沒有連 DB、沒有動 `pyproject.toml`
