universal-dev-standards 6.8.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/bin/uds.js +12 -2
  2. package/bundled/ai/standards/acceptance-criteria-traceability.ai.yaml +14 -2
  3. package/bundled/ai/standards/adr-standards.ai.yaml +14 -2
  4. package/bundled/ai/standards/code-review.ai.yaml +13 -3
  5. package/bundled/ai/standards/commit-message.ai.yaml +8 -4
  6. package/bundled/ai/standards/deferred-item-exit.ai.yaml +225 -0
  7. package/bundled/ai/standards/feature-discovery-standards.ai.yaml +14 -2
  8. package/bundled/ai/standards/governance-layer.ai.yaml +128 -2
  9. package/bundled/ai/standards/logging.ai.yaml +2 -2
  10. package/bundled/ai/standards/retrospective-standards.ai.yaml +14 -2
  11. package/bundled/ai/standards/reverse-engineering-standards.ai.yaml +73 -2
  12. package/bundled/ai/standards/security-standards.ai.yaml +2 -2
  13. package/bundled/ai/standards/spec-driven-development.ai.yaml +14 -2
  14. package/bundled/ai/standards/tech-debt-standards.ai.yaml +87 -3
  15. package/bundled/ai/standards/turn-completion-integrity.ai.yaml +131 -0
  16. package/bundled/core/acceptance-criteria-traceability.md +5 -2
  17. package/bundled/core/adr-standards.md +26 -2
  18. package/bundled/core/code-review-checklist.md +5 -2
  19. package/bundled/core/context-aware-loading.md +1 -1
  20. package/bundled/core/deferred-item-exit.md +254 -0
  21. package/bundled/core/feature-discovery-standards.md +5 -1
  22. package/bundled/core/governance-layer.md +114 -2
  23. package/bundled/core/retrospective-standards.md +4 -2
  24. package/bundled/core/reverse-engineering-standards.md +81 -2
  25. package/bundled/core/spec-driven-development.md +8 -2
  26. package/bundled/core/tech-debt-standards.md +67 -8
  27. package/bundled/core/turn-completion-integrity.md +196 -0
  28. package/bundled/hooks/check-dangerous-cmd.mjs +60 -0
  29. package/bundled/hooks/check-logging-standard.mjs +59 -0
  30. package/bundled/hooks/check-turn-completion.mjs +233 -0
  31. package/bundled/hooks/inject-standards.mjs +183 -0
  32. package/bundled/hooks/telemetry-wrapper.mjs +77 -0
  33. package/bundled/hooks/turn-completion/detect.mjs +99 -0
  34. package/bundled/hooks/turn-completion/locales/en.mjs +159 -0
  35. package/bundled/hooks/turn-completion/locales/zh-TW.mjs +166 -0
  36. package/bundled/hooks/validate-commit-msg.mjs +104 -0
  37. package/bundled/locales/zh-CN/CHANGELOG.md +47 -3
  38. package/bundled/locales/zh-CN/CLAUDE.md +1 -1
  39. package/bundled/locales/zh-CN/README.md +2 -2
  40. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  41. package/bundled/locales/zh-CN/core/adr-standards.md +1 -1
  42. package/bundled/locales/zh-CN/core/governance-layer.md +118 -6
  43. package/bundled/locales/zh-CN/core/retrospective-standards.md +1 -1
  44. package/bundled/locales/zh-CN/core/tech-debt-standards.md +71 -4
  45. package/bundled/locales/zh-CN/core/turn-completion-integrity.md +190 -0
  46. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +8 -1
  47. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +29 -68
  48. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +25 -15
  49. package/bundled/locales/zh-CN/docs/USAGE-MODES-COMPARISON.md +1 -2
  50. package/bundled/locales/zh-CN/integrations/google-antigravity/{INSTRUCTIONS.md → AGENTS.md} +1 -1
  51. package/bundled/locales/zh-CN/integrations/google-antigravity/README.md +3 -3
  52. package/bundled/locales/zh-CN/skills/atdd-assistant/SKILL.md +2 -0
  53. package/bundled/locales/zh-CN/skills/bdd-assistant/SKILL.md +2 -0
  54. package/bundled/locales/zh-CN/skills/brainstorm-assistant/SKILL.md +22 -12
  55. package/bundled/locales/zh-CN/skills/brainstorm-assistant/guide.md +12 -9
  56. package/bundled/locales/zh-CN/skills/code-review-assistant/SKILL.md +1 -0
  57. package/bundled/locales/zh-CN/skills/commands/brainstorm.md +17 -13
  58. package/bundled/locales/zh-CN/skills/commands/config.md +0 -1
  59. package/bundled/locales/zh-CN/skills/commands/init.md +1 -2
  60. package/bundled/locales/zh-CN/skills/commit-standards/SKILL.md +2 -0
  61. package/bundled/locales/zh-CN/skills/contract-test-assistant/SKILL.md +1 -0
  62. package/bundled/locales/zh-CN/skills/dev-methodology/SKILL.md +2 -0
  63. package/bundled/locales/zh-CN/skills/observability-assistant/SKILL.md +1 -0
  64. package/bundled/locales/zh-CN/skills/project-structure-guide/SKILL.md +1 -0
  65. package/bundled/locales/zh-CN/skills/release-standards/SKILL.md +3 -0
  66. package/bundled/locales/zh-CN/skills/requirement-assistant/SKILL.md +2 -0
  67. package/bundled/locales/zh-CN/skills/reverse-engineer/SKILL.md +3 -0
  68. package/bundled/locales/zh-CN/skills/runbook-assistant/SKILL.md +1 -0
  69. package/bundled/locales/zh-CN/skills/slo-assistant/SKILL.md +1 -0
  70. package/bundled/locales/zh-CN/skills/tdd-assistant/SKILL.md +2 -0
  71. package/bundled/locales/zh-TW/CHANGELOG.md +47 -3
  72. package/bundled/locales/zh-TW/CLAUDE.md +1 -1
  73. package/bundled/locales/zh-TW/README.md +2 -2
  74. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  75. package/bundled/locales/zh-TW/core/acceptance-criteria-traceability.md +2 -0
  76. package/bundled/locales/zh-TW/core/adr-standards.md +26 -5
  77. package/bundled/locales/zh-TW/core/code-review-checklist.md +2 -0
  78. package/bundled/locales/zh-TW/core/container-image-standards.md +2 -2
  79. package/bundled/locales/zh-TW/core/contract-testing-standards.md +2 -2
  80. package/bundled/locales/zh-TW/core/cross-flow-regression.md +8 -7
  81. package/bundled/locales/zh-TW/core/data-contract.md +2 -2
  82. package/bundled/locales/zh-TW/core/data-migration-testing.md +2 -2
  83. package/bundled/locales/zh-TW/core/data-pipeline.md +2 -2
  84. package/bundled/locales/zh-TW/core/deferred-item-exit.md +251 -0
  85. package/bundled/locales/zh-TW/core/documentation-writing-standards.md +228 -3
  86. package/bundled/locales/zh-TW/core/full-coverage-testing.md +15 -2
  87. package/bundled/locales/zh-TW/core/governance-layer.md +118 -5
  88. package/bundled/locales/zh-TW/core/iac-design-principles.md +2 -2
  89. package/bundled/locales/zh-TW/core/incident-response.md +2 -2
  90. package/bundled/locales/zh-TW/core/model-provenance.md +4 -2
  91. package/bundled/locales/zh-TW/core/pii-classification.md +42 -6
  92. package/bundled/locales/zh-TW/core/prd-standards.md +4 -2
  93. package/bundled/locales/zh-TW/core/product-metrics-standards.md +4 -2
  94. package/bundled/locales/zh-TW/core/release-readiness-gate.md +2 -2
  95. package/bundled/locales/zh-TW/core/resource-cost-boundary.md +2 -2
  96. package/bundled/locales/zh-TW/core/retrospective-standards.md +5 -3
  97. package/bundled/locales/zh-TW/core/reverse-engineering-standards.md +83 -5
  98. package/bundled/locales/zh-TW/core/runbook.md +2 -2
  99. package/bundled/locales/zh-TW/core/schema-evolution.md +2 -2
  100. package/bundled/locales/zh-TW/core/secret-management-standards.md +2 -2
  101. package/bundled/locales/zh-TW/core/slo-sli.md +2 -2
  102. package/bundled/locales/zh-TW/core/spec-driven-development.md +2 -0
  103. package/bundled/locales/zh-TW/core/tech-debt-standards.md +71 -4
  104. package/bundled/locales/zh-TW/core/turn-completion-integrity.md +190 -0
  105. package/bundled/locales/zh-TW/core/user-journey-testing.md +2 -2
  106. package/bundled/locales/zh-TW/core/user-story-mapping.md +2 -2
  107. package/bundled/locales/zh-TW/core/verification-oracle.md +2 -2
  108. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +8 -1
  109. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +29 -68
  110. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +25 -15
  111. package/bundled/locales/zh-TW/docs/USAGE-MODES-COMPARISON.md +1 -2
  112. package/bundled/locales/zh-TW/integrations/google-antigravity/{INSTRUCTIONS.md → AGENTS.md} +1 -1
  113. package/bundled/locales/zh-TW/integrations/google-antigravity/README.md +3 -3
  114. package/bundled/locales/zh-TW/skills/adr-assistant/SKILL.md +1 -1
  115. package/bundled/locales/zh-TW/skills/atdd-assistant/SKILL.md +2 -0
  116. package/bundled/locales/zh-TW/skills/bdd-assistant/SKILL.md +2 -0
  117. package/bundled/locales/zh-TW/skills/brainstorm-assistant/SKILL.md +22 -12
  118. package/bundled/locales/zh-TW/skills/brainstorm-assistant/guide.md +12 -9
  119. package/bundled/locales/zh-TW/skills/code-review-assistant/SKILL.md +1 -0
  120. package/bundled/locales/zh-TW/skills/commands/brainstorm.md +17 -13
  121. package/bundled/locales/zh-TW/skills/commands/config.md +0 -1
  122. package/bundled/locales/zh-TW/skills/commands/init.md +1 -2
  123. package/bundled/locales/zh-TW/skills/commit-standards/SKILL.md +2 -0
  124. package/bundled/locales/zh-TW/skills/contract-test-assistant/SKILL.md +2 -1
  125. package/bundled/locales/zh-TW/skills/dev-methodology/SKILL.md +2 -0
  126. package/bundled/locales/zh-TW/skills/dev-workflow-guide/SKILL.md +1 -1
  127. package/bundled/locales/zh-TW/skills/knowledge-graph/guide.md +2 -2
  128. package/bundled/locales/zh-TW/skills/migration-assistant/SKILL.md +1 -1
  129. package/bundled/locales/zh-TW/skills/observability-assistant/SKILL.md +1 -0
  130. package/bundled/locales/zh-TW/skills/project-discovery/SKILL.md +1 -0
  131. package/bundled/locales/zh-TW/skills/project-structure-guide/SKILL.md +1 -0
  132. package/bundled/locales/zh-TW/skills/release-standards/SKILL.md +3 -0
  133. package/bundled/locales/zh-TW/skills/requirement-assistant/SKILL.md +2 -0
  134. package/bundled/locales/zh-TW/skills/reverse-engineer/SKILL.md +3 -0
  135. package/bundled/locales/zh-TW/skills/runbook-assistant/SKILL.md +1 -0
  136. package/bundled/locales/zh-TW/skills/slo-assistant/SKILL.md +1 -0
  137. package/bundled/locales/zh-TW/skills/tdd-assistant/SKILL.md +2 -0
  138. package/bundled/skills/atdd-assistant/SKILL.md +2 -0
  139. package/bundled/skills/bdd-assistant/SKILL.md +2 -0
  140. package/bundled/skills/brainstorm-assistant/SKILL.md +31 -13
  141. package/bundled/skills/brainstorm-assistant/guide.md +9 -6
  142. package/bundled/skills/code-review-assistant/SKILL.md +1 -0
  143. package/bundled/skills/commands/brainstorm.md +12 -9
  144. package/bundled/skills/commands/config.md +0 -1
  145. package/bundled/skills/commands/init.md +2 -3
  146. package/bundled/skills/commit-standards/SKILL.md +2 -0
  147. package/bundled/skills/contract-test-assistant/SKILL.md +1 -0
  148. package/bundled/skills/dev-methodology/SKILL.md +4 -0
  149. package/bundled/skills/observability-assistant/SKILL.md +1 -0
  150. package/bundled/skills/project-discovery/SKILL.md +1 -0
  151. package/bundled/skills/project-structure-guide/SKILL.md +1 -0
  152. package/bundled/skills/release-standards/SKILL.md +3 -0
  153. package/bundled/skills/requirement-assistant/SKILL.md +2 -0
  154. package/bundled/skills/reverse-engineer/SKILL.md +3 -0
  155. package/bundled/skills/runbook-assistant/SKILL.md +1 -0
  156. package/bundled/skills/slo-assistant/SKILL.md +1 -0
  157. package/bundled/skills/tdd-assistant/SKILL.md +2 -0
  158. package/bundled/templates/.ai-context.yaml.template +194 -0
  159. package/bundled/templates/CLAUDE.md.template +145 -0
  160. package/bundled/templates/DESIGN.md +237 -0
  161. package/bundled/templates/SKILL-BRIEF-TEMPLATE.md +57 -0
  162. package/bundled/templates/SKILL-CANDIDATES.md +39 -0
  163. package/bundled/templates/gates/check-error-exit.mjs +309 -0
  164. package/bundled/templates/mcp-config.json +10 -0
  165. package/bundled/templates/methodology-template.yaml +209 -0
  166. package/bundled/templates/migration-template.md +408 -0
  167. package/bundled/templates/requirement-checklist.md +410 -0
  168. package/bundled/templates/requirement-document-template.md +591 -0
  169. package/bundled/templates/requirement-template.md +881 -0
  170. package/bundled/templates/reverse-spec-template.md +409 -0
  171. package/bundled/templates/test-case-template.md +74 -0
  172. package/bundled/templates/test-plan-template.md +74 -0
  173. package/package.json +7 -5
  174. package/src/commands/audit.js +82 -0
  175. package/src/commands/check.js +66 -10
  176. package/src/commands/init.js +161 -16
  177. package/src/commands/update.js +286 -14
  178. package/src/compilers/claude-code-compiler.js +4 -1
  179. package/src/config/ai-agent-paths.js +62 -17
  180. package/src/core/constants.js +42 -11
  181. package/src/core/manifest.js +201 -3
  182. package/src/core/paths.js +2 -2
  183. package/src/i18n/messages.js +6 -29
  184. package/src/installers/hooks-installer.js +167 -75
  185. package/src/installers/integration-installer.js +9 -5
  186. package/src/prompts/init.js +14 -14
  187. package/src/utils/detector.js +21 -1
  188. package/src/utils/effect-boundary.js +1093 -0
  189. package/src/utils/hasher.js +166 -1
  190. package/src/utils/hook-stats.js +1 -1
  191. package/src/utils/integration-generator.js +79 -1
  192. package/src/utils/reference-sync.js +4 -1
  193. package/src/utils/yaml-generator.js +51 -9
  194. package/standards-registry.json +31 -8
@@ -0,0 +1,1093 @@
1
+ /**
2
+ * effect-boundary — XSPEC-383 R8 靜態效果邊界閘(引擎)
3
+ *
4
+ * ## 這道閘門存在的理由:形狀 D
5
+ *
6
+ * 「一個元件可達、被呼叫、跑得動、回報成功,而它什麼動作都沒做。」
7
+ *
8
+ * 它會通過 R3 的可達性閘門(`scripts/check-module-reachability.mjs`),因為
9
+ * 可達性問的是**入邊**——「誰呼叫它」。形狀 D 的病灶在**出邊**——「它最終碰到
10
+ * 什麼」。方向相反,所以那道閘門對它一個字都沒說。
11
+ *
12
+ * 根因是**宣稱與證據共用同一個作者**:一個效果實作的回傳值既是「我做了什麼」
13
+ * 的宣稱,也是下游唯一的證據,沒有第二條讀取路徑。型別系統驗的是回傳值的
14
+ * 形狀,而形狀對「真的做了」與「捏造的」**完全同構**——`{ ok: true, url }`
15
+ * 這個型別,寫了 200 行部署邏輯的實作與 `return { ok: true, url }` 一行的空殼
16
+ * 都完全滿足。
17
+ *
18
+ * 這支引擎走訪每個宣告的效果實作的**呼叫圖**,量它是否觸及跨程序邊界。
19
+ *
20
+ * ## 它抓不到什麼(誠實邊界,寫在最前面而不是附錄)
21
+ *
22
+ * 「摸到了外部世界,但做的是無關的事」——打一發裝飾性的 log 請求就能過關。
23
+ * 它只證明**有路徑**,不證明**路徑載著正確的貨**。這是 R9(世界回讀)存在的
24
+ * 理由,R9 不在本引擎範圍內。
25
+ *
26
+ * ## 邊界集合從 runtime 自讀,不手打白名單
27
+ *
28
+ * 邊界模組清單由 `builtinModules` 走訪產生(見 BOUNDARY_FAMILY_RULES):
29
+ * 規則是「家族根名」,實際模組名(`fs`、`fs/promises`、`node:fs`…)由走訪展開。
30
+ * 一條規則展開出 0 個模組,本身就是值得看的訊號——**規則過期了,或它從來
31
+ * 沒對過**——所以每條規則都印出自己命中幾個。
32
+ *
33
+ * 免 import 的全域邊界 API(`fetch`、`WebSocket`、`process.dlopen`…)同樣
34
+ * **先探測 runtime 是否真的有這個全域再納入**,不是假設它存在。Node 版本不同
35
+ * 時納入的集合會不同,而那個差異會印出來。
36
+ *
37
+ * ## 三種判定,以及為什麼不能只有一種
38
+ *
39
+ * 1. **絕對零判準**:可達圖裡零邊界節點 → RED。
40
+ * 2. **家族組內離群判準**:同一家族中位數 > 0 而某成員 = 0 → RED,且該判紅
41
+ * 帶「有兄弟當對照組」的佐證(corroborated)。
42
+ * 判準 2 的命中集合是判準 1 的子集,它的價值不在多抓,在**佐證強度**:
43
+ * 當整個家族全是 0,組內比較會**集體沉默**,此時判紅只由判準 1 支撐,
44
+ * 報告必須明說這件事(見 report 中的 "no in-family control group")。
45
+ * 3. **不可判定**:可達圖零邊界,但圖裡有**無法分類的外部套件 import**。
46
+ * 這既不是紅也不是綠——`import { deploy } from 'some-sdk'` 可能整包都在
47
+ * 打網路,也可能是純型別。**把它算成綠是 fail-open,算成紅是誣告**,
48
+ * 所以它是第三態,並讓整支結束於 2。採用者的出路是在 config 的
49
+ * `packages.boundary` / `packages.inert` 宣告那個套件的分類。
50
+ *
51
+ * ## R7-b:誠實的未實作必須有一條合法出路
52
+ *
53
+ * 「什麼也沒做卻回報成功」是違規;「什麼也沒做並誠實說出來」必須合法。
54
+ * 否則動機鏈是:閘門擋死新 adapter → 有人加白名單 → 白名單腐壞。
55
+ * 給誠實的未實作一條合法出路,白名單就沒有存在的理由。
56
+ *
57
+ * **靜態可偵測的契約(採用者要照做的就是這兩條)**:
58
+ * - 檔案中出現 `NOT_IMPLEMENTED` 這個字面值(字串或列舉成員皆可),
59
+ * 或註解中出現 `@uds-effect-not-implemented`;**且**
60
+ * - 檔案中**不得**出現成功形狀的回傳字面值(`ok: true`、`success: true`、
61
+ * `status: 'ok'|'success'`)。
62
+ * 兩者同時出現 → **不是**豁免,是本閘門要抓的正中紅心(宣告未實作卻同時
63
+ * 回報成功),判 RED 並在理由中指名。
64
+ *
65
+ * ## R7-c:產品不得印出我方不擁有的網域
66
+ *
67
+ * 從**字串拼接**抽出網域(模板字面值與 `+` 串接皆處理),對照「組織實際持有
68
+ * 清單」。清單來源**可插拔**且**不得手打進本檔**:config 的 `ownedDomains`
69
+ * 指向 file / env / command,`command` 正是接註冊商或雲帳戶 DNS zone API 的
70
+ * 那個位置。宣告了但讀不到或讀到空的 → **exit 2**,不得把讀不到當成
71
+ * 「清單為空所以什麼都合法」。
72
+ *
73
+ * ## 為什麼引擎在 src/ 而不是只在 scripts/
74
+ *
75
+ * 實測 `npm pack --dry-run`:`cli/scripts/` 出貨 0 個檔案,`cli/src/` 出貨 120 個
76
+ * (package.json 的 `files` 只列 bin/src/bundled/…)。一個只住在 scripts/ 的
77
+ * 引擎,採用者 `npx uds audit --effects` 時**根本不在他的硬碟上**。
78
+ * 所以:引擎在此(出貨、被 `uds audit --effects` import),
79
+ * scripts/check-effect-boundary.mjs 只做 argv/報表/自測/exit code(不出貨)。
80
+ *
81
+ * ## 輸出語言
82
+ *
83
+ * 本檔的報告文字是**英文**,與兩支同型的 dev-only 腳本(中文)不同:那兩支
84
+ * 不出貨、只有本 repo 的人會看;這一支會出現在任何採用者的 terminal 上。
85
+ *
86
+ * @module utils/effect-boundary
87
+ */
88
+
89
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
90
+ import { join, resolve, dirname, relative, extname, sep } from 'node:path';
91
+ import { builtinModules } from 'node:module';
92
+ import { spawnSync } from 'node:child_process';
93
+
94
+ /** 預設的 config 位置,沿用本 repo 既有的 `.uds/` 慣例(見 src/core/constants.js)。 */
95
+ export const DEFAULT_CONFIG_PATH = join('.uds', 'effect-boundary.json');
96
+
97
+ /** 採用者專案裡的基線位置。格式與到期日規則見 parseBaselineTsv。 */
98
+ export const DEFAULT_BASELINE_PATH = join('.uds', 'effect-boundary-baseline.tsv');
99
+
100
+ /** R7-b 契約的兩個字面值。改這裡等於改採用者的契約,不要當成內部常數。 */
101
+ export const NOT_IMPLEMENTED_MARKERS = [
102
+ /\bNOT_IMPLEMENTED\b/,
103
+ /@uds-effect-not-implemented\b/
104
+ ];
105
+
106
+ /** 成功形狀的回傳字面值——與上面的 marker 同時出現時,豁免不成立。 */
107
+ export const SUCCESS_SHAPED_LITERALS = [
108
+ /\bok\s*:\s*true\b/,
109
+ /\bsuccess\s*:\s*true\b/,
110
+ /\bstatus\s*:\s*['"`](ok|success|succeeded|done)['"`]/i
111
+ ];
112
+
113
+ /**
114
+ * 邊界家族規則。**每一條是規則(家族根名),不是模組清單**——
115
+ * 實際模組名由走訪 `builtinModules` 展開,因此 Node 新增
116
+ * `fs/promises`、`dns/promises` 這類子路徑時不需要改這裡。
117
+ */
118
+ export const BOUNDARY_FAMILY_RULES = [
119
+ {
120
+ id: 'process-spawn',
121
+ why: 'starts another OS process — the clearest possible cross-process effect',
122
+ roots: ['child_process', 'cluster']
123
+ },
124
+ {
125
+ id: 'filesystem',
126
+ why: 'reads or writes state that outlives this process',
127
+ roots: ['fs']
128
+ },
129
+ {
130
+ id: 'network-socket',
131
+ why: 'opens a socket to something outside this process',
132
+ roots: ['net', 'dgram', 'tls']
133
+ },
134
+ {
135
+ id: 'network-protocol',
136
+ why: 'HTTP family — the usual shape of "call the platform API"',
137
+ roots: ['http', 'https', 'http2']
138
+ },
139
+ {
140
+ id: 'name-resolution',
141
+ why: 'a DNS lookup is itself a round trip out of the process',
142
+ roots: ['dns']
143
+ },
144
+ {
145
+ id: 'thread-boundary',
146
+ why: 'worker_threads crosses a scheduling boundary and can hold its own handles',
147
+ roots: ['worker_threads']
148
+ }
149
+ ];
150
+
151
+ /**
152
+ * FFI / N-API 入口**不是一個 builtin 家族**,所以它不在上表裡。
153
+ *
154
+ * 首版把它寫成 `roots: ['module']`,那是錯的:`node:module` 提供的是
155
+ * `createRequire`,而本 repo 自己的 `src/commands/audit.js` 第一行就在 import 它。
156
+ * 那樣寫會讓「載入原生模組」與「讀 package.json 版本號」變成同一件事,
157
+ * 每一個用了 createRequire 的檔案都會被誤判成有邊界——**一個憑空多出來的綠燈**。
158
+ *
159
+ * 原生入口實際靠兩個訊號認:`.node` 指定字串(見 detectBoundaryHits),
160
+ * 以及 `process.dlopen`(見 GLOBAL_BOUNDARY_CANDIDATES,且先探測 runtime 才納入)。
161
+ */
162
+ export const NATIVE_ADDON_SIGNALS = ['a specifier ending in .node', 'process.dlopen'];
163
+
164
+ /**
165
+ * 免 import 的全域邊界 API 候選。**每個都先探測 runtime 再納入**——
166
+ * 這正是「邊界集合從 runtime 自讀」對全域 API 的那一半。
167
+ */
168
+ const GLOBAL_BOUNDARY_CANDIDATES = [
169
+ {
170
+ id: 'fetch',
171
+ why: 'HTTP round trip with no import at all',
172
+ probe: () => typeof globalThis.fetch === 'function',
173
+ // 排除 `function fetch(` 這種自己定義的同名函式——那不是全域 fetch,
174
+ // 而把它算成命中的方向是**假綠**,正是本閘門最不能犯的錯。
175
+ pattern: /(?<!\bfunction\s)(?<![.\w$])fetch\s*\(/
176
+ },
177
+ {
178
+ id: 'WebSocket',
179
+ why: 'long-lived socket with no import at all',
180
+ probe: () => typeof globalThis.WebSocket === 'function',
181
+ pattern: /\bnew\s+WebSocket\s*\(/
182
+ },
183
+ {
184
+ id: 'EventSource',
185
+ why: 'server-sent events; a long-lived outbound connection',
186
+ probe: () => typeof globalThis.EventSource === 'function',
187
+ pattern: /\bnew\s+EventSource\s*\(/
188
+ },
189
+ {
190
+ id: 'process.dlopen',
191
+ why: 'loads a native addon directly — the FFI entry point',
192
+ probe: () => typeof process.dlopen === 'function',
193
+ pattern: /\bprocess\s*\.\s*dlopen\s*\(/
194
+ }
195
+ ];
196
+
197
+ /** 走訪原始碼時認得的副檔名。 */
198
+ const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx'];
199
+
200
+ /** 走訪時永遠不進入的目錄。規則,不是專案清單。 */
201
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', 'out', '.turbo']);
202
+
203
+ /* ────────────────────────────── 邊界集合 ────────────────────────────── */
204
+
205
+ /**
206
+ * 從 runtime 推導邊界表面。
207
+ *
208
+ * @returns {{
209
+ * modules: Set<string>,
210
+ * moduleFamily: Map<string, string>,
211
+ * familyStats: Array<{id: string, why: string, roots: string[], matched: number}>,
212
+ * globals: Array<{id: string, why: string, pattern: RegExp}>,
213
+ * globalsRejected: string[],
214
+ * builtinTotal: number,
215
+ * inertBuiltins: Set<string>
216
+ * }}
217
+ */
218
+ export function deriveBoundarySurface() {
219
+ const modules = new Set();
220
+ const moduleFamily = new Map();
221
+ const inertBuiltins = new Set();
222
+ const familyStats = [];
223
+
224
+ const rootOf = (name) => name.replace(/^node:/, '').split('/')[0];
225
+
226
+ for (const rule of BOUNDARY_FAMILY_RULES) {
227
+ let matched = 0;
228
+ for (const name of builtinModules) {
229
+ const bare = name.replace(/^node:/, '');
230
+ if (!rule.roots.includes(rootOf(bare))) continue;
231
+ // 兩種書寫形式都要認得,且兩種都由走訪產生,不是手打的一對
232
+ for (const form of [bare, `node:${bare}`]) {
233
+ if (!modules.has(form)) {
234
+ modules.add(form);
235
+ moduleFamily.set(form, rule.id);
236
+ matched++;
237
+ }
238
+ }
239
+ }
240
+ familyStats.push({ id: rule.id, why: rule.why, roots: rule.roots, matched });
241
+ }
242
+
243
+ for (const name of builtinModules) {
244
+ const bare = name.replace(/^node:/, '');
245
+ for (const form of [bare, `node:${bare}`]) {
246
+ if (!modules.has(form)) inertBuiltins.add(form);
247
+ }
248
+ }
249
+
250
+ const globals = [];
251
+ const globalsRejected = [];
252
+ for (const cand of GLOBAL_BOUNDARY_CANDIDATES) {
253
+ let present;
254
+ try {
255
+ present = cand.probe() === true;
256
+ } catch {
257
+ present = false;
258
+ }
259
+ if (present) globals.push({ id: cand.id, why: cand.why, pattern: cand.pattern });
260
+ else globalsRejected.push(cand.id);
261
+ }
262
+
263
+ return {
264
+ modules,
265
+ moduleFamily,
266
+ familyStats,
267
+ globals,
268
+ globalsRejected,
269
+ builtinTotal: builtinModules.length,
270
+ inertBuiltins
271
+ };
272
+ }
273
+
274
+ /* ────────────────────────── 原始碼掃描的基礎件 ────────────────────────── */
275
+
276
+ /**
277
+ * 剝掉註解、保留字串與換行。
278
+ *
279
+ * **為什麼一定要剝**:JSDoc 裡的 `import fs from 'node:fs'` 範例會被算成一次
280
+ * 真的邊界命中——那是**憑空多出來的綠燈**,也就是讓這道閘門**少報**,
281
+ * 正是它存在要防的那個方向。R3 的 reachability 首版踩過同型的坑。
282
+ *
283
+ * **為什麼不能用正則**:`'https://example.com'` 裡的 `//` 會被行註解正則吃掉,
284
+ * 而 R7-c 的整個判定就住在那些字串裡。所以用狀態機,逐字掃。
285
+ *
286
+ * 已知限制:不辨識 regex 字面值,因此 `/foo\/\*bar/` 這種內含 `/*` 的
287
+ * regex 會被誤判為區塊註解起點。實務上罕見,且失效方向是「多剝一點」=多報。
288
+ */
289
+ export function stripComments(src) {
290
+ let out = '';
291
+ let i = 0;
292
+ let state = 'code';
293
+ while (i < src.length) {
294
+ const c = src[i];
295
+ const n = src[i + 1];
296
+ if (state === 'code') {
297
+ if (c === '/' && n === '/') { state = 'line'; i += 2; continue; }
298
+ if (c === '/' && n === '*') { state = 'block'; i += 2; continue; }
299
+ if (c === '\'') { state = 'sq'; out += c; i++; continue; }
300
+ if (c === '"') { state = 'dq'; out += c; i++; continue; }
301
+ if (c === '`') { state = 'tpl'; out += c; i++; continue; }
302
+ out += c; i++; continue;
303
+ }
304
+ if (state === 'line') {
305
+ if (c === '\n') { state = 'code'; out += c; }
306
+ i++; continue;
307
+ }
308
+ if (state === 'block') {
309
+ if (c === '*' && n === '/') { state = 'code'; i += 2; continue; }
310
+ if (c === '\n') out += c;
311
+ i++; continue;
312
+ }
313
+ if (c === '\\') { out += c + (n ?? ''); i += 2; continue; }
314
+ if ((state === 'sq' && c === '\'') || (state === 'dq' && c === '"') || (state === 'tpl' && c === '`')) {
315
+ state = 'code'; out += c; i++; continue;
316
+ }
317
+ out += c; i++; continue;
318
+ }
319
+ return out;
320
+ }
321
+
322
+ /**
323
+ * 從(已剝註解的)原始碼抽出 import/require 指定字串,**並分辨 type-only import**。
324
+ *
325
+ * 🔴 為什麼一定要分辨 type-only:這是本引擎第一次自測就抓到的真缺陷。
326
+ *
327
+ * 形狀 D 的空殼實作幾乎一定會 `import type { Result } from './real-one'`——
328
+ * 它要那個介面的型別。而 `./real-one` 是個真實作,裡面 import 了 `node:fs`。
329
+ * 首版把那條 type-only import 當成呼叫圖的一條邊,於是**空殼繼承了介面檔的
330
+ * 邊界命中,被判成綠**。實測:hollow.provider.ts → GREEN,金絲雀拿掉
331
+ * spawnSync 之後仍然 GREEN(1 hit,全部來自型別 import)。
332
+ *
333
+ * `import type` 在編譯後**整行消失**,它不可能在 runtime 造成任何效果。
334
+ * 把它算成邊,失效方向正是最不能犯的那個:**憑空多出來的綠燈**。
335
+ *
336
+ * 認定為 type-only 的兩種寫法:
337
+ * - `import type { X } from 's'` / `export type { X } from 's'`
338
+ * - `import { type X, type Y } from 's'`(所有具名綁定都帶 type 修飾)
339
+ * 混合寫法 `import { type X, doWork } from 's'` 帶有值綁定,**是**真的邊。
340
+ *
341
+ * 無法靜態解析的 dynamic import / require 要計數,不能當作不存在——
342
+ * 吞掉它會讓一個其實有邊界的元件被誤報成空殼。
343
+ */
344
+ export function extractSpecifiers(strippedSrc) {
345
+ const specs = [];
346
+ const seen = new Set();
347
+ const push = (spec, typeOnly, at) => {
348
+ const key = `${at}:${spec}`;
349
+ if (seen.has(key)) return;
350
+ seen.add(key);
351
+ specs.push({ spec, typeOnly });
352
+ };
353
+
354
+ // tempered pattern:clause 不得跨過下一個 import/export 或一個 `;`,
355
+ // 否則一個沒有 from 的 `import 'side-effect'` 會把後面那句的 from 吸過來。
356
+ const stmt = /\b(?:import|export)\b((?:(?!\b(?:import|export)\b|;)[\s\S])*?)\bfrom\s*['"]([^'"]+)['"]/g;
357
+ for (const m of strippedSrc.matchAll(stmt)) {
358
+ const clause = m[1].trim();
359
+ let typeOnly = /^type\b/.test(clause);
360
+ if (!typeOnly) {
361
+ const braced = clause.match(/^\{([\s\S]*)\}$/);
362
+ if (braced) {
363
+ const parts = braced[1].split(',').map((p) => p.trim()).filter(Boolean);
364
+ typeOnly = parts.length > 0 && parts.every((p) => /^type\s/.test(p));
365
+ }
366
+ }
367
+ push(m[2], typeOnly, m.index);
368
+ }
369
+ for (const m of strippedSrc.matchAll(/\bimport\s+['"]([^'"]+)['"]/g)) push(m[1], false, m.index);
370
+ for (const m of strippedSrc.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) push(m[1], false, m.index);
371
+ for (const m of strippedSrc.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) push(m[1], false, m.index);
372
+
373
+ const dynAll = [...strippedSrc.matchAll(/\b(?:import|require)\s*\(/g)].length;
374
+ const dynLiteral = [...strippedSrc.matchAll(/\b(?:import|require)\s*\(\s*['"]/g)].length;
375
+ return { specs, unresolvable: Math.max(0, dynAll - dynLiteral) };
376
+ }
377
+
378
+ /**
379
+ * 對單一檔案的原始碼做邊界偵測。**純函式**——這一點很重要:
380
+ * 它讓探針自證可以用兩段寫死在本檔裡的對照字串跑,不依賴磁碟上任何 fixture
381
+ * (fixture 會被搬走、被 .npmignore 濾掉,而字串常數不會)。
382
+ *
383
+ * @param {string} rawSrc 原始碼
384
+ * @param {ReturnType<deriveBoundarySurface>} surface
385
+ * @param {{boundary: Set<string>, inert: Set<string>}} pkgClass 採用者宣告的外部套件分類
386
+ */
387
+ export function detectBoundaryHits(rawSrc, surface, pkgClass = { boundary: new Set(), inert: new Set() }) {
388
+ const src = stripComments(rawSrc);
389
+ const hits = [];
390
+ const relativeSpecs = [];
391
+ const unknownPackages = new Set();
392
+
393
+ const { specs, unresolvable } = extractSpecifiers(src);
394
+ let typeOnlySkipped = 0;
395
+ for (const { spec, typeOnly } of specs) {
396
+ // type-only import 編譯後整行消失——它既不是呼叫圖的邊,也不是邊界命中
397
+ if (typeOnly) { typeOnlySkipped++; continue; }
398
+ if (spec.startsWith('.') || spec.startsWith('/')) {
399
+ relativeSpecs.push(spec);
400
+ if (spec.endsWith('.node')) hits.push({ kind: 'native-addon', detail: spec });
401
+ continue;
402
+ }
403
+ if (spec.endsWith('.node')) { hits.push({ kind: 'native-addon', detail: spec }); continue; }
404
+
405
+ const bare = spec.replace(/^node:/, '');
406
+ const normalised = spec.startsWith('node:') ? spec : bare;
407
+ if (surface.modules.has(normalised) || surface.modules.has(bare)) {
408
+ hits.push({ kind: surface.moduleFamily.get(bare) ?? surface.moduleFamily.get(normalised), detail: spec });
409
+ continue;
410
+ }
411
+ if (surface.inertBuiltins.has(bare)) continue; // 內建但不跨邊界(path、url、util…)
412
+
413
+ // 外部套件:本引擎靜態上分不出它跨不跨邊界
414
+ const pkgRoot = bare.startsWith('@') ? bare.split('/').slice(0, 2).join('/') : bare.split('/')[0];
415
+ if (pkgClass.boundary.has(pkgRoot)) { hits.push({ kind: 'declared-boundary-package', detail: pkgRoot }); continue; }
416
+ if (pkgClass.inert.has(pkgRoot)) continue;
417
+ unknownPackages.add(pkgRoot);
418
+ }
419
+
420
+ for (const g of surface.globals) {
421
+ if (g.pattern.test(src)) hits.push({ kind: `global:${g.id}`, detail: g.id });
422
+ }
423
+
424
+ return { hits, relativeSpecs, unknownPackages: [...unknownPackages], unresolvable, typeOnlySkipped };
425
+ }
426
+
427
+ /* ───────────────────────────── R7-c 網域抽取 ───────────────────────────── */
428
+
429
+ /**
430
+ * 從原始碼抽出**字串拼接產生的**網域。
431
+ *
432
+ * 只在 URL 上下文裡抽(`https?://` 之後),不從任意看起來像網域的字面值抽——
433
+ * 否則 `package.json`、`1.2.3`、`foo.spec.ts` 全都會變成「網域」,而一個滿是
434
+ * 假陽性的清單會在兩週內被整條關掉。
435
+ *
436
+ * 動態片段(`${x}`、`' + x + '`)一律折成 `*`,因此
437
+ * `` `https://${env}.api.example.net` `` → `*.api.example.net`:
438
+ * **可註冊的那一段是字面值,所以這個判定是可決的**。反過來
439
+ * `` `https://api.${tld}` `` → `api.*`,最後兩段含動態片段 → 不可決,計入
440
+ * undecidable 而不是默默放行。
441
+ */
442
+ export function extractDomains(rawSrc) {
443
+ const src = stripComments(rawSrc);
444
+ const found = [];
445
+ const re = /https?:\/\//gi;
446
+ let m;
447
+ while ((m = re.exec(src)) !== null) {
448
+ let i = m.index + m[0].length;
449
+ let host = '';
450
+ let sawDynamic = false;
451
+ // 往前吃,直到 host 結束(/ ? # 空白 引號 反引號 或字串結束)
452
+ while (i < src.length) {
453
+ const c = src[i];
454
+ if (c === '$' && src[i + 1] === '{') {
455
+ let depth = 1; i += 2;
456
+ while (i < src.length && depth > 0) {
457
+ if (src[i] === '{') depth++;
458
+ else if (src[i] === '}') depth--;
459
+ i++;
460
+ }
461
+ host += '*'; sawDynamic = true; continue;
462
+ }
463
+ // `'https://' + host + '.example.net'` 形式:跳過引號與 + 與識別字
464
+ if (c === '\'' || c === '"') {
465
+ const rest = src.slice(i);
466
+ const cont = rest.match(/^['"]\s*\+\s*[A-Za-z_$][\w$.[\]()]*\s*\+\s*['"]/);
467
+ if (cont) { host += '*'; sawDynamic = true; i += cont[0].length; continue; }
468
+ break;
469
+ }
470
+ if (/[A-Za-z0-9._-]/.test(c)) { host += c; i++; continue; }
471
+ break;
472
+ }
473
+ host = host.replace(/^\.+|\.+$/g, '').toLowerCase();
474
+ if (!host) continue;
475
+ const labels = host.split('.').filter(Boolean);
476
+ if (labels.length < 2) {
477
+ found.push({ host, decidable: false, why: 'fewer than two labels after folding dynamic segments' });
478
+ continue;
479
+ }
480
+ const registrable = labels.slice(-2).join('.');
481
+ const decidable = !registrable.includes('*');
482
+ found.push({
483
+ host,
484
+ registrable,
485
+ decidable,
486
+ dynamic: sawDynamic,
487
+ why: decidable ? null : 'the registrable part itself is built at runtime'
488
+ });
489
+ }
490
+ return found;
491
+ }
492
+
493
+ /**
494
+ * 解析「組織實際持有的網域」清單。**來源可插拔,本檔不得內建任何網域。**
495
+ *
496
+ * @returns {{ok: true, domains: string[], source: string} | {ok: false, reason: string}}
497
+ */
498
+ export function resolveOwnedDomains(ownedConfig, projectPath) {
499
+ if (!ownedConfig) return { ok: false, reason: 'not-declared' };
500
+ const parse = (text, source) => {
501
+ // 逐行處理再切 token。**不能整份 split 空白**——那樣一行 `# see foo.example`
502
+ // 的註解會貢獻一個叫 `foo.example` 的「持有網域」,
503
+ // 而一份被自己的說明文字污染的白名單,比沒有白名單更難發現。
504
+ const domains = text
505
+ .split(/\r?\n/)
506
+ .map((line) => line.split('#')[0])
507
+ .flatMap((line) => line.split(/[\s,;]+/))
508
+ .map((d) => d.trim().toLowerCase().replace(/^\*\./, '').replace(/\.$/, ''))
509
+ .filter((d) => d && d.includes('.'));
510
+ if (domains.length === 0) {
511
+ return { ok: false, reason: `source ${source} produced zero domains — an unreadable list is not an empty list` };
512
+ }
513
+ return { ok: true, domains: [...new Set(domains)], source };
514
+ };
515
+
516
+ if (ownedConfig.source === 'file') {
517
+ const p = resolve(projectPath, ownedConfig.path ?? '');
518
+ if (!ownedConfig.path) return { ok: false, reason: 'ownedDomains.source=file but no path given' };
519
+ if (!existsSync(p)) return { ok: false, reason: `ownedDomains file not found: ${p}` };
520
+ try {
521
+ return parse(readFileSync(p, 'utf8'), `file:${relative(projectPath, p)}`);
522
+ } catch (e) {
523
+ return { ok: false, reason: `ownedDomains file unreadable: ${e.message}` };
524
+ }
525
+ }
526
+
527
+ if (ownedConfig.source === 'env') {
528
+ const v = process.env[ownedConfig.var ?? ''];
529
+ if (!ownedConfig.var) return { ok: false, reason: 'ownedDomains.source=env but no var given' };
530
+ if (v === undefined) return { ok: false, reason: `env var ${ownedConfig.var} is not set` };
531
+ return parse(v, `env:${ownedConfig.var}`);
532
+ }
533
+
534
+ if (ownedConfig.source === 'command') {
535
+ // 這是接註冊商 / 雲帳戶 DNS zone API 的位置。指令從專案自己 commit 進來的
536
+ // config 讀,信任層級等同 npm scripts。
537
+ if (!ownedConfig.command) return { ok: false, reason: 'ownedDomains.source=command but no command given' };
538
+ const r = spawnSync(ownedConfig.command, { shell: true, encoding: 'utf8', cwd: projectPath, timeout: 60_000 });
539
+ if (r.error) return { ok: false, reason: `ownedDomains command failed to start: ${r.error.message}` };
540
+ if (r.status !== 0) {
541
+ return { ok: false, reason: `ownedDomains command exited ${r.status}: ${(r.stderr || '').trim().slice(0, 300)}` };
542
+ }
543
+ return parse(r.stdout ?? '', `command:${ownedConfig.command}`);
544
+ }
545
+
546
+ return { ok: false, reason: `unknown ownedDomains.source: ${JSON.stringify(ownedConfig.source)}` };
547
+ }
548
+
549
+ /* ─────────────────────────── 探針自證(R10 入場費) ─────────────────────────── */
550
+
551
+ /**
552
+ * 已知為真的對照樣本。**寫成字串常數而不是磁碟上的 fixture**,因為
553
+ * fixture 會被 `files` 濾掉、被搬走、被改壞,而那三種情況下這個對照組
554
+ * 會安靜地消失——正是它要防的那件事。
555
+ */
556
+ const KNOWN_POSITIVE_SOURCE = `
557
+ import { writeFileSync } from 'node:fs';
558
+ import { spawnSync } from 'child_process';
559
+ export function reallyDoesSomething(p, d) {
560
+ writeFileSync(p, d);
561
+ return spawnSync('true');
562
+ }
563
+ `;
564
+
565
+ /** 已知為淨的對照樣本:純計算,一個邊界都不碰。 */
566
+ const KNOWN_NEGATIVE_SOURCE = `
567
+ import { join } from 'node:path';
568
+ export function pureComputation(a, b) {
569
+ return join(String(a), String(b)).length + 1;
570
+ }
571
+ `;
572
+
573
+ /**
574
+ * 已知**只有型別 import** 的對照樣本。
575
+ * 它釘住的是本引擎第一次自測就抓到的真缺陷(見 extractSpecifiers 的說明):
576
+ * 把 `import type` 當成呼叫圖的邊,會讓空殼實作繼承介面檔的邊界命中而判綠。
577
+ * 沒有這一臂,那個 bug 修好之後也沒有東西阻止它回來。
578
+ */
579
+ const KNOWN_TYPE_ONLY_SOURCE = `
580
+ import type { Stats } from 'node:fs';
581
+ import { type ChildProcess } from 'node:child_process';
582
+ export function describe(s: Stats, c: ChildProcess) { return String(s) + String(c); }
583
+ `;
584
+
585
+ /** 已知含拼接網域的對照樣本,給 R7-c 抽取器用。 */
586
+ const KNOWN_DOMAIN_SOURCE = [
587
+ 'const base = `https://${region}.api.probe-fixture.invalid/v1`;',
588
+ 'const alt = \'https://\' + tenant + \'.probe-fixture.invalid\';'
589
+ ].join('\n');
590
+
591
+ /**
592
+ * 探針自我驗證。**在任何掃描開始之前跑,主路徑與自測路徑共用同一支。**
593
+ *
594
+ * 🔴 為什麼這段必須在主路徑,不能只在 `--self-test` 裡:
595
+ * 這道閘門判紅的方式是「命中數 = 0」。一個**壞掉的偵測器**對每一個檔案都
596
+ * 回 0,於是它會把整個家族判成紅——看起來像「抓到一堆問題」,實際上是
597
+ * 探針死了。反向亦然:一個把什麼都當成命中的偵測器,會把整個家族判成綠,
598
+ * 而**那個綠燈與真的乾淨逐字相同**。
599
+ *
600
+ * ⚠️ **兩臂不等價,缺一不可**(R4 那支已經用實測付過學費):
601
+ * - 正向臂(已知真實作 → 命中 > 0)抓「偵測器整個死了」。
602
+ * - 負向臂(已知純計算 → 命中 = 0)抓「偵測器對什麼都說命中」。
603
+ * 只留正向臂時,一個無條件回報命中的偵測器照樣全綠通過。
604
+ *
605
+ * 第三臂是 R7-c 的抽取器自證:抽取器找到 0 個網域,與「這份程式碼真的沒有
606
+ * 網域」在輸出上無從分辨——所以先用已知含網域的對照樣本證明它會抽到東西。
607
+ */
608
+ export function verifyProbeIsWorking(surface, pkgClass) {
609
+ const lines = [];
610
+ let ok = true;
611
+
612
+ const pos = detectBoundaryHits(KNOWN_POSITIVE_SOURCE, surface, pkgClass);
613
+ const posOk = pos.hits.length > 0;
614
+ lines.push(
615
+ ` ${posOk ? '✓' : '✗'} control (positive): a known-real implementation reports ${pos.hits.length} boundary hit(s)` +
616
+ (posOk ? '' : ' — the detector is dead; every member would be judged RED')
617
+ );
618
+ ok &&= posOk;
619
+
620
+ const neg = detectBoundaryHits(KNOWN_NEGATIVE_SOURCE, surface, pkgClass);
621
+ const negOk = neg.hits.length === 0;
622
+ lines.push(
623
+ ` ${negOk ? '✓' : '✗'} control (negative): a known-pure computation reports ${neg.hits.length} boundary hit(s)` +
624
+ (negOk ? '' : ' — the detector says "hit" for anything; every member would be judged GREEN')
625
+ );
626
+ ok &&= negOk;
627
+
628
+ const typeOnly = detectBoundaryHits(KNOWN_TYPE_ONLY_SOURCE, surface, pkgClass);
629
+ const typeOnlyOk = typeOnly.hits.length === 0 && typeOnly.typeOnlySkipped === 2;
630
+ lines.push(
631
+ ` ${typeOnlyOk ? '✓' : '✗'} control (type-only imports): ${typeOnly.typeOnlySkipped}/2 skipped, ` +
632
+ `${typeOnly.hits.length} boundary hit(s) counted` +
633
+ (typeOnlyOk ? '' : ' — a type import vanishes at compile time; counting it is a green light out of thin air')
634
+ );
635
+ ok &&= typeOnlyOk;
636
+
637
+ const dom = extractDomains(KNOWN_DOMAIN_SOURCE);
638
+ const domOk = dom.length === 2 && dom.every((d) => d.registrable === 'probe-fixture.invalid');
639
+ lines.push(
640
+ ` ${domOk ? '✓' : '✗'} control (domain extractor): known concatenated domains extracted ` +
641
+ `${dom.length}/2 → ${dom.map((d) => d.host).join(', ') || '(none)'}`
642
+ );
643
+ ok &&= domOk;
644
+
645
+ const surfaceOk = surface.modules.size > 0 && surface.familyStats.every((f) => f.matched > 0);
646
+ lines.push(
647
+ ` ${surfaceOk ? '✓' : '✗'} control (boundary surface): ${surface.modules.size} module forms derived from ` +
648
+ `${surface.builtinTotal} builtins; every family rule matched at least one` +
649
+ (surfaceOk ? '' : ` — empty families: ${surface.familyStats.filter((f) => f.matched === 0).map((f) => f.id).join(', ')}`)
650
+ );
651
+ ok &&= surfaceOk;
652
+
653
+ return { ok, lines };
654
+ }
655
+
656
+ /* ────────────────────────────── 走訪與判定 ────────────────────────────── */
657
+
658
+ /** 極小的 glob → RegExp。支援 `**`、`*`、`?`;路徑一律以 `/` 正規化。 */
659
+ export function globToRegExp(glob) {
660
+ let re = '';
661
+ for (let i = 0; i < glob.length; i++) {
662
+ const c = glob[i];
663
+ if (c === '*') {
664
+ if (glob[i + 1] === '*') {
665
+ // `**/` 允許零層目錄,否則 `**/x` 匹配不到根目錄下的 x
666
+ if (glob[i + 2] === '/') { re += '(?:[^/]*\\/)*'; i += 2; }
667
+ else { re += '.*'; i += 1; }
668
+ } else {
669
+ re += '[^/]*';
670
+ }
671
+ continue;
672
+ }
673
+ if (c === '?') { re += '[^/]'; continue; }
674
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
675
+ }
676
+ return new RegExp(`^${re}$`);
677
+ }
678
+
679
+ /** 走訪 dir 下所有原始碼檔,回傳相對 root 的 posix 路徑。 */
680
+ export function walkSources(root, dir = root, out = []) {
681
+ if (!existsSync(dir)) return out;
682
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
683
+ if (entry.isDirectory()) {
684
+ if (SKIP_DIRS.has(entry.name)) continue;
685
+ walkSources(root, join(dir, entry.name), out);
686
+ } else if (SOURCE_EXTENSIONS.includes(extname(entry.name))) {
687
+ out.push(relative(root, join(dir, entry.name)).split(sep).join('/'));
688
+ }
689
+ }
690
+ return out;
691
+ }
692
+
693
+ /** 把相對 import 解析成相對 root 的檔案路徑;解析不到回 null。 */
694
+ function resolveRelative(root, fromRel, spec) {
695
+ const base = resolve(dirname(join(root, fromRel)), spec);
696
+ const candidates = [];
697
+ if (extname(base)) {
698
+ candidates.push(base);
699
+ // TS 寫 `./x.js` 指的常常是 `./x.ts`
700
+ candidates.push(base.replace(/\.js$/, '.ts'), base.replace(/\.mjs$/, '.mts'));
701
+ }
702
+ for (const ext of SOURCE_EXTENSIONS) candidates.push(base + ext, join(base, `index${ext}`));
703
+ for (const c of candidates) {
704
+ if (existsSync(c) && statSync(c).isFile()) return relative(root, c).split(sep).join('/');
705
+ }
706
+ return null;
707
+ }
708
+
709
+ /** R7-b:這個檔案有沒有誠實地說「我沒做」——以及有沒有同時回報成功。 */
710
+ export function classifyHonesty(rawSrc) {
711
+ const src = rawSrc; // marker 允許出現在註解裡,所以這裡**不**剝註解
712
+ const stripped = stripComments(rawSrc);
713
+ const declaresNotImplemented = NOT_IMPLEMENTED_MARKERS.some((r) => r.test(src));
714
+ const claimsSuccess = SUCCESS_SHAPED_LITERALS.some((r) => r.test(stripped));
715
+ return { declaresNotImplemented, claimsSuccess };
716
+ }
717
+
718
+ function median(nums) {
719
+ if (nums.length === 0) return 0;
720
+ const s = [...nums].sort((a, b) => a - b);
721
+ const mid = Math.floor(s.length / 2);
722
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
723
+ }
724
+
725
+ /**
726
+ * 主分析。
727
+ *
728
+ * @param {{projectPath: string, config: object, surface?: object}} opts
729
+ * `surface` 是**測試接縫**:預設從 runtime 推導。傳入一個壞掉的 surface
730
+ * 是唯一能端對端演示「探針失效 → exit 2」的方法,而一道從未演示過自己會
731
+ * exit 2 的閘門,與一道 exit 2 路徑早就壞掉的閘門,輸出上無從分辨。
732
+ * 它是參數不是環境變數,所以不會有人在生產路徑上意外踩到。
733
+ * @returns {object} 完整結果;exit code 由 formatReport 決定
734
+ */
735
+ export function analyseEffectBoundary({ projectPath, config, surface: injectedSurface }) {
736
+ const surface = injectedSurface ?? deriveBoundarySurface();
737
+ const pkgClass = {
738
+ boundary: new Set(config.packages?.boundary ?? []),
739
+ inert: new Set(config.packages?.inert ?? [])
740
+ };
741
+
742
+ // 🔴 先證明探針在工作,再相信它量出來的任何東西。
743
+ const probe = verifyProbeIsWorking(surface, pkgClass);
744
+ if (!probe.ok) {
745
+ return { fatal: 'probe-broken', probe, surface };
746
+ }
747
+
748
+ const root = resolve(projectPath, config.root ?? '.');
749
+ if (!existsSync(root)) return { fatal: `root not found: ${root}`, probe, surface };
750
+
751
+ const families = config.families ?? [];
752
+ if (families.length === 0) {
753
+ return { fatal: 'config declares zero effect families — nothing to measure is not the same as nothing wrong', probe, surface };
754
+ }
755
+
756
+ const walked = walkSources(root);
757
+ if (walked.length === 0) {
758
+ return { fatal: `walked ${root} and found zero source files — this is "cannot measure", not "clean"`, probe, surface };
759
+ }
760
+
761
+ const ownedResult = resolveOwnedDomains(config.ownedDomains, projectPath);
762
+
763
+ const familyResults = [];
764
+ let totalMembers = 0;
765
+ let totalExcluded = 0;
766
+
767
+ for (const fam of families) {
768
+ const includeRes = (fam.include ?? []).map(globToRegExp);
769
+ const excludeRes = (fam.exclude ?? []).map(globToRegExp);
770
+ const included = walked.filter((p) => includeRes.some((r) => r.test(p)));
771
+ const members = [];
772
+ let excluded = 0;
773
+ for (const p of included) {
774
+ if (excludeRes.some((r) => r.test(p))) { excluded++; continue; }
775
+ members.push(p);
776
+ }
777
+ totalExcluded += excluded;
778
+
779
+ const memberResults = [];
780
+ for (const rel of members) {
781
+ // 沿呼叫圖 BFS:一個把邊界委派給 helper 的成員必須算綠
782
+ const visited = new Set();
783
+ const queue = [rel];
784
+ const hits = [];
785
+ const unknownPackages = new Set();
786
+ let unresolvable = 0;
787
+ let graphSize = 0;
788
+ while (queue.length) {
789
+ const cur = queue.shift();
790
+ if (visited.has(cur)) continue;
791
+ visited.add(cur);
792
+ const full = join(root, cur);
793
+ if (!existsSync(full)) continue;
794
+ graphSize++;
795
+ const srcText = readFileSync(full, 'utf8');
796
+ const d = detectBoundaryHits(srcText, surface, pkgClass);
797
+ for (const h of d.hits) hits.push({ ...h, file: cur });
798
+ for (const u of d.unknownPackages) unknownPackages.add(u);
799
+ unresolvable += d.unresolvable;
800
+ for (const spec of d.relativeSpecs) {
801
+ const t = resolveRelative(root, cur, spec);
802
+ if (t && !visited.has(t)) queue.push(t);
803
+ }
804
+ }
805
+
806
+ const honesty = classifyHonesty(readFileSync(join(root, rel), 'utf8'));
807
+ const domains = [];
808
+ for (const f of visited) {
809
+ const full = join(root, f);
810
+ if (!existsSync(full)) continue;
811
+ for (const d of extractDomains(readFileSync(full, 'utf8'))) domains.push({ ...d, file: f });
812
+ }
813
+
814
+ memberResults.push({
815
+ file: rel,
816
+ graphSize,
817
+ hits,
818
+ hitCount: hits.length,
819
+ unknownPackages: [...unknownPackages],
820
+ unresolvable,
821
+ honesty,
822
+ domains
823
+ });
824
+ totalMembers++;
825
+ }
826
+
827
+ const nonExempt = memberResults.filter((m) => !(m.honesty.declaresNotImplemented && !m.honesty.claimsSuccess));
828
+ const med = median(nonExempt.map((m) => m.hitCount));
829
+
830
+ for (const m of memberResults) {
831
+ const exempt = m.honesty.declaresNotImplemented && !m.honesty.claimsSuccess;
832
+ if (exempt) {
833
+ m.verdict = 'EXEMPT-HONEST';
834
+ m.reason = 'declares NOT_IMPLEMENTED and never claims success — R7-b says this must be legal';
835
+ continue;
836
+ }
837
+ if (m.honesty.declaresNotImplemented && m.honesty.claimsSuccess) {
838
+ m.verdict = 'RED';
839
+ m.reason = 'declares NOT_IMPLEMENTED *and* returns a success-shaped literal — the exemption does not apply';
840
+ m.corroborated = med > 0;
841
+ continue;
842
+ }
843
+ if (m.hitCount > 0) { m.verdict = 'GREEN'; m.reason = `${m.hitCount} boundary hit(s) across ${m.graphSize} file(s)`; continue; }
844
+ if (m.unknownPackages.length > 0) {
845
+ m.verdict = 'UNDECIDABLE';
846
+ m.reason = `zero boundary hits, but ${m.unknownPackages.length} unclassifiable external package import(s): ` +
847
+ `${m.unknownPackages.join(', ')} — declare them under packages.boundary / packages.inert`;
848
+ continue;
849
+ }
850
+ if (m.unresolvable > 0) {
851
+ m.verdict = 'UNDECIDABLE';
852
+ m.reason = `zero boundary hits, but ${m.unresolvable} dynamic import/require call(s) whose argument is not a literal`;
853
+ continue;
854
+ }
855
+ m.verdict = 'RED';
856
+ m.corroborated = med > 0;
857
+ m.reason = `zero boundary hits across its whole reachable graph (${m.graphSize} file(s))` +
858
+ (med > 0
859
+ ? ` — and its family median is ${med}, so siblings in the same family do reach the outside world`
860
+ : ' — the whole family is zero, so there is no in-family control group; this RED rests on the absolute-zero test alone');
861
+ }
862
+
863
+ familyResults.push({
864
+ name: fam.name ?? '(unnamed)',
865
+ include: fam.include ?? [],
866
+ exclude: fam.exclude ?? [],
867
+ matched: included.length,
868
+ excluded,
869
+ members: memberResults,
870
+ median: med,
871
+ hasControlGroup: med > 0
872
+ });
873
+ }
874
+
875
+ if (totalMembers === 0) {
876
+ return {
877
+ fatal: `every declared family resolved to zero members (walked ${walked.length} source files under ${root}) — ` +
878
+ 'an empty collection prints the same green as zero failures',
879
+ probe,
880
+ surface,
881
+ walked: walked.length
882
+ };
883
+ }
884
+
885
+ // R7-c 判定
886
+ const allDomains = familyResults.flatMap((f) => f.members.flatMap((m) => m.domains.map((d) => ({ ...d, member: m.file }))));
887
+ const domainAudit = { requested: Boolean(config.ownedDomains), owned: ownedResult, extracted: allDomains.length, violations: [], undecidable: [] };
888
+ if (domainAudit.requested) {
889
+ if (!ownedResult.ok) {
890
+ domainAudit.fatal = `ownedDomains was declared but could not be resolved: ${ownedResult.reason}`;
891
+ } else {
892
+ const owned = ownedResult.domains;
893
+ for (const d of allDomains) {
894
+ if (!d.decidable) { domainAudit.undecidable.push(d); continue; }
895
+ const isOurs = owned.some((o) => d.registrable === o || d.host === o || d.host.endsWith(`.${o}`));
896
+ if (!isOurs) domainAudit.violations.push(d);
897
+ }
898
+ }
899
+ }
900
+
901
+ return {
902
+ probe,
903
+ surface,
904
+ root,
905
+ walked: walked.length,
906
+ totalMembers,
907
+ totalExcluded,
908
+ families: familyResults,
909
+ domainAudit
910
+ };
911
+ }
912
+
913
+ /* ──────────────────────────────── 基線(TSV) ──────────────────────────────── */
914
+
915
+ /**
916
+ * 基線用 **TSV + 每筆到期日**,不是 JSON——這是 XSPEC-383 §7.11.2 明文指定給
917
+ * 本閘門的格式(同 dev-platform `cross-project/unattended-risks.tsv` 的形狀),
918
+ * **不是**要回頭改 reachability / command-existence 那兩支的 JSON 基線。
919
+ *
920
+ * 為什麼是 TSV:每一列是一個人做過的判斷,而它要能被 `sort`/`awk`/人眼在
921
+ * 一行內讀完並比對到期日。JSON 基線要展開三層才看得到那個日期。
922
+ *
923
+ * **每筆帶到期日。** 一份沒有時鐘的例外清單只是有禮貌的刪除鍵。
924
+ *
925
+ * 欄位:family <TAB> member <TAB> verdict <TAB> expires(YYYY-MM-DD) <TAB> reason
926
+ * 欄數不對 → 解析失敗 → exit 2。**壞掉的基線不是「沒有基線」。**
927
+ */
928
+ export function parseBaselineTsv(text) {
929
+ const entries = [];
930
+ const errors = [];
931
+ const lines = text.split(/\r?\n/);
932
+ for (let i = 0; i < lines.length; i++) {
933
+ const line = lines[i];
934
+ if (!line.trim() || line.trimStart().startsWith('#')) continue;
935
+ const cols = line.split('\t');
936
+ if (cols.length !== 5) {
937
+ errors.push(`line ${i + 1}: expected 5 tab-separated columns, got ${cols.length}`);
938
+ continue;
939
+ }
940
+ const [family, member, verdict, expires, reason] = cols.map((c) => c.trim());
941
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
942
+ errors.push(`line ${i + 1}: expiry '${expires}' is not YYYY-MM-DD — an entry without a clock never expires`);
943
+ continue;
944
+ }
945
+ entries.push({ family, member, verdict, expires, reason });
946
+ }
947
+ return { entries, errors };
948
+ }
949
+
950
+ export function baselineKey(familyName, memberFile) {
951
+ return `${familyName}\t${memberFile}`;
952
+ }
953
+
954
+ /* ──────────────────────────────── 報表 ──────────────────────────────── */
955
+
956
+ /**
957
+ * 產生報表行與 exit code。
958
+ *
959
+ * 三態:
960
+ * 0 量得到,而且乾淨
961
+ * 1 量得到,有基線外的違規(或基線已過期)
962
+ * 2 **量不出來**——探針壞掉/沒有 config/家族解析出 0 個成員/
963
+ * 有 UNDECIDABLE 成員/宣告了 ownedDomains 卻讀不到。**這不是綠燈。**
964
+ *
965
+ * 2 優先於 1:有東西量不出來時,「其餘皆綠」這句話本身就不成立。
966
+ * 但報表仍然把已確認的紅列出來——exit code 只有一個數字,而資訊不必因此丟掉。
967
+ */
968
+ export function formatReport(result, baseline, today = new Date().toISOString().slice(0, 10)) {
969
+ const L = [];
970
+ const P = (s) => L.push(s);
971
+
972
+ if (result.fatal) {
973
+ P(`[effect-boundary] FATAL: ${result.fatal}`);
974
+ if (result.probe && !result.probe.ok) for (const l of result.probe.lines) P(l);
975
+ return { lines: L, exitCode: 2 };
976
+ }
977
+
978
+ const s = result.surface;
979
+ P('[effect-boundary] Boundary surface derived from this runtime (not a hand-written allowlist):');
980
+ for (const f of s.familyStats) {
981
+ P(` ${f.matched.toString().padStart(3)} module form(s) [${f.id}] roots=${f.roots.join(',')} — ${f.why}`);
982
+ }
983
+ P(` ${s.globals.length} import-free global API(s) present in this runtime: ${s.globals.map((g) => g.id).join(', ') || '(none)'}`);
984
+ if (s.globalsRejected.length) {
985
+ P(` ${s.globalsRejected.length} global candidate(s) absent here, so not counted: ${s.globalsRejected.join(', ')}`);
986
+ }
987
+ P(` native addon entry is not a builtin family; detected via: ${NATIVE_ADDON_SIGNALS.join(' / ')}`);
988
+ P(` total: ${s.modules.size} boundary module forms out of ${s.builtinTotal} builtins`);
989
+ for (const l of result.probe.lines) P(l);
990
+
991
+ P(`[effect-boundary] Walked ${result.walked} source file(s) under ${result.root}`);
992
+ P(`[effect-boundary] ${result.families.length} declared family/families → ${result.totalMembers} member(s), ${result.totalExcluded} excluded by the families' own exclude globs`);
993
+
994
+ const baseSet = new Map((baseline?.entries ?? []).map((e) => [baselineKey(e.family, e.member), e]));
995
+ const newFindings = [];
996
+ const baselined = [];
997
+ const expired = [];
998
+ const undecidable = [];
999
+
1000
+ for (const fam of result.families) {
1001
+ P(` family '${fam.name}': include=${JSON.stringify(fam.include)} matched ${fam.matched}, excluded ${fam.excluded}, members ${fam.members.length}, hit-count median ${fam.median}` +
1002
+ (fam.hasControlGroup ? '' : ' ⚠ no in-family control group (every member is zero)'));
1003
+ for (const m of fam.members) {
1004
+ if (m.verdict === 'UNDECIDABLE') { undecidable.push({ fam: fam.name, m }); continue; }
1005
+ if (m.verdict !== 'RED') continue;
1006
+ const b = baseSet.get(baselineKey(fam.name, m.file));
1007
+ if (!b) { newFindings.push({ fam: fam.name, m }); continue; }
1008
+ if (b.expires < today) expired.push({ fam: fam.name, m, b });
1009
+ else baselined.push({ fam: fam.name, m, b });
1010
+ }
1011
+ }
1012
+
1013
+ if (baseline?.errors?.length) {
1014
+ P('[effect-boundary] FATAL: the baseline file exists but does not parse:');
1015
+ for (const e of baseline.errors) P(` ${e}`);
1016
+ P(' A broken baseline is not "no baseline" — refusing to report a result.');
1017
+ return { lines: L, exitCode: 2 };
1018
+ }
1019
+
1020
+ const da = result.domainAudit;
1021
+ if (da.fatal) {
1022
+ P(`[effect-boundary] FATAL (R7-c): ${da.fatal}`);
1023
+ P(' Failing to read the owned-domain list is not the same as "the list is empty, so everything is allowed".');
1024
+ return { lines: L, exitCode: 2 };
1025
+ }
1026
+ if (!da.requested) {
1027
+ P('[effect-boundary] R7-c domain audit: NOT RUN — config declares no `ownedDomains` source.');
1028
+ P(' This is an opt-in gap, stated loudly on purpose: no domain in this codebase was checked against anything.');
1029
+ } else {
1030
+ P(`[effect-boundary] R7-c domain audit: ${da.extracted} domain(s) extracted from string concatenation, checked against ${da.owned.domains.length} owned domain(s) from ${da.owned.source}`);
1031
+ if (da.extracted === 0) {
1032
+ P(' ⚠ zero domains extracted. The extractor\'s known-positive control passed above, so this reads as "this corpus has none" rather than "the extractor is dead" — but those two are only distinguishable *because* of that control.');
1033
+ }
1034
+ for (const d of da.undecidable) {
1035
+ P(` ? ${d.member}: ${d.host} — ${d.why}`);
1036
+ }
1037
+ }
1038
+
1039
+ let exitCode = 0;
1040
+
1041
+ if (undecidable.length) {
1042
+ P(`\n[effect-boundary] ${undecidable.length} member(s) could not be classified:`);
1043
+ for (const { fam, m } of undecidable) P(` ? [${fam}] ${m.file} — ${m.reason}`);
1044
+ P(' Zero boundary hits plus an unclassifiable dependency is neither clean nor dirty.');
1045
+ P(' Calling it clean would be fail-open; calling it dirty would be an accusation. So: exit 2.');
1046
+ exitCode = 2;
1047
+ }
1048
+
1049
+ if (da.requested && da.violations.length) {
1050
+ P(`\n[effect-boundary] ✗ ${da.violations.length} domain(s) built in code are not on the owned list:`);
1051
+ for (const d of da.violations) P(` ✗ [${d.member}] ${d.host} (registrable: ${d.registrable})`);
1052
+ if (exitCode === 0) exitCode = 1;
1053
+ }
1054
+
1055
+ if (expired.length) {
1056
+ P(`\n[effect-boundary] ✗ ${expired.length} baseline entry/entries expired:`);
1057
+ for (const { fam, m, b } of expired) P(` ⏰ [${fam}] ${m.file} expired ${b.expires} — ${b.reason}`);
1058
+ P(' Expiry does not mean something broke. It means the decision to defer this needs making again.');
1059
+ if (exitCode === 0) exitCode = 1;
1060
+ }
1061
+
1062
+ if (newFindings.length) {
1063
+ P(`\n[effect-boundary] ✗ ${newFindings.length} effect implementation(s) reach nothing outside this process:`);
1064
+ for (const { fam, m } of newFindings) {
1065
+ P(` ✗ [${fam}] ${m.file}`);
1066
+ P(` ${m.reason}`);
1067
+ if (m.corroborated === false) P(' (corroboration: none — no sibling in this family reaches the outside either)');
1068
+ else if (m.corroborated === true) P(' (corroboration: siblings in this family do reach the outside)');
1069
+ }
1070
+ P('\n Three ways out: implement the effect; make it fail honestly (see R7-b — a NOT_IMPLEMENTED');
1071
+ P(' marker with no success-shaped return is legal and needs no allowlist); or add a baseline row');
1072
+ P(' with an expiry date and a reason.');
1073
+ if (exitCode === 0) exitCode = 1;
1074
+ }
1075
+
1076
+ if (baselined.length) {
1077
+ P(`[effect-boundary] ${baselined.length} known finding(s) suppressed by the baseline (all still within their expiry).`);
1078
+ }
1079
+
1080
+ const exemptCount = result.families.flatMap((f) => f.members).filter((m) => m.verdict === 'EXEMPT-HONEST').length;
1081
+ if (exemptCount) {
1082
+ P(`[effect-boundary] ${exemptCount} member(s) exempt under R7-b: they declare NOT_IMPLEMENTED and never claim success.`);
1083
+ }
1084
+
1085
+ if (exitCode === 0) {
1086
+ const green = result.families.flatMap((f) => f.members).filter((m) => m.verdict === 'GREEN').length;
1087
+ P(`[effect-boundary] ✓ ${green} member(s) reach a real cross-process boundary; no unexplained zero-effect implementations.`);
1088
+ P(' What this does NOT prove: that the boundary they touch is the *right* one.');
1089
+ P(' A decorative log request passes this gate. That is R9\'s job, and R9 is not implemented.');
1090
+ }
1091
+
1092
+ return { lines: L, exitCode };
1093
+ }