oscal 逐檔分類

oscal 逐檔分類

實查日期:2026-09-16 | 當時 HEAD:84b8426a | branch:feature/review

🔴 本表是某一天的快照,引用前先重驗。 每個數字下方都附了產生它的指令,重跑一次比相信這份表安全。


§1

一句話結論

oscal 是主專案最大的一塊自有資產,不是套件的殼:163 支檔案裡, 11,494 行(57.5%)是主專案自己寫的業務(Word/Excel 解析、兩份文件比對、 CMMC 格式轉換、匯入管線),4,757 行(23.8%)是轉接殼(把前端的欄位名翻成 jedi_oscal_v2 套件的形狀、再交給套件存),其餘 3,619 行(18.1%)是序列化器、 DTO、mapper 這類接線與型別。

真業務是殼的 2.4 倍——比 module_frame 那邊的 1.45 倍更懸殊。原因是這個模組 獨佔了整個「把客戶的既有文件吃進系統」那條線:domain/oscal/parser/(2,027 行) 與 app/oscal/service/import_diff/(1,384 行)合計 3,411 行零套件引用, 套件完全不參與。

殼的部分則高度集中在兩處:框架維護四支(framework_*,1,101 行, 主體是 v1 形狀 ↔︎ v2 形狀的來回翻譯)與 SSP 子物件五支(ssp_party /components /inventory_items/leveraged/system_characteristic,714 行)。


§2

① 現況盤點

四層規模

目錄 檔數 行數
api/oscal 67 3,984
app/oscal 46 10,964
domain/oscal 36 4,549
infra/oscal 14 499
合計 163 19,996

另有 di_containers/oscal/oscal_containers.py(509 行,不在四層內,判 DI 註冊時要看)。

怎麼重跑
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git rev-parse --short HEAD
for L in api app domain infra; do
  d=$L/oscal
  printf '%-22s %s 檔 / %s 行\n' $d \
    "$(git ls-files $d | grep '\.py$' | wc -l | tr -d ' ')" \
    "$(git ls-files $d | grep '\.py$' | xargs wc -l | tail -1 | awk '{print $1}')"
done

用 git ls-files 不用 find——find 會撈到 __pycache__ 與未入版控的殘留。 行數一律 wc -l(數換行符),與 FR-102 總表 A 的 Python 數法在本模組恰好同值 (19,996),module_frame 那邊則差 9 行。

對外 route:61 條

用 AST 數(不是 regex——add_resource( 常換行寫,regex 會漏抓)。 61 條全部在 api/oscal/__init__.py 統一註冊,掛 blueprint oscal、前綴 /api/1.0。 注意:所有 route 註冊都寫在 __init__.py,各 route 檔本身只定義 Resource 類別。

另有 3 條被註解掉不計入(grep -rn "^\s*#.*add_resource" api/oscal/):

被註解的 route 註解說明
OscalExportRoute → /oscal/export/<doc_type>/<uid> 無停用註解,連 import 那行一起被註解(__init__.py:88-89)
SspScopedExcelImportRoute → /ssp/<ssp_uid>/excel-import/<parse_uid> [FR-038 DEAD-V1|2026-06-18 停用待清理] scoped excel 只用 upload,預覽走非 scoped 端點
SspScopedExcelImportConfirmRoute → …/confirm 同上

前端四層查的第三層先講清楚

ui_routes 全庫只有 1 列與本模組相關—— id=33, name='compliance-framework-manage', url='/compliance-framework/compliance-framework-manage', enable=1。 它是選單項不是 API,管的是「左側選單看不看得到合規框架管理」這一件事; 61 條 API route 不會逐條出現在 ui_routes 裡。SSP 那批(/ssp/*,佔 61 條的 40 條) 更是完全不經選單——它們掛在專案頁面內的分頁裡(/project/projects/:id/...), 由專案選單進入。因此下表不設第三層欄位,在此統一交代。

PGPASSWORD=<查 .env> psql -h localhost -p 5432 -U cmmgr -d guidant_ai_dev \
  -c "SELECT id, name, url, enable FROM public.ui_routes
      WHERE name ILIKE '%framework%' OR name ILIKE '%ssp%' OR name ILIKE '%oscal%';"

route 清單與前端查證結果

四層=① 前端 API 常數表 src/config/api/api.js ② 前端實際呼叫(.vue/service/*.js) ③ DB public.ui_routes(見上段)④ 非瀏覽器呼叫者(scripts/、e2e)。

框架維護(13 條)

# route 吃哪支 service ①常數 ②呼叫 ④非瀏覽器 判定
1-2 POST /oscal-frameworks、GET /oscal-frameworks/menu FrameworkAppService OSCAL_FRAMEWORKS(_MENU) ✅ — 活
3-4 POST /oscal-framework、GET/PUT/DELETE /oscal-framework/<uid> 同上 OSCAL_FRAMEWORK ✅ — 活
5 POST /oscal-framework-versions FrameworkVersionAppService OSCAL_FRAMEWORK_VERSIONS ✅ e2e compliance-framework-import-version.steps.js:171 活
6-7 GET /oscal-framework-version/download/oscal/<uid>[/token] 同上 OSCAL_FRAMEWORK_VERSION_DOWNLOAD ✅ signedDownloadUtil.js:73-78 — 活(簽章下載兩段式)
8 GET/PUT/DELETE /oscal-framework-version/<uid>、POST /oscal-framework-version 同上 OSCAL_FRAMEWORK_VERSION ✅ — 活
9 GET /oscal-framework-version/<uid>/catalog-tree FrameworkVersionEditService …CATALOG_TREE ✅ 9 處 — 活
10-12 PUT/DELETE /oscal-catalog-group|control|control-assessment/<uid> 同上 OSCAL_CATALOG_* 三個 ✅ FrameworkVersionEditService.js — 活
13-16 /oscal-framework-parse-jobs、/parse、/<uid>、/<uid>/confirm FrameworkParseJobService OSCAL_FRAMEWORK_PARSE_JOBS ✅ 5 處 e2e compliance-framework-import-version.steps.js:50,155,164 活

資源庫(4 條)

# route ①常數 ②呼叫 ④非瀏覽器 判定
17 POST /oscal/resource-libraries/list RESOURCE_LIBRARIES_LIST ✅ ModuleFrame.vue:453 e2e module-frame.steps.js:70 活
18 POST /oscal/resource-libraries RESOURCE_LIBRARY_CREATE ✅ ModuleFrame.vue:773、SspDocxImportPage.vue:718 e2e resource-library-factory.js:53 活
19 GET /oscal/resource-library/<uid> RESOURCE_LIBRARY(有定義) ❌ 全前端零呼叫 ❌ 疑似死 — 見 ④ 4.2
20 POST /oscal/resource-library/<uid>/publish 同上(同一常數) ❌ 全前端零呼叫 ❌ 疑似死 — 見 ④ 4.2

SSP 匯入(8 條)

# route 吃哪支 service ①常數 ②呼叫 ④非瀏覽器 判定
21-23 /ssp-excel-imports/parse、/ssp-excel-import/<uid>[/confirm] SspExcelImportAppService SSP_EXCEL_IMPORT(_PARSE) ✅ e2e module-frame-import.steps.js:242,269,286 活
24-26 /ssp-docx-imports/parse、/ssp-docx-import/<uid>[/confirm] SspDocxImportAppService SSP_DOCX_IMPORT(_PARSE) ✅ scripts/smoke_test_ssp_docx_parser.py、scripts/e2e_test_module_frame_with_docx.py、e2e project-ssp-import-docx.steps.js:58 活
27 POST /ssp/<ssp_uid>/excel-import/upload 同 Excel SSP_EXCEL_IMPORT_SSP ✅ SspExcelImportService.js:56 — 活

SSP 子物件維護(14 條)

# route 吃哪支 service ①常數 ②呼叫 ④非瀏覽器 判定
28 GET/PUT /ssp/<uid>/system-characteristic SspSystemCharacteristicAppService SSP_SYSTEM_CHARACTERISTIC ✅ 9 處 e2e project-ssp-basic.steps.js:81,90 活
29-30 /ssp/<uid>/components[/<item_uid>] SspComponentsAppService SSP_COMPONENTS ✅ e2e ssp-helpers.js:54 活
31-32 /ssp/<uid>/inventory-items[/<item_uid>] SspInventoryItemsAppService SSP_INVENTORY_ITEMS ✅ e2e ssp-helpers.js:56 活
33-34 /ssp/<uid>/leveraged[/<item_uid>] SspLeveragedAppService SSP_LEVERAGED(_ITEM) ✅ 20 處 e2e ssp-helpers.js:55 活
35-36 /ssp/<uid>/parties[/<party_uid>] SspPartyAppService SSP_PARTIES/SSP_PARTY ✅ 21 處 e2e ssp-helpers.js:53 活
37-39 /ssp/<uid>/ssp-resources、/items[/<item_uid>] SspResourcesAppService → SspResourcesContextService SSP_RESOURCES/SSP_RESOURCE_ITEM(S) ✅ 22 處 — 活

SSP 程序書池與控制項實作(16 條)

# route 吃哪支 service ①常數 ②呼叫 ④非瀏覽器 判定
40-41 /ssp/<uid>/document-pool[/<doc_uid>] SspDocumentPoolService SSP_DOCUMENT_POOL ✅ 12 處 — 活
42-45 control/objective 各一組 document-mapping(s) 四條 同上 SSP_DOCUMENT_MAPPING(S)/SSP_OBJECTIVE_MAPPING(S) ✅ — 活
46 GET /ssp/<uid>/control-tree SspControlImplementationService SSP_CONTROL_TREE ✅ 22 處 e2e agent-task-management.steps.js:49、my-tasks-detection.steps.js:35、project-task-agent-dispatch.steps.js:37 活
47 GET /ssp/<uid>/control-implementations 同上 SSP_CONTROL_IMPLEMENTATIONS ✅ SspService.js:11 — 活
48 GET/PUT /ssp/<uid>/control-implementation/<ci> 同上 SSP_CONTROL_IMPLEMENTATION ✅ — 活
49 PUT …/objective/<si> 同上 SSP_OBJECTIVE ✅ — 活
50 DELETE …/objective/<si>/document/<file_id> 同上 SSP_OBJECTIVE_DOCUMENT ✅ — 活
51-54 control/objective 各一組 reference-document(s) 四條 同上 SSP_REFERENCE_DOCUMENT(S) ✅ — 活
55-58 /ssp/<uid>/control-implementations/export、/import、/import/validate、/import/confirm SspControlImplImportService SSP_EXPORT/SSP_IMPORT(_VALIDATE/_CONFIRM) ✅ SspService.js:90-94 等 6 處 — 活

匯出與範本 SSP(3 條)

# route 吃哪支 service ①常數 ②呼叫 ④非瀏覽器 判定
59 GET /ssp/<uid>/export SspExportAppService SSP_DOCUMENT_EXPORT ✅ ProjectPlanningView.vue:2238 e2e planning-batch-ops.steps.js:211 活
60 GET /module-frame/<mf_uid>/template-ssp ResourceLibraryAppService 拼 MODULE_FRAME ✅ ModuleFrameTemplateService.js:23 — 活
61 GET /oscal/catalog-controls/<uid>/assessments(BE 無此 route) — CATALOG_CONTROL_ASSESSMENTS ⚠️ 前端有打(ModuleFrameTemplateService.js:198) — 前端打空氣 — 見 ⑤ 5.4

第 61 列不是 BE 的 route(AST 只數到 60 條掛在 oscal blueprint 上、加上第 59 的 /ssp/<uid>/export 共 61)。列在此是因為前端確實會打這個路徑,而 BE 全 repo 沒有 任何地方註冊它——屬於前端打得到但後端不存在的缺口,詳見 ⑤。

這模組自己持有的表

compliance.framework_parse_jobs、compliance.ssp_docx_parse_jobs、 compliance.ssp_excel_parse_jobs 三張匯入工作表,ORM 在 infra/oscal/model/。 模組所有其他資料——框架、版本、catalog、profile、SSP 本體與所有子物件—— 都不在主專案的表,而是由 jedi_oscal_v2 持有。這是本模組「殼」那一半的由來: 三張 job 表記的是「這份檔案解析到哪、使用者選了什麼」,解析完寫進套件就結束。


§3

② 逐檔分類表

四類的判準(依賴方向,不是檔名)

  • 真業務:主要邏輯自己寫,jedi_* 引用少或零;刪掉這支功能就消失。
  • 轉接殼:主體是「開 @transaction → 呼叫套件 service → 欄位對應」,商業規則在套件。 殼不等於可刪——它是「主專案回答套件問題」的必要接線。要另外標「有沒有帶主專案獨有規則」。
  • 共用設施:沒有 route、沒有自己的業務語意,是給別的模組或別的檔用的(DTO、序列化器、 error code、port、mapper、resolver)。
  • 死碼:全 repo 零 import、零 route 註冊、零 DI 註冊(四層都要查才算)。
  • 還看不準:允許存在,要寫卡在哪。本表 0 支。

api/oscal(67 檔/3,984 行)

routes/ — 20 檔/2,314 行

檔 行 類別 依賴證據 殼帶主專案規則?
__init__.py 207 共用設施 blueprint 組裝+全模組 61 條 route 的唯一註冊處;19 個 host import(全是自己模組的 Resource 類別),jedi=0。零 DB、零業務 —
routes/ssp/ssp_control_implementation_route.py 309 轉接殼 10 個 Resource 類別(控制樹/IR CRUD/AO/程序書關聯)全轉呼叫 SspControlImplementationService;jedi=2、host=4 ⚠️ 守門不在 route——全部落在 app service 的 SspPermissionChecker(資源域守門,需先 resolve ssp_uid 才知道判誰)
routes/ssp/ssp_document_pool_route.py 183 轉接殼 6 個 Resource 轉呼叫 SspDocumentPoolService;jedi=2 ⚠️ 同上
routes/framework/framework_parse_job_route.py 178 轉接殼 4 個 Resource 轉呼叫 FrameworkParseJobService;multipart 檔案接收在 route ⚠️ 守門在 service(require_platform_admin)
routes/framework/oscal_framework_version_route.py 157 轉接殼 轉呼叫 FrameworkVersionAppService+一支簽章下載(OscalExportAppService.export('catalog',…) 回裸 OSCAL JSON) ✅ 帶 — 唯一在 route 層 import common.authz 的檔;簽章下載 token 兩段式是主專案機制
routes/framework/framework_version_edit_route.py 132 轉接殼 4 個 Resource 轉呼叫 FrameworkVersionEditService ⚠️ 守門在 service
routes/ssp/ssp_scoped_excel_import_route.py 130 轉接殼 3 個 Resource,其中 2 個已無 route 註冊(__init__.py:116-117 被註解,標 FR-038 DEAD-V1) ⚠️ 守門在 service
routes/ssp/ssp_control_impl_import_route.py 114 轉接殼 4 個 Resource 轉呼叫 SspControlImplImportService;blob 回傳走 send_file ⚠️ 守門在 service
routes/ssp/ssp_excel_import_route.py 112 轉接殼 3 個 Resource 轉呼叫 SspExcelImportAppService ⚠️ 守門在 service
routes/ssp/ssp_docx_import_route.py 105 轉接殼 3 個 Resource 轉呼叫 SspDocxImportAppService ⚠️ 守門在 service
routes/framework/oscal_framework_route.py 104 轉接殼 4 個 Resource 轉呼叫 FrameworkAppService;marshmallow dump ⚠️ 守門在 service
routes/ssp/ssp_resources_route.py 88 轉接殼 3 個 Resource 轉呼叫 SspResourcesAppService ⚠️ 守門在 service
routes/ssp/ssp_components_route.py 85 轉接殼 2 個 Resource 轉呼叫 SspComponentsAppService ⚠️ 守門在 service
routes/resource_library_route.py 84 轉接殼 4 個 Resource 轉呼叫 ResourceLibraryAppService。ResourceLibraryRoute(詳情)與 ResourceLibraryPublishRoute(發布)兩支已註冊但前端零呼叫——見 ④ 4.2 ✅ 帶 — @require_platform_admin_route 掛在 publish;建立端點有 12 行註解說明為何刻意不掛(建立只寫租戶私有庫,公版治理靠 publish 守住)
routes/ssp/ssp_inventory_items_route.py 84 轉接殼 2 個 Resource 轉呼叫 SspInventoryItemsAppService ⚠️ 守門在 service
routes/ssp/ssp_party_route.py 84 轉接殼 2 個 Resource 轉呼叫 SspPartyAppService+marshmallow serializer ⚠️ 守門在 service
routes/ssp/ssp_leveraged_route.py 78 轉接殼 2 個 Resource 轉呼叫 SspLeveragedAppService ⚠️ 守門在 service
routes/ssp/ssp_export_route.py 72 轉接殼 1 個 Resource 轉呼叫 SspExportAppService;blob 回傳 ✅ 帶 — RFC 5987 中文檔名編碼(與 module_frame 的 mf_ssp_export_route 同法)
routes/ssp/ssp_system_characteristic_route.py 51 轉接殼 1 個 Resource 轉呼叫 SspSystemCharacteristicAppService ⚠️ 守門在 service
routes/oscal_export_route.py 39 🔴 死碼 定義 OscalExportRoute。四層全零:route 註冊被註解(__init__.py:89)、模組內 import 那行也一起被註解(:88)、模組外零 import、不進 DI。詳見 ④ 4.1 —
routes/__init__.py/routes/framework/__init__.py/routes/ssp/__init__.py 0×3 共用設施 空 —

這模組的守門位置與 module_frame 相反,是刻意的。 module_frame 的 route 幾乎每支 都掛 @require_capability(...)(主體域守門:只看「你是誰」);oscal 的 SSP 那批 route 一個也沒掛——因為它們是資源域守門:要先把 ssp_uid 反查成專案、才知道 要判這個人在哪個專案裡的角色。route 層表達不了,所以統一落在 app service 的 SspPermissionChecker。這符合 common/authz/ 的雙軌設計(CLAUDE.md 授權守門段)。

serializers/ — 47 檔/1,670 行

除下列四支外,其餘 43 支全是 共用設施(純 marshmallow Schema,零業務、零 DB; oscal_common/ 下 19 支另被 api/project/serializers/project.py 與 api/module_frame/serializers/module_frame.py 借用,是跨模組的欄位契約)。

檔 行 類別 依賴證據
serializers/ssp/ssp.py 29 🔴 死碼 SystemSecurityPlanResponseSchema 只被 serializers/__init__.py:12 re-export,全 repo 無其他命中(含字串式 Nested)。詳見 ④ 4.3
serializers/ssp/ssp_system_characteristic.py 18 🔴 死碼 SystemCharacteristicResponseSchema 只被上面那支死 schema 用字串式 Nested 引用(ssp.py:19)+__init__ re-export。⚠️ 與活的 ssp_system_characteristic_inproject.py 的 SspSystemCharacteristicResponseSchema 名字只差 Ssp 前綴,清理時極易誤刪
serializers/ssp/ssp_system_implementation.py 17 🔴 死碼 同上(ssp.py:20 字串式引用+__init__ re-export)
serializers/framework/oscal_framework_menu.py 12 🔴 死碼 OscalFrameworkMenuResponseSchema 只被 __init__ re-export。route 用的是同名不同物的 OscalFrameworkMenuSchema(定義在 oscal_framework.py:70,無 Response 三字)
serializers/framework/oscal_framework_version_menu.py 11 🔴 死碼 只被上面那支死 schema 與自己(children 遞迴)引用+__init__ re-export
serializers/framework/oscal_framework.py 165 共用設施 6 個 Schema,含 route 實際在用的 OscalFrameworkMenuSchema/OscalFrameworkPageQueryResponse/OscalFrameworkResponseSchema
serializers/ssp/ssp_docx_import.py 185 共用設施 12 個 Schema(匯入決策/預覽/confirm 請求回應),被 ssp_docx_import_route 用
serializers/ssp/ssp_excel_import.py 137 共用設施 7 個 Schema,被 ssp_excel_import_route 用
serializers/framework_parse_job/framework_parse_job_schemas.py 201 共用設施 被 framework_parse_job_route 用
(其餘 38 支 serializer) — 共用設施 純 Schema/空 __init__

⚠️ 這五支死 schema 用 grep import 抓不到、也不能只看 grep 命中數。 它們互相用 marshmallow 的字串式 fields.Nested("X") 引用,形成一個封閉的小圈: ssp.py 引 SystemCharacteristic/SystemImplementation,oscal_framework_menu 引 oscal_framework_version_menu,而圈外沒有任何人引用這個圈。守衛測試 test/test_module_boundaries.py:2028-2042 把這幾行列為字串式 Nested 白名單, 理由寫的是「本卡範圍外未動」——那是說明沒清,不是說明它們是活的。


app/oscal(46 檔/10,964 行)

service/ — 直屬 14 支,模組的重心

檔 行 類別 依賴證據 殼帶主專案規則?
ssp_control_implementation_service.py 1,004 真業務(殼+大量業務混住,見下方拆解) jedi=19/host=4。19 個 jedi import 多數是讀資料的來源(v2 IR/statement/catalog、jedi_compliance_audit 的輪次與 AP、jedi_flow_engine 的 job)。單一方法 build_control_tree_by_ssp_id 就 400 行(408-807),做的是「把 catalog 控制樹 + 已填值 + 本輪任務狀態 + 上一輪結果」四份資料揉成前端要的一棵樹 ✅ 帶 — SspPermissionChecker 守門;輪次凍結範圍(_frozen_round_control_scope)與「規劃期重驗」(_is_planning_stage_reverify)是產品的稽核輪次語意,套件不知道
ssp_excel_import_app_service.py 937 真業務 jedi=5/host=8。26 支方法,主體是自寫的匯入編排:上傳→解析→比對→預覽→確認四階段,含 TTL 逾期清理、參與方鉤稽、決策合併、內容覆寫。套件只在最後一步收寫入 ✅ 帶 — ① 三種落點分流(資源庫範本/專案 SSP/新建資源庫)② 專案角色守門(_require_project_manager/_require_project_participant)③ 檔案 TTL 與暫存清理
ssp_docx_import_app_service.py 787 真業務 jedi=2/host=8。與上一支同形(四階段匯入編排),多一段「依框架挑 adapter 跑格式轉換」(_run_framework_adapter)與 docx 修訂記號正規化(_normalize_docx_revisions,54 行) ✅ 帶 — 同上三條,另加 CMMC 格式判定
framework_parse_job_service.py 674 真業務(殼+業務混住) jedi=14/host=4。parse(112 行)與 confirm(118 行)是自寫編排,_apply_decisions_and_overrides(83 行)處理使用者逐項決策,_persist_catalog(54 行)才把結果交給套件 ✅ 帶 — ① require_platform_admin 守門 ② _effective_tenant_id(多租戶落點判定)③ _guard_version_replaceable(版本可否覆寫的產品規則)
resource_library_app_service.py 541 真業務(非純殼——卡片問的那支) jedi=11/host=2。看行數分布就知道不是殼:create_resource_library 單一方法 104 行,做的是「clone catalog → 依規則建 profile → 建空範本 SSP → 寫 link record」四段編排;_build_catalog_tree(65 行)與 update_applicable_controls(60 行)是自寫的樹狀組裝與差異更新;_init_ao_workflows(38 行)掛預設流程範本。套件提供的是 clone/CRUD 原語,編排與資源庫這個概念本身是主專案的 ✅ 帶 — ① 資源庫的 link record 存主專案的 compliance.module_frames(原生 SQL,_MF 常數)② 暱稱 enrich(enrich_audit_nicknames)③ _fmt_dt(本 route 直接回 dict 未套 Schema,須自行格式化時間,否則回 GMT 格式)
ssp_control_impl_import_service.py 431 真業務 jedi=3/host=3。openpyxl 手刻 9 欄 Excel(建 workbook、套配色邊框、下拉、隱藏 A 欄)+解析+逐列驗證+批次 upsert。與 module_frame_template_import_service.py 共用同一份欄位契約——見 ⑤ 5.1 —
framework_version_edit_service.py 351 轉接殼 jedi=14/host=2、零主專案業務 import。檔頭 12 行就是 v1↔︎v2 欄位對照表(control.description ↔︎ statement part prose、group.description ↔︎ v2 無此欄讀 None 寫忽略、uid ↔︎ str(id))。主體是「收 FE 的 v1 形狀 → 翻成 v2 → 呼叫套件」 ✅ 帶兩條 — ① 雙軌守門:publish_status != 'draft' 拒絕、catalog 被 profile 引用時不允許刪 ② require_platform_admin
ssp_document_pool_service.py 313 轉接殼+業務 jedi=8/host=3。程序書池本體落 jedi_compliance_audit 的 ssp_reference_documents,控制項/AO 關聯的 context_id 要反查 v2 的 IR.id/statement.id ✅ 帶 — ① SspPermissionChecker ② IR/statement 不存在時的三種行為(寫入自動建、讀取回 []、刪除回 False)是產品的 UX 判斷 ③ context_type 的 control_implementation/objective 慣例
framework_app_service.py 247 轉接殼 jedi=7/host=2。檔頭自陳「補套件沒有的主專案職責——分頁、filter、main_version 字串解析、建框架時順手建首版」。模組層級的 apply_sorts_in_memory(在 Python 端做多欄排序,因為套件 query 不支援跨欄位 OR 搜尋)被 framework_version_app_service.py:26 借用 ✅ 帶 — require_platform_admin;分頁/排序/搜尋是主專案的 API 契約
ssp_resources_context_service.py 230 轉接殼+業務 jedi=3/host=1。落 v2 ssp_components,以 type 區分 hardware/system。兩個入口共用這一支:專案走 SspResourcesAppService、資源庫範本走 module_frame 的 ModuleFrameSspResourcesService ✅ 帶 — ① FR-032 軟參照欄(device_id/system_characteristic_id/responsible_party 走 props)② 讀取時 enrich 設備/資訊系統主檔名稱 ③ sys_impl_main_id/ensure_sys_impl_main_id 是刻意保留的 no-op 簽章,讓兩個入口不必改
framework_version_app_service.py 229 轉接殼 jedi=5/host=3。檔頭自陳「對外對齊舊 v1 版本管理 response shape(FE 零改動)」。版本樹(parent/children/is_root)在本層用 Python 算,避免動套件 repo 的 NULL 過濾 ✅ 帶 — require_platform_admin;v1 shape 相容是主專案對 FE 的契約
ssp_inventory_items_app_service.py 194 轉接殼+業務 jedi=4/host=6。直接 import module_frame 的 ModuleFrameInventoryService 當欄位對應表(_InvMap/_build_props/_build_implemented) 🔴 帶 — FR-032 master-snapshot:picker 帶 ref_id 時由後端從主檔撈明細寫入(不信前端傳值、單向凍結),free input 才原樣存。檔頭寫明這是相對範本版的差異——範本可以 store-as-is,專案是租戶實際資料不行
ssp_party_app_service.py 169 轉接殼 jedi=5/host=3。共用 module_frame 的 ModuleFramePartyService 純 mapping 靜態方法。自己只做「metadata_id 從哪來+權限」 ✅ 帶 — SspPermissionChecker;metadata_id 解析(party 在 v2 掛 SSP 的 metadata 而非 SSP 本體,get_ssp 只吃 uuid 故繞 list_ssps 取 root)
ssp_components_app_service.py 142 轉接殼 jedi=4/host=5。同上,共用 ModuleFrameComponentsService 的 _to_dict/_build_props ✅ 帶 — SspPermissionChecker;uid(OSCAL uuid)↔︎ int id 的對外識別轉換
ssp_leveraged_app_service.py 141 轉接殼 jedi=4/host=5。同上,共用 ModuleFrameLeveragedService 🔴 帶 — 繼承範本版的空值哨兵:party_uuid(uuid 非空欄)空值落全零 UUID、date_authorized(非空欄)空值落今天,讀回時換成 None。這是主專案替套件的設計缺口補位——見 ⑤ 5.2
ssp_system_characteristic_app_service.py 68 轉接殼(薄) jedi=3/host=3。共用 ModuleFrameSystemCharacteristicService 的 mapping ✅ 帶 — 空 SC 時 GET 回 placeholder、PUT 首次自動建立
ssp_resources_app_service.py 52 轉接殼(薄,真的只轉呼叫) jedi=2/host=2。全部委派 SspResourcesContextService,自己只做 ssp_uid → ssp_id 反查與守門 ✅ 帶 — SspPermissionChecker
oscal_export_app_service.py 47 轉接殼 jedi=2/host=1。接套件 OscalIoService.export_oscal。注意它註冊在 flow_control_container 不是 oscal_container(flow_control_containers.py:230)。使用者是 oscal_framework_version_route.py:138(框架簽章下載),不是被註解掉的 OscalExportRoute ⚠️ 帶一點 — 本期權限只有 jwt 認證,檔頭自陳 per-project 授權是 follow-up
service/__init__.py 0 共用設施 空 —
ssp_control_implementation_service.py 的殼段/業務段拆解

這支 1,004 行、19 個套件引用,是全模組最像「殼」卻最不是殼的一支。按方法區塊估算:

區段 約行數 性質
build_control_tree_by_ssp_id(408-807) 400 業務——四份資料(catalog 樹/已填值/本輪任務/上輪結果)揉成前端的樹
_resolve_inscope_catalog(160-223)+_frozen_round_control_scope(295-333)+_prev_round_context(359-405) 155 業務——稽核輪次的控制項範圍判定,是產品的輪次模型
build_classifier_catalog_by_ssp_id(237-292) 56 業務——給證據分類器用的控制項母體
CRUD 三支(get/update/list_control_implementations) 57 殼——轉呼叫套件 IR CRUD
程序書關聯六支(add_reference_documents 等) 47 殼——全部一行轉呼叫 SspDocumentPoolService
get-or-create 與 props 解析小工具(_ir_for_control/_get_or_create_*/_props_to_map 等 9 支) 82 共用設施(同檔內用,另 _P_DESC/_P_STATUS/_props_to_map 三個被 ssp_control_impl_import_service.py:34-38 借用)

粗分約 業務 611 行/殼 104 行/工具 82 行,其餘是 import 與類別骨架。 這個拆法是按方法區塊估的,不是逐行計數。

import_diff/ — 4 檔/1,384 行,整包零套件引用

grep -rnE '^\s*(from|import) jedi_' app/oscal/service/import_diff/ | wc -l   # → 0
檔 行 類別 依賴證據
ssp_diff_service.py 751 真業務 零 jedi、零 DB、無狀態。7 個區段(控制項說明/逐 AO/參與方/元件/外部授權/資產清冊/系統特性)× 4 種差異狀態(未變/已改/新增/消失)的比對演算法+智慧預設表。呼叫端自己餵資料進來
decision_merge.py 318 真業務 零 import(只有標準庫)。把使用者逐項選的「用新的/保留現況/略過」摺成一份最終 OSCAL dict。關鍵設計是「保留現況=省略不寫」——套件的 update 模式不刪缺漏的列、也不把省略欄位設成 null,所以省略剛好等於保留
snapshot_views.py 315 真業務 零 import。把套件產的 OSCAL 快照 dict 重塑成比對器要的「現況側」視圖,鍵用的是自然身分(參與方 name/email、元件 title+type、控制項 control-id)
import_diff/__init__.py 0 共用設施 空

import_adapter/ — 6 檔/1,057 行

檔 行 類別 依賴證據
_common.py 620 真業務 零 import(只有標準庫)。Excel 與 Word 兩條匯入線共用的 OSCAL 組裝器——系統特性/元件/外部授權/資產清冊/SoA props。核心設計:OSCAL 要求實作說明掛在 by-component 上、by-component 又要指向一個 component,故每份匯入的 SSP 都合成一個標準的 this-system component 當錨點
docx_to_oscal_ssp.py 152 真業務 host=1(引 _common)、jedi=0。Word 專屬的參與方與控制項實作抽取
excel_to_oscal_ssp.py 144 真業務 host=1、jedi=0。Excel 專屬的同上
party_match_enrich.py 78 共用設施 jedi=1。把比對到的 matched_user_id/matched_org_unit_id 補上暱稱與帳號(CLAUDE.md 審計欄位規範)。批次查避 N+1、失敗靜默略過(預覽不可因此爆)
v2_candidate_loader.py 54 轉接殼 host=1、jedi=0(但實際跑時吃注入的套件 service)。從 v2 catalog 撈控制項候選清單餵給 Word 解析器
import_adapter/__init__.py 9 共用設施 re-export

excel_parser/ — 7 檔/1,047 行

檔 行 類別 依賴證據
sheet_handlers.py 428 真業務 jedi=0、host=4。9 張 sheet 的逐列解析。:43 直接 import module_frame 的 excel_template/sheet_definitions.py 的 10 個 sheet 常數——匯出端定義、匯入端引用,單一真相
v2_bundle.py 217 真業務 零 import。把 Excel 解析出的舊鍵(devices/info_systems/leveraged)換成新的 OSCAL 鍵(components/inventory_items/leveraged_authorizations)。缺這層匯入確認會直接 412
parser.py 153 真業務 jedi=1、host=4。openpyxl 入口,逐 sheet 分派給 handler
types.py 111 共用設施 零 import。ParsedExcel/ValidationError 兩個 dataclass
validators.py 66 真業務 host=1。逐列驗證器,含「參與方角色四值」與「勾選欄的 14 種真值寫法」(含 是、Y、1)
version_check.py 63 真業務 零 import。樣板版本相容性檢查。檔內 20 行註解說明為何拒絕 v2.x 與 v1.x:sheet 名與欄位鍵全不同,硬讀會雙重錯位、資料全錯,強制使用者重下樣板是唯一安全路徑
excel_parser/__init__.py 9 共用設施 re-export

export/ — 6 檔/883 行

檔 行 類別 依賴證據
ssp_docx_generator.py 258 真業務 零 jedi、host=2。docxtpl + python-docx 排版:渲染靜態段落 → 存檔 → 重開 → 逐控制項動態 append(標題三 + AO 表格 + 實作說明)→ 附錄。範本檔 app/oscal/templates/ssp/ssp_cmmc_template.docx 進版控,由 scripts/generate_ssp_docx_template.py 產生
ssp_v2_content_loader.py 217 轉接殼 jedi=1(實際跑吃注入的套件 service)、host=1。六個資料來源全部向套件拿(系統特性/人員/受評標的/外部服務/控制實作/控制標題),組成 SspExportDataModel
ssp_export_model.py 169 共用設施 零 import。純 dataclass,讓 generator 不必知道資料來源差異
ssp_libreoffice_converter.py 124 真業務 零 import(只有標準庫)。LibreOffice headless 子行程包裝(docx → pdf/odt),含三平台 binary 名稱解析與暫存檔清理
ssp_export_app_service.py 115 轉接殼 jedi=2、host=3。統一入口,協調 loader + generator + converter
export/__init__.py 0 共用設施 空

dto/ — 3 檔/29 行

檔 行 類別 依賴證據
dto/ssp/ssp_reference_document_dto.py 29 共用設施 jedi=1。被程序書池 service 與 route 用
dto/__init__.py/dto/ssp/__init__.py/app/oscal/__init__.py 0/0/7 共用設施 空或只有 docstring

domain/oscal(36 檔/4,549 行)

parser/ — 7 檔/2,027 行,整包零套件引用

grep -rnE '^\s*(from|import) jedi_' domain/oscal/parser/ | wc -l   # → 0
檔 行 類別 依賴證據
docx_section_extractors.py 892 真業務 零 import(只有 python-docx)。依「章節標題 + 表格欄位標題」雙錨點在 Word 裡定位特定表格並抽出欄位。回純 dict/list,不回 OSCAL 型別,讓各框架的轉換器自由組裝
docx_parser_core.py 555 真業務 零 jedi、host=4。Word 檔的通用結構解析(段落/表格依文件順序走訪)+控制項識別碼比對
ssp_intermediate.py 369 共用設施 零 jedi、host=2。OSCAL 對齊的中間層 dataclass(ParsedSsp/ParsedParty/ParsedComponent 等)。被 7 處模組內引用,是整條匯入線的共同語言。命名一律用 OSCAL 術語、不用 CMMC 專有名詞,框架專屬屬性統一塞 framework_specific_props
control_id_matcher.py 120 真業務 零 jedi、host=1。控制項識別碼的樣式比對與模糊比對(rapidfuzz)。檔頭記 FR-069 P0-② 把它從 common/util/ 搬來的理由:它是 OSCAL 專屬業務知識,放 common/ 會造成 common/ → domain.oscal 的反向依賴
docx_intermediate.py 72 共用設施 零 import。ParsedDocx/CandidateControl 等 dataclass,被 5 處引用
framework_patterns.py 19 真業務 零 import。四種框架(CMMC L1/L2、NIST 800-171、ISO 27001)的控制項識別碼正規式。CMMC 那條同時吃 1.0 與 2.0 兩種寫法,因為實際客戶文件常混用
parser/__init__.py 0 共用設施 空

adapter/ — 4 檔/959 行

檔 行 類別 依賴證據
cmmc_ssp_adapter.py 860 真業務 零 jedi、host=4。把框架無關的 ParsedDocx 轉成 OSCAL 對齊的 ParsedSsp。整支是 CMMC 的文件慣例知識:封面表格哪一格是版本、五個固定角色的順序、哪個角色對應「單位」哪些對應「人員」(含一條反直覺的:Information Provider 字面是上游廠商,但實際填的是窗口聯絡人,要對使用者不對單位)
i_ssp_docx_adapter.py 78 共用設施 零 jedi、host=2。轉換器的抽象介面
adapter_registry.py 21 共用設施 host=1。框架代號 → 轉換器的查表。設計理由:加 ISO 27001 只要在 DI 加一行註冊,不必動 app service
adapter/__init__.py 0 共用設施 空

service/ — 14 檔/1,043 行

檔 行 類別 依賴證據
ssp_project_resolver.py 184 共用設施(跨模組) jedi=3、host=1。ssp_uid → SSP/專案的反查鏈+寫入階段判定。被 common/authz/ssp/SspPermissionChecker 用(授權軸的資源解析),屬全專案共用的守門基礎設施
ssp_context_resolver.py 158 共用設施 jedi=1、host=2。判斷一份 SSP 屬專案還是資源庫範本,讓同一個端點雙端共用。走 profile 反查而非 SSP.template_module_frame_id——檔頭寫明後者雖有欄位但從沒接上、DB 該欄 100% 是 null
framework_parse_job_domain_service.py 123 真業務 零 jedi、host=2。框架解析工作的 domain service(自持表)
reconciliation/base.py 107 共用設施 host=1。比對演算法的抽象基底(三段回退:完全相符 → 正規化後相符 → 模糊相符),子類只實作 5 個掛鉤
reconciliation/person_reconciler.py 101 真業務 jedi=1、host=4。解析出的人員 ↔︎ 系統使用者的比對
reconciliation/organization_reconciler.py 101 真業務 jedi=1、host=4。解析出的單位 ↔︎ 系統組織單位的比對
ssp_docx_parse_job_domain_service.py 71 真業務 零 jedi、host=2。自持表的 domain service
ssp_excel_parse_job_domain_service.py 71 真業務 同上
reconciliation/_normalizers.py 51 共用設施 零 import。字串正規化 helper
party_reconciliation_service.py 30 共用設施 host=1。門面:依 party_type 把混合清單分派給兩個比對器。檔頭寫明簽章 100% 不變、既有呼叫端零行變動
reconciliation/__init__.py 18 共用設施 host=2。只 re-export 不 import ssp_intermediate 的那兩支——檔頭寫明 re-export 具體比對器會觸發循環 import,呼叫端與 DI 必須從各自模組路徑 import
reconciliation/match_method.py 10 共用設施 零 import。比對方式列舉,被 7 處引用
service/__init__.py 0 共用設施 空

entity/ 與 repository/ — 10 檔/520 行

檔 行 類別 依賴證據
entity/framework_parse_job_entity.py 76 共用設施 零 import。entity + query entity,被 5 處引用
entity/ssp_docx_parse_job_entity.py 70 共用設施 同上,4 處
entity/ssp_excel_parse_job_entity.py 70 共用設施 同上,4 處
import_pipeline/bundle_restore.py 208 真業務 零 jedi、host=2。確認階段的 dict ↔︎ dataclass 還原。只支援新版形狀,舊版拋 LegacyConfirmRequired 讓呼叫端掉回舊路徑
repository/i_framework_parse_job_repo.py 39 共用設施 host=1。抽象介面
repository/i_ssp_docx_parse_job_repo.py/i_ssp_excel_parse_job_repo.py 25/25 共用設施 同上
repository/i_ssp_catalog_title_query.py 24 共用設施 零 import。控制標題查詢的抽象介面,被 module_frame 那側也用到(同一份 v2 catalog 鏈)
entity/__init__.py/repository/__init__.py/import_pipeline/__init__.py/domain/oscal/__init__.py 0/0/1/0 共用設施 空

infra/oscal(14 檔/499 行)

檔 行 類別 依賴證據
repository/ssp_catalog_title_query.py 93 真業務 jedi=4、host=1。跨疆界唯讀查詢:ssp_id → import_profile_id → source_catalog_id → 控制標題與 AO 清單。檔頭記舊版走已退役的 AP 鏈、且 from jedi_oscal.infra.model.ap... 會把 v1 套件拉進啟動圖撞 v2 同名表直接炸 create_app
repository/framework_parse_job_repo_impl.py 91 真業務 jedi=1、host=4。繼承 BaseRepositoryImpl
repository/ssp_excel_parse_job_repo_impl.py 56 真業務 同上
repository/ssp_docx_parse_job_repo_impl.py 54 真業務 同上
mapper/framework_parse_job_mapper.py 39 共用設施 host=2。entity ↔︎ model
model/framework_parse_job.py 36 真業務 jedi=2。ORM,含 TenantScopedMixinModel(多租戶)
mapper/ssp_docx_parse_job_mapper.py/ssp_excel_parse_job_mapper.py 35/35 共用設施 host=2 各
model/ssp_docx_parse_job.py/ssp_excel_parse_job.py 30/30 真業務 jedi=2 各
__init__.py×4(infra/oscal/mapper/model/repository) 0×4 共用設施 空

§4

③ 分類統計

分類單位是檔。混住兩種性質的檔(如 ssp_control_implementation_service.py 是業務+殼、 ssp_document_pool_service.py 是殼+業務)按主體歸類,另在 ② 表的證據欄註明混住情形。

類別 檔數 佔比 行數 佔比
真業務 37 22.7% 11,494 57.5%
轉接殼 34 20.9% 4,757 23.8%
共用設施 86 52.8% 3,619 18.1%
死碼 6 3.7% 126 0.6%
還看不準 0 0% 0 0%
合計 163 100% 19,996 100%

共用設施佔過半檔數但只佔 18.1% 行數——裡面有 22 支空的或只有一兩行的 __init__.py, 加上 43 支純 marshmallow Schema 與一批 dataclass。看行數比看檔數準。

怎麼重跑統計

把 ② 表的每一列(檔路徑 → 類別)餵進下面這段,行數用 wc -l 現算:

git ls-files api/oscal app/oscal domain/oscal infra/oscal \
  | grep '\.py$' | while read f; do printf '%s\t%s\n' "$(wc -l < "$f"|tr -d ' ')" "$f"; done

行數合計必須等於 19,996,檔數必須等於 163;對不上就是分類有漏或重複。

按層分布:

層 真業務 轉接殼 共用設施 死碼
api/ 0 19 檔/2,188 行 42 檔/1,670 行 6 檔/126 行
app/ 19 檔/7,983 行 15 檔/2,569 行 12 檔/412 行 0
domain/ 11 檔/3,121 行 0 25 檔/1,428 行 0
infra/ 7 檔/390 行 0 7 檔/109 行 0

四件事從這張表看得出來:

  1. api/ 一支真業務都沒有,全是殼與序列化器。這是乾淨的形狀——route 層不該有業務。 對照 module_frame 那邊 api/ 還有一支 162 行的真業務(route 層自己用 pandas 讀 Excel 比對欄位標題), oscal 這邊沒有同類問題。
  2. domain/ 3,121 行真業務、零殼——這是全模組最值錢的一塊:Word 解析(2,027)、 CMMC 轉換(860)、比對器(202)、匯入還原(208)。套件完全不參與。
  3. 殼集中在 app/ 與 api/(4,757 行的 100%),與 module_frame 同形。
  4. 六支死碼全在 api/,且全是序列化器與一支被註解掉的 route,沒有一支在 app/ 以下。

對 FR-102 總表 B 該列(README.md:100)逐句核對:

FR-102 原句 核對結果
「domain/oscal 4,549 行是真 domain 資產(docx 解析 892 + CMMC adapter 860 + parser core 555)」 成立,三個數字逐一重跑相符。但「4,549 行全是真 domain 資產」需精確化:實查 3,121 行是真業務,另 1,428 行是 dataclass/抽象介面/resolver 這類共用設施
「import_diff/ssp_diff_service.py(751 行)零個 jedi_oscal_v2 引用、全自寫」 成立,且整包 import_diff/ 4 檔 1,384 行全都零套件引用(比原句涵蓋更廣)
「infra/oscal 自己持有三張匯入 job 表」 成立。三張表的 repo 全都有寫入者也有讀取者(卡片問的「只寫不讀/只讀不寫孤兒」不成立):三支 domain service 各被對應的 app service 注入使用
「framework_app_service.py(247 行)幾乎每支就一行轉呼叫套件,檔頭自陳補套件沒有的主專案職責——分頁、filter」 成立。另補:同檔的 apply_sorts_in_memory 被 framework_version_app_service 借用,是兩支殼共用的排序工具
「真業務與轉接殼混住同一 service 目錄,模組層級單一標籤蓋不住」 成立,這正是本表存在的理由

FR-102 對本模組的五句證據沒有一句已失效,兩句需要精確化。


§5

④ 可以立刻收的

判準是「說得出證據才叫可收」。下面每一項都附了怎麼驗它真的沒人用。 本卡只分析不動程式,列在此處是備妥證據,清不清屬異動、要決策者裁。

4.1 一支被註解掉的 route 檔(39 行)

api/oscal/routes/oscal_export_route.py 整檔(定義 OscalExportRoute)。

四層查證結果:

層 結果
route 註冊 ❌ __init__.py:89 被註解
模組內 import ❌ __init__.py:88 那行 import 也一起被註解
模組外 import ❌ 0(grep -rn "OscalExportRoute" --include='*.py' . 只命中定義本身、兩行註解,以及三處提到它名字的文字說明)
DI 註冊 不適用(Resource 類別不進 DI)

怎麼驗:

grep -rn "OscalExportRoute" --include='*.py' . | grep -v __pycache__
grep -rn "oscal/export/" ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/
grep -rn "oscal/export/" ~/Projects/Billows/Audit-Manager/compliance-manager-test/site-regression/

⚠️ 但它背後的 OscalExportAppService(47 行)是活的,不能一起收—— api/oscal/routes/framework/oscal_framework_version_route.py:138 用它做框架的 OSCAL 下載 (export('catalog', catalog_uid, fmt='json')),走 flow_control_container 注入。 另外三處註解文字(ssp_export_app_service.py:6,113、oscal_containers.py:406) 寫著「json/yaml/xml 匯出已移至 OscalExportRoute」——那個目的地其實是關著的, 這三行說明與現況不符。

4.2 兩條註冊了但前端零呼叫的資源庫端點

GET /oscal/resource-library/<uid>(詳情)與 POST /oscal/resource-library/<uid>/publish(發布), 對應 resource_library_route.py 的 ResourceLibraryRoute(67-73 行)與 ResourceLibraryPublishRoute(76-84 行)。

四層查證結果:

層 結果
FE① api.js 常數 ✅ 有定義(RESOURCE_LIBRARY:145,註解寫「+ /<uid> [GET 詳情] [+ /publish POST]」)
FE② 實際呼叫 ❌ 全前端零命中——grep -rn "API.RESOURCE_LIBRARY\b" src/ 回 0 行,grep -rn "resource-library" src/ 只命中三行註解文字
③ ui_routes 不適用(不是選單)
④ 非瀏覽器 ❌ 0——e2e step code 零命中(只在 docs/test-cases/module-frame.md 的文字說明裡被提到)、scripts/ 與 bin/ 零命中

怎麼驗:

cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
grep -rn "RESOURCE_LIBRARY\b" src/ | grep -v "config/api/api.js"
grep -rn "resource-library/" src/
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
grep -rn "resource-library/" site-regression/steps/ site-regression/support/ site-regression/pages/

⚠️ 兩支背後的 service 方法不能一起收:

  • get_resource_library(resource_library_app_service.py:205)被六處後端內部呼叫 (ssp_excel_import_app_service.py:367,464,817、ssp_docx_import_app_service.py:165,337,454,603), 是匯入線驗證「這個資源庫存不存在」的手段。
  • publish_resource_library(:533)只有這個端點呼叫,收掉端點等於收掉整個發布功能。 「發布」是資源庫從租戶私有變成系統公版的唯一途徑(建立端點刻意不掛平台管理員守門, 就是因為治理靠發布這關擋)。列在此處只是標出「目前對外不可達」,不是建議砍。

4.3 五支互相引用成封閉圈的死序列化器(87 行)

檔 行 類別名
api/oscal/serializers/ssp/ssp.py 29 SystemSecurityPlanResponseSchema
api/oscal/serializers/ssp/ssp_system_characteristic.py 18 SystemCharacteristicResponseSchema
api/oscal/serializers/ssp/ssp_system_implementation.py 17 SystemImplementationResponseSchema
api/oscal/serializers/framework/oscal_framework_menu.py 12 OscalFrameworkMenuResponseSchema
api/oscal/serializers/framework/oscal_framework_version_menu.py 11 OscalFrameworkVersionMenuResponseSchema

以及 api/oscal/serializers/__init__.py:10-14 對它們的五行 re-export。

四層查證結果:

層 結果
route 使用 ❌ 0——沒有任何 route 的 @marshal_with 或 .dump() 用到這五個類別
模組內 import 只有 serializers/__init__.py 的 re-export,以及圈內互相的字串式 Nested
模組外 import ❌ 0(唯一一處 from api.oscal.serializers import ... 是 oscal_framework_version_route.py:11,拿的是 OscalFrameworkVersionResponseSchema,不在這五支裡)
DI 註冊 不適用

怎麼驗(必須連字串式 Nested 一起查,只 grep import 會漏):

for c in SystemSecurityPlanResponseSchema SystemCharacteristicResponseSchema \
         SystemImplementationResponseSchema OscalFrameworkMenuResponseSchema \
         OscalFrameworkVersionMenuResponseSchema; do
  echo "--- $c"
  grep -rn "\b$c\b" --include='*.py' . | grep -v __pycache__ | grep -v '^./docs/'
done

每一支的命中應該只有三種:自己的 class 定義、serializers/__init__.py 的 re-export、 圈內另一支的字串式 Nested。有第四種就表示它是活的。

🔴 兩個清理時會踩的坑:

  1. 名字只差三個字母。 活的是 ssp_system_characteristic_inproject.py 的 SspSystemCharacteristicResponseSchema(有 Ssp 前綴,被 ssp_system_characteristic_route.py:14,23,38 用);死的是 ssp_system_characteristic.py 的 SystemCharacteristicResponseSchema(無前綴)。 同理框架選單:活的是 oscal_framework.py:70 的 OscalFrameworkMenuSchema(無 Response), 死的是 oscal_framework_menu.py 的 OscalFrameworkMenuResponseSchema(有 Response)。 grep 時不加 \b 邊界會兩邊一起命中,直接誤判。

  2. 守衛測試的白名單不是「它是活的」的證明。 test/test_module_boundaries.py:2028-2042 把這幾支的字串式 Nested 行列在白名單,理由寫「本卡範圍外未動」——那是 FR-092 第 18 棒的作業範圍註記。真要清,這幾行白名單也要一起移除,否則守衛會 指向不存在的檔案行號。

4.4 沒有的

以下曾被懷疑、實查後確認不可收,列在此處避免下一個人重查:

  • infra/oscal 三張匯入工作表的 repo:三支都有寫入者也有讀取者(各自的 domain service 被對應的 app service 注入),沒有孤兒。
  • ssp_scoped_excel_import_route.py 的兩個被註解類別:雖然 route 註冊被註解,但 同檔的 SspScopedExcelImportUploadRoute 仍註冊且前端在用(SspExcelImportService.js:56), 整檔不可收,只有那兩個類別可議——而它們的 service 方法是共用的,收了也省不到行數。
  • app/oscal/service/oscal_export_app_service.py:見 4.1 的警告,框架 OSCAL 下載在用。

§6

⑤ 要設計才能動的

5.1 兩支匯入匯出 service 各寫一份 Excel 欄位契約

app/oscal/service/ssp_control_impl_import_service.py(431 行)與 app/module_frame/service/module_frame_template_import_service.py(1,008 行) 各自定義了同一份 Excel 樣板的欄位契約。實查逐項比對結果:

常數 比對結果
九欄中文標題 逐字相同,但變數名不同:oscal 叫 HEADER_LABELS、module_frame 叫 EXPECTED_HEADERS
六個狀態值(VALID_STATUSES) 值相同、排版不同(一個寫成三行、一個寫成六行)
四個配色常數(標題字/標題底/唯讀底/可編輯底) 逐字相同
換行對齊(WRAP_ALIGNMENT) 逐字相同
邊框(THIN_BORDER) 逐字相同
兩個 props 名(_P_STATUS/_P_DESC) oscal 側不自己定義,直接 import 自 ssp_control_implementation_service;module_frame 側自己寫一份
MAX_FILE_SIZE(10 MB) 只有 oscal 側有

逐字重複的約 25 行(module_frame 那側的檔頭第 3 行明寫「格式對齊 app/oscal/service/ssp_control_impl_import_service.py」)。

為什麼現在動不了:三個候選落點各有代價,且與「這兩個模組誰是誰的上游」這個更大的 疆界問題綁在一起——

  • 抽到 common/:兩邊都只是引用者,但「Excel 樣板的欄位契約」不是通用工具, 放 common/ 會讓 common/ 開始長業務知識。
  • 留在其中一側讓另一側引用:現有依賴方向是 oscal → module_frame(v2 子物件 的欄位對應表、Excel sheet 定義都在 module_frame 那側定義),照這個方向應該是 module_frame 定義、oscal 引用。但 _P_STATUS/_P_DESC 的方向恰好相反 (module_frame 自己寫一份,oscal 從自己模組內 import)——兩個方向並存。
  • 進套件:欄位標題是中文、是主專案的 FE 契約,套件不該知道。

需要什麼前置:先定「module_frame 與 oscal 誰是誰的上游」。目前跨模組 import 共 9 處、方向全是 oscal → module_frame(app/oscal 六支引用 app/module_frame 的 service 與 excel_template/sheet_definitions),反向只有 3 處 (module_frame 的 ssp-resources service、兩支 serializer、一支匯出 route)。 方向已經很清楚是 module_frame 在上游,但這與直覺相反(oscal 比較大、比較底層), 需要決策者確認這是刻意的還是長歪的。

5.2 五支 SSP 子物件殼與資源庫範本版是共用不是重複

卡片問「五支 ssp_*_app_service 與 module_frame 六支殼是否鏡像」。實查結論: 不是兩份真相,是同一份——五支全都直接 import module_frame 那側的對應表:

oscal 側 行 import 了 module_frame 的什麼
ssp_inventory_items_app_service.py 194 ModuleFrameInventoryService as _InvMap、_build_props、_build_implemented
ssp_party_app_service.py 169 ModuleFramePartyService(另在方法內兩處延遲 import 避循環)
ssp_components_app_service.py 142 ModuleFrameComponentsService as _CompMap、_to_dict、_build_props
ssp_leveraged_app_service.py 141 ModuleFrameLeveragedService
ssp_system_characteristic_app_service.py 68 ModuleFrameSystemCharacteristicService as _ScMap

五支的檔頭都明寫「欄位 mapping 與資源庫範本版完全相同……故直接共用其純 mapping 靜態方法,確保兩端一致、單一真相。差別只在『ssp_id 從哪來 + 權限』」。 module_frame 那一側的分類表記的也是同一件事,兩邊對得上。

module_frame 那側是六支(多一支 ModuleFrameSspResourcesService), oscal 這側對應的 SspResourcesAppService 走的是第三支共用 service (ssp_resources_context_service.py,住在 oscal 這側,被兩邊共用)—— 所以嚴格說是 5 + 1 對 5 + 1,不是六對六。

卡在哪:這五支殼繼承了範本版的空值哨兵——套件的資料表有幾個欄位不准留空, 前端允許留空,主專案就塞假值進去(party_uuid 塞全零 UUID、date_authorized 塞今天), 讀出來時再換回空白。這件事在 module_frame 那一側的分類表也列為待裁。 在 oscal 這一側看,問題更嚴重一級: 範本 SSP 是內部資料,專案 SSP 是租戶的實際稽核資料,全零 UUID 與「今天」這個假日期 會跟著匯出進客戶的系統安全計畫書。目前 _to_dict 有換回 None,但那是讀路徑的補救—— 任何第三個寫入者(例如未來新增的批次匯入)不知道這個看不見的約定,就會把假值寫成真值。

需要什麼前置:與 module_frame 那一側同一題——jedi_oscal_v2 除本專案外還有沒有別的使用者。 有就只能維持現狀;沒有就該把欄位改成可留空。

5.3 殼裡混著業務、拆不拆會影響套件契約的三支

下列三支的「殼」與「業務」纏在同一個方法裡,不是分開的區段,拆解會動到套件契約:

檔 混住情形 拆了會動到什麼
ssp_control_implementation_service.py(1,004 行) build_control_tree_by_ssp_id 一個方法 400 行,同時做「向套件拿 catalog 與 IR」(殼)與「疊上本輪任務狀態、上一輪結果、輪次凍結範圍」(業務)。兩者在同一個迴圈裡 要拆就得讓套件回一個「帶洞的樹」讓主專案填,等於在套件開一個主專案專屬的回傳形狀
framework_parse_job_service.py(674 行) confirm(118 行)裡,使用者的逐項決策(業務)與 catalog 落地(殼)交錯 同上,套件的 catalog 匯入要能接受「部分覆寫」的參數
resource_library_app_service.py(541 行) create_resource_library(104 行)四段編排全都是「呼叫套件 → 拿結果 → 決定下一步」,中間夾主專案的 link record 原生 SQL 「資源庫」這個概念要不要進套件是更大的問題(套件只有 catalog/profile/SSP 三個原語,三件組是主專案組出來的)

這三支不列在 ④「可以立刻收的」,因為它們不是可收,是可能該搬——而搬的前提是 先決定套件契約要不要變。

5.4 前端打得到、後端不存在的一條路徑

前端 src/service/ModuleFrameTemplateService.js:198 打 GET /oscal/catalog-controls/<control_uid>/assessments,常數定義在 api.js:180。 後端全 repo 沒有註冊這條 route(grep -rn "catalog-controls" --include='*.py' . 零命中, 套件側也零命中)。

呼叫者是 ModuleFrameTemplateEditView.vue:566 的 loadAOsForControl()。 現況不會壞,因為那段程式碼上方註解寫明「AO 已在 buildTreeFromProfile 從 module_frame.oscal_profile 預載,此處 cache 應該總是命中。走 API 只是防呆」, 且整段包在 try/catch 裡、失敗只 console.warn 並回空陣列。

卡在哪:這是個沉默的後備路徑——真的走到(快取沒命中)時會拿到 404, 然後靜默回空陣列,前端的 AO 清單變空白、不報錯。要嘛後端補這條 route, 要嘛前端把這個後備拿掉改成明確報錯。屬前端與後端契約問題,不在本卡的分析範圍內。

另外兩條同型的(都在 ComplianceFrameworkVersionManage.vue): OSCAL_FRAMEWORK_VERSION_IMPORT(/oscal-framework-version/import/<type>)與 OSCAL_FRAMEWORK_VERSION_SAMPLE_DOWNLOAD(/oscal-framework-version/download/sample) 後端也都不存在。但這兩條的前端入口本身是關著的——開啟該對話框的選單項 onUpdateVersionDocClick 在 :596-599 被整段註解(註解寫「v2 兩階段化後暫時隱藏」), 所以使用者點不到。三條合起來看是同一件事:框架匯入從舊的「上傳整份文件」 改成新的「解析工作」兩階段流程後,前端留下了指向舊端點的殘骸。


§7

需要決策者裁的

本表遇到「兩種合理判法、不自選」的有三件:

一、module_frame 與 oscal 誰是誰的上游

跨模組 import 的方向已經很清楚是 oscal 依賴 module_frame(9 處對 3 處), 包括五支 SSP 子物件的欄位對應表、Excel sheet 定義。但這與規模直覺相反 (oscal 兩倍大、概念上更底層)。

  • 維持現狀(module_frame 是上游):零風險,但每個新來的人看到「大模組依賴小模組」 都會想反過來改,而改了會壞。
  • 反轉(把共用的欄位對應表搬去 oscal):符合直覺,但要動五支 SSP 子物件 service +六支 module_frame service,且 module_frame 那側的 Excel 樣板產生器(1,656 行) 也在被 oscal 引用,一併反轉範圍會很大。

判斷需要的資訊:這兩個模組未來會不會有一個進套件。若 module_frame(資源庫) 要進套件,現在的方向剛好(套件不能依賴主專案);若 oscal 要進,方向就是錯的。

二、5.1 那約 25 行 Excel 欄位契約要不要抽、抽去哪

module_frame 那一側的分類表也把這題列為待裁,建議兩個模組的結果一起看。 從 oscal 這一側實測,逐字重複約 25 行,但變數名不一致 (HEADER_LABELS vs EXPECTED_HEADERS)反而是更麻煩的部分——兩邊各改一次時, grep 同一個名字只會找到一半。

三個候選落點與代價見 5.1。這題與第一題綁死,建議一起裁。

三、四類「對外不可達」的東西要不要清

項目 行數 收掉的風險
4.1 被註解的 oscal_export_route.py 39 低——但要一併修三處指向它的過期註解
4.2 資源庫詳情與發布兩條端點 — 🔴 高——發布是公版治理的唯一途徑,收了等於砍功能。建議只清「詳情」那條或兩條都不動
4.3 五支死序列化器 + 五行 re-export 87 中——要同步移除守衛測試白名單三行,且名字極易誤刪(見 4.3 的兩個坑)
5.4 前端三條指向不存在端點的殘骸 — 中——屬前端 repo,且其中一條是沉默的後備路徑,拿掉前要確認快取真的總是命中

「清」屬異動,本案只分析不動程式。