@loopover/engine 1.0.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 (272) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +661 -0
  3. package/README.md +676 -0
  4. package/dist/advisory/gate-advisory.d.ts +151 -0
  5. package/dist/advisory/gate-advisory.js +512 -0
  6. package/dist/ai-policy-map.d.ts +97 -0
  7. package/dist/ai-policy-map.js +377 -0
  8. package/dist/ams-policy-spec.d.ts +56 -0
  9. package/dist/ams-policy-spec.js +165 -0
  10. package/dist/calibration-dashboard.d.ts +23 -0
  11. package/dist/calibration-dashboard.js +65 -0
  12. package/dist/calibration-trend.d.ts +40 -0
  13. package/dist/calibration-trend.js +60 -0
  14. package/dist/contributor-fit.d.ts +24 -0
  15. package/dist/contributor-fit.js +50 -0
  16. package/dist/discovery-index-contract.d.ts +74 -0
  17. package/dist/discovery-index-contract.js +184 -0
  18. package/dist/discovery-soft-claim.d.ts +35 -0
  19. package/dist/discovery-soft-claim.js +50 -0
  20. package/dist/duplicate-winner.d.ts +57 -0
  21. package/dist/duplicate-winner.js +98 -0
  22. package/dist/feasibility.d.ts +27 -0
  23. package/dist/feasibility.js +86 -0
  24. package/dist/finding-severity-calibration.d.ts +116 -0
  25. package/dist/finding-severity-calibration.js +457 -0
  26. package/dist/fleet-run-manifest.d.ts +41 -0
  27. package/dist/fleet-run-manifest.js +151 -0
  28. package/dist/focus-manifest/guidance.d.ts +21 -0
  29. package/dist/focus-manifest/guidance.js +222 -0
  30. package/dist/focus-manifest.d.ts +826 -0
  31. package/dist/focus-manifest.js +2730 -0
  32. package/dist/gate-verdict-calibration.d.ts +111 -0
  33. package/dist/gate-verdict-calibration.js +411 -0
  34. package/dist/github/constants.d.ts +1 -0
  35. package/dist/github/constants.js +1 -0
  36. package/dist/github/sanitize-public-comment.d.ts +1 -0
  37. package/dist/github/sanitize-public-comment.js +26 -0
  38. package/dist/goal-model.d.ts +7 -0
  39. package/dist/goal-model.js +87 -0
  40. package/dist/governor/action-mode.d.ts +55 -0
  41. package/dist/governor/action-mode.js +74 -0
  42. package/dist/governor/budget-cap.d.ts +49 -0
  43. package/dist/governor/budget-cap.js +34 -0
  44. package/dist/governor/chokepoint.d.ts +58 -0
  45. package/dist/governor/chokepoint.js +269 -0
  46. package/dist/governor/kill-switch.d.ts +38 -0
  47. package/dist/governor/kill-switch.js +62 -0
  48. package/dist/governor/rate-limit.d.ts +40 -0
  49. package/dist/governor/rate-limit.js +63 -0
  50. package/dist/governor/reputation-throttle.d.ts +50 -0
  51. package/dist/governor/reputation-throttle.js +94 -0
  52. package/dist/governor/run-halt.d.ts +39 -0
  53. package/dist/governor/run-halt.js +83 -0
  54. package/dist/governor/self-plagiarism.d.ts +47 -0
  55. package/dist/governor/self-plagiarism.js +200 -0
  56. package/dist/governor/write-rate-limit.d.ts +52 -0
  57. package/dist/governor/write-rate-limit.js +132 -0
  58. package/dist/governor-ledger.d.ts +25 -0
  59. package/dist/governor-ledger.js +91 -0
  60. package/dist/index.d.ts +99 -0
  61. package/dist/index.js +120 -0
  62. package/dist/issue-plan-decomposition.d.ts +18 -0
  63. package/dist/issue-plan-decomposition.js +56 -0
  64. package/dist/issue-rag-query.d.ts +12 -0
  65. package/dist/issue-rag-query.js +35 -0
  66. package/dist/local-scorer.d.ts +34 -0
  67. package/dist/local-scorer.js +36 -0
  68. package/dist/metadata-best-min-score.d.ts +9 -0
  69. package/dist/metadata-best-min-score.js +9 -0
  70. package/dist/metadata-best-pick.d.ts +9 -0
  71. package/dist/metadata-best-pick.js +9 -0
  72. package/dist/metadata-min-score.d.ts +9 -0
  73. package/dist/metadata-min-score.js +13 -0
  74. package/dist/metadata-top-min-score.d.ts +9 -0
  75. package/dist/metadata-top-min-score.js +13 -0
  76. package/dist/metadata-top-pick.d.ts +9 -0
  77. package/dist/metadata-top-pick.js +13 -0
  78. package/dist/miner/acceptance-criteria.d.ts +43 -0
  79. package/dist/miner/acceptance-criteria.js +77 -0
  80. package/dist/miner/agent-sdk-driver.d.ts +39 -0
  81. package/dist/miner/agent-sdk-driver.js +162 -0
  82. package/dist/miner/attempt-log.d.ts +29 -0
  83. package/dist/miner/attempt-log.js +100 -0
  84. package/dist/miner/attempt-metering.d.ts +39 -0
  85. package/dist/miner/attempt-metering.js +67 -0
  86. package/dist/miner/cli-subprocess-driver.d.ts +68 -0
  87. package/dist/miner/cli-subprocess-driver.js +272 -0
  88. package/dist/miner/coding-agent-driver.d.ts +33 -0
  89. package/dist/miner/coding-agent-driver.js +36 -0
  90. package/dist/miner/coding-agent-invoke.d.ts +11 -0
  91. package/dist/miner/coding-agent-invoke.js +80 -0
  92. package/dist/miner/coding-agent-mode.d.ts +25 -0
  93. package/dist/miner/coding-agent-mode.js +43 -0
  94. package/dist/miner/driver-factory.d.ts +82 -0
  95. package/dist/miner/driver-factory.js +169 -0
  96. package/dist/miner/harness-submission-trigger.d.ts +37 -0
  97. package/dist/miner/harness-submission-trigger.js +46 -0
  98. package/dist/miner/iterate-loop.d.ts +97 -0
  99. package/dist/miner/iterate-loop.js +278 -0
  100. package/dist/miner/iterate-policy.d.ts +98 -0
  101. package/dist/miner/iterate-policy.js +98 -0
  102. package/dist/miner/lint-guard.d.ts +50 -0
  103. package/dist/miner/lint-guard.js +68 -0
  104. package/dist/miner/local-write-tools.d.ts +81 -0
  105. package/dist/miner/local-write-tools.js +113 -0
  106. package/dist/miner/loop-reentry-policy.d.ts +36 -0
  107. package/dist/miner/loop-reentry-policy.js +53 -0
  108. package/dist/miner/repo-map.d.ts +51 -0
  109. package/dist/miner/repo-map.js +268 -0
  110. package/dist/miner/self-review-adapter.d.ts +106 -0
  111. package/dist/miner/self-review-adapter.js +83 -0
  112. package/dist/miner/submission-gate.d.ts +40 -0
  113. package/dist/miner/submission-gate.js +89 -0
  114. package/dist/miner/worktree-allocator.d.ts +59 -0
  115. package/dist/miner/worktree-allocator.js +60 -0
  116. package/dist/miner/worktree-pool.d.ts +55 -0
  117. package/dist/miner/worktree-pool.js +65 -0
  118. package/dist/miner-goal-lane-fit.d.ts +18 -0
  119. package/dist/miner-goal-lane-fit.js +76 -0
  120. package/dist/miner-goal-spec.d.ts +139 -0
  121. package/dist/miner-goal-spec.js +281 -0
  122. package/dist/miner-prediction-metrics.d.ts +16 -0
  123. package/dist/miner-prediction-metrics.js +61 -0
  124. package/dist/miner-telemetry.d.ts +32 -0
  125. package/dist/miner-telemetry.js +87 -0
  126. package/dist/objective-anchor.d.ts +125 -0
  127. package/dist/objective-anchor.js +401 -0
  128. package/dist/opportunity-competition.d.ts +6 -0
  129. package/dist/opportunity-competition.js +29 -0
  130. package/dist/opportunity-freshness.d.ts +18 -0
  131. package/dist/opportunity-freshness.js +55 -0
  132. package/dist/opportunity-metadata.d.ts +53 -0
  133. package/dist/opportunity-metadata.js +204 -0
  134. package/dist/opportunity-ranker.d.ts +46 -0
  135. package/dist/opportunity-ranker.js +74 -0
  136. package/dist/pairwise-calibration.d.ts +49 -0
  137. package/dist/pairwise-calibration.js +106 -0
  138. package/dist/phase7-calibration-loop.d.ts +136 -0
  139. package/dist/phase7-calibration-loop.js +437 -0
  140. package/dist/plan-blocked.d.ts +6 -0
  141. package/dist/plan-blocked.js +24 -0
  142. package/dist/plan-completed.d.ts +5 -0
  143. package/dist/plan-completed.js +6 -0
  144. package/dist/plan-completion.d.ts +6 -0
  145. package/dist/plan-completion.js +7 -0
  146. package/dist/plan-empty.d.ts +5 -0
  147. package/dist/plan-empty.js +6 -0
  148. package/dist/plan-export.d.ts +27 -0
  149. package/dist/plan-export.js +86 -0
  150. package/dist/plan-failure.d.ts +5 -0
  151. package/dist/plan-failure.js +6 -0
  152. package/dist/plan-overall-status.d.ts +6 -0
  153. package/dist/plan-overall-status.js +25 -0
  154. package/dist/plan-pending.d.ts +5 -0
  155. package/dist/plan-pending.js +6 -0
  156. package/dist/plan-progress-complete.d.ts +7 -0
  157. package/dist/plan-progress-complete.js +10 -0
  158. package/dist/plan-ready.d.ts +6 -0
  159. package/dist/plan-ready.js +12 -0
  160. package/dist/plan-running.d.ts +5 -0
  161. package/dist/plan-running.js +6 -0
  162. package/dist/plan-skipped.d.ts +5 -0
  163. package/dist/plan-skipped.js +6 -0
  164. package/dist/plan-step-count.d.ts +5 -0
  165. package/dist/plan-step-count.js +6 -0
  166. package/dist/plan-step-stats.d.ts +5 -0
  167. package/dist/plan-step-stats.js +6 -0
  168. package/dist/plan-templates.d.ts +25 -0
  169. package/dist/plan-templates.js +107 -0
  170. package/dist/plan-terminated.d.ts +6 -0
  171. package/dist/plan-terminated.js +9 -0
  172. package/dist/portfolio/non-convergence.d.ts +37 -0
  173. package/dist/portfolio/non-convergence.js +57 -0
  174. package/dist/portfolio/queue.d.ts +30 -0
  175. package/dist/portfolio/queue.js +150 -0
  176. package/dist/predicted-gate.d.ts +103 -0
  177. package/dist/predicted-gate.js +218 -0
  178. package/dist/prompt-packet.d.ts +11 -0
  179. package/dist/prompt-packet.js +34 -0
  180. package/dist/ranked-opportunity-best-min-score.d.ts +8 -0
  181. package/dist/ranked-opportunity-best-min-score.js +9 -0
  182. package/dist/ranked-opportunity-best-pick.d.ts +8 -0
  183. package/dist/ranked-opportunity-best-pick.js +9 -0
  184. package/dist/ranked-opportunity-min-score.d.ts +8 -0
  185. package/dist/ranked-opportunity-min-score.js +13 -0
  186. package/dist/ranked-opportunity-top-min-score.d.ts +8 -0
  187. package/dist/ranked-opportunity-top-min-score.js +13 -0
  188. package/dist/review/advisory-ai-routing-config.d.ts +8 -0
  189. package/dist/review/advisory-ai-routing-config.js +40 -0
  190. package/dist/review/check-names.d.ts +1 -0
  191. package/dist/review/check-names.js +1 -0
  192. package/dist/review/cla-check.d.ts +49 -0
  193. package/dist/review/cla-check.js +71 -0
  194. package/dist/review/diff-file-priority.d.ts +1 -0
  195. package/dist/review/diff-file-priority.js +12 -0
  196. package/dist/review/enrichment-analyzer-names.d.ts +3 -0
  197. package/dist/review/enrichment-analyzer-names.js +63 -0
  198. package/dist/review/guardrail-config.d.ts +10 -0
  199. package/dist/review/guardrail-config.js +62 -0
  200. package/dist/review/linked-issue-hard-rules-config.d.ts +4 -0
  201. package/dist/review/linked-issue-hard-rules-config.js +79 -0
  202. package/dist/review/linked-issue-label-propagation.d.ts +9 -0
  203. package/dist/review/linked-issue-label-propagation.js +114 -0
  204. package/dist/review/pre-merge-checks.d.ts +28 -0
  205. package/dist/review/pre-merge-checks.js +66 -0
  206. package/dist/review/review-thread-findings.d.ts +1 -0
  207. package/dist/review/review-thread-findings.js +1 -0
  208. package/dist/review/safe-url.d.ts +5 -0
  209. package/dist/review/safe-url.js +137 -0
  210. package/dist/review/screenshot-table-gate.d.ts +87 -0
  211. package/dist/review/screenshot-table-gate.js +340 -0
  212. package/dist/review/unlinked-issue-guardrail-config.d.ts +9 -0
  213. package/dist/review/unlinked-issue-guardrail-config.js +44 -0
  214. package/dist/reviewer-consensus-calibration.d.ts +113 -0
  215. package/dist/reviewer-consensus-calibration.js +493 -0
  216. package/dist/reward-risk.d.ts +223 -0
  217. package/dist/reward-risk.js +618 -0
  218. package/dist/scoring/label-match.d.ts +4 -0
  219. package/dist/scoring/label-match.js +145 -0
  220. package/dist/scoring/model.d.ts +31 -0
  221. package/dist/scoring/model.js +157 -0
  222. package/dist/scoring/pending-pr-scenarios.d.ts +44 -0
  223. package/dist/scoring/pending-pr-scenarios.js +145 -0
  224. package/dist/scoring/preview.d.ts +246 -0
  225. package/dist/scoring/preview.js +1108 -0
  226. package/dist/scoring/types.d.ts +131 -0
  227. package/dist/scoring/types.js +7 -0
  228. package/dist/settings/auto-close-exempt.d.ts +10 -0
  229. package/dist/settings/auto-close-exempt.js +58 -0
  230. package/dist/settings/autonomy.d.ts +33 -0
  231. package/dist/settings/autonomy.js +80 -0
  232. package/dist/settings/command-authorization.d.ts +49 -0
  233. package/dist/settings/command-authorization.js +222 -0
  234. package/dist/settings/contributor-blacklist.d.ts +17 -0
  235. package/dist/settings/contributor-blacklist.js +86 -0
  236. package/dist/settings/moderation-rules.d.ts +51 -0
  237. package/dist/settings/moderation-rules.js +104 -0
  238. package/dist/settings/pr-type-label.d.ts +70 -0
  239. package/dist/settings/pr-type-label.js +149 -0
  240. package/dist/signals/change-guardrail.d.ts +49 -0
  241. package/dist/signals/change-guardrail.js +164 -0
  242. package/dist/signals/check-summary.d.ts +9 -0
  243. package/dist/signals/check-summary.js +12 -0
  244. package/dist/signals/contributor-calibration.d.ts +23 -0
  245. package/dist/signals/contributor-calibration.js +47 -0
  246. package/dist/signals/duplicate-winner.d.ts +9 -0
  247. package/dist/signals/duplicate-winner.js +9 -0
  248. package/dist/signals/path-matchers.d.ts +36 -0
  249. package/dist/signals/path-matchers.js +371 -0
  250. package/dist/signals/predicted-gate-engine.d.ts +62 -0
  251. package/dist/signals/predicted-gate-engine.js +895 -0
  252. package/dist/signals/preflight-limits.d.ts +14 -0
  253. package/dist/signals/preflight-limits.js +14 -0
  254. package/dist/signals/slop.d.ts +56 -0
  255. package/dist/signals/slop.js +324 -0
  256. package/dist/signals/test-evidence.d.ts +25 -0
  257. package/dist/signals/test-evidence.js +108 -0
  258. package/dist/subprocess-env.d.ts +23 -0
  259. package/dist/subprocess-env.js +74 -0
  260. package/dist/track-record-summary.d.ts +92 -0
  261. package/dist/track-record-summary.js +301 -0
  262. package/dist/types/manifest-deps-types.d.ts +514 -0
  263. package/dist/types/manifest-deps-types.js +4 -0
  264. package/dist/types/predicted-gate-types.d.ts +338 -0
  265. package/dist/types/predicted-gate-types.js +3 -0
  266. package/dist/types/reward-risk-types.d.ts +319 -0
  267. package/dist/types/reward-risk-types.js +11 -0
  268. package/dist/utils/json.d.ts +1 -0
  269. package/dist/utils/json.js +3 -0
  270. package/dist/version.d.ts +2 -0
  271. package/dist/version.js +3 -0
  272. package/package.json +80 -0
@@ -0,0 +1 @@
1
+ export declare const REVIEW_THREAD_BLOCKER_CODE = "review_thread_unresolved";
@@ -0,0 +1 @@
1
+ export const REVIEW_THREAD_BLOCKER_CODE = "review_thread_unresolved";
@@ -0,0 +1,5 @@
1
+ /** https + public (non-loopback, non-private) host. */
2
+ export declare function isSafeHttpUrl(raw: string): boolean;
3
+ /** Like isSafeHttpUrl but also permits secure WebSocket endpoints (`wss:`, `ws:`) — base-layer chain
4
+ * endpoints (subtensor RPC/WSS/archive) are probed via JSON-RPC, not HTTP. Same SSRF host/IP guard. */
5
+ export declare function isSafeEndpointUrl(raw: string): boolean;
@@ -0,0 +1,137 @@
1
+ // SSRF-safe URL guard (content-lane primitive).
2
+ //
3
+ // SELF-CONTAINED NATIVE PORT (reviewbot→gittensory convergence). Ported from reviewbot's
4
+ // core/source-url.ts isSafeHttpUrl + isSafeEndpointUrl (the host/IP guard, including the encoded-IP
5
+ // decoding that a dotted-quad regex misses), hardened so a trailing-dot or `*.localhost` host can't
6
+ // dodge the loopback check. PURE — no imports, no I/O.
7
+ //
8
+ // Rejects non-HTTPS (isSafeHttpUrl), localhost / `*.localhost` / .local / .internal, and private/
9
+ // loopback/link-local IPs in any literal notation (decimal `2130706433`, hex `0x7f000001`, octal,
10
+ // short `127.1`, and the IPv6 forms). isSafeEndpointUrl additionally permits wss:/ws: for base-layer
11
+ // chain endpoints.
12
+ function parseIpv4Component(part) {
13
+ if (/^0x[0-9a-f]+$/i.test(part))
14
+ return parseInt(part, 16);
15
+ if (/^0[0-7]+$/.test(part))
16
+ return parseInt(part, 8); // leading-zero → octal
17
+ if (/^(?:0|[1-9]\d*)$/.test(part))
18
+ return parseInt(part, 10);
19
+ return null;
20
+ }
21
+ function ipv4ToInt(host) {
22
+ const parts = host.split(".");
23
+ if (parts.length < 1 || parts.length > 4)
24
+ return null;
25
+ const vals = [];
26
+ for (const part of parts) {
27
+ const v = parseIpv4Component(part);
28
+ if (v === null || !Number.isFinite(v) || v < 0)
29
+ return null;
30
+ vals.push(v);
31
+ }
32
+ const n = vals.length;
33
+ // Byte-faithful overflow guards from reviewbot's core/source-url.ts. Unreachable via the public
34
+ // isSafe*Url entry points: a host reaches here only after `new URL()`, and the WHATWG parser
35
+ // rejects any all-numeric dotted host whose components overflow (so the >0xff / >lastMax / >2^32
36
+ // cases never arrive), while a host that survives parsing as a domain has a non-numeric label that
37
+ // makes parseIpv4Component bail (line 26) before these run. Retained for source parity + defense.
38
+ /* v8 ignore start -- @preserve unreachable through new URL() host normalization (see note above) */
39
+ for (let i = 0; i < n - 1; i += 1)
40
+ if (vals[i] > 0xff)
41
+ return null;
42
+ const lastMax = [0xffffffff, 0xffffff, 0xffff, 0xff][n - 1];
43
+ if (vals[n - 1] > lastMax)
44
+ return null;
45
+ let result = vals[n - 1];
46
+ for (let i = 0; i < n - 1; i += 1)
47
+ result += vals[i] * 256 ** (3 - i);
48
+ return result > 0xffffffff ? null : result >>> 0;
49
+ /* v8 ignore stop */
50
+ }
51
+ function ipv4IsPrivateOrLocal(host) {
52
+ const n = ipv4ToInt(host);
53
+ if (n === null)
54
+ return false;
55
+ const a = (n >>> 24) & 0xff;
56
+ const b = (n >>> 16) & 0xff;
57
+ if (a === 0 || a === 10 || a === 127)
58
+ return true; // 0.0.0.0/8, 10/8, loopback
59
+ if (a === 169 && b === 254)
60
+ return true; // link-local (incl. cloud metadata 169.254.169.254)
61
+ if (a === 192 && b === 168)
62
+ return true;
63
+ if (a === 172 && b >= 16 && b <= 31)
64
+ return true;
65
+ return false;
66
+ }
67
+ function ipv6IsPrivateOrLocal(host) {
68
+ const addr = host.replace(/^\[|\]$/g, "");
69
+ // Caller (hostIsPrivateOrLocal) only invokes this when the host contains ":", and bracket
70
+ // stripping never removes an interior colon — so the no-colon guard's true side is unreachable.
71
+ /* v8 ignore next -- @preserve true side unreachable: caller guards host.includes(":") */
72
+ if (!addr.includes(":"))
73
+ return false;
74
+ if (addr === "::1" || addr === "::")
75
+ return true;
76
+ // `new URL()` collapses the dotted IPv4-mapped form (::ffff:127.0.0.1) to the hex form
77
+ // (::ffff:7f00:1), so this dotted-quad regex never matches via the public entry points; the hex
78
+ // branch below carries the IPv4-mapped case. Retained for source parity.
79
+ const dotted = addr.match(/::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
80
+ /* v8 ignore next -- @preserve dotted ::ffff:N.N.N.N is normalized to hex by new URL() */
81
+ if (dotted)
82
+ return ipv4IsPrivateOrLocal(dotted[1]);
83
+ const hex = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
84
+ if (hex) {
85
+ const hi = parseInt(hex[1], 16);
86
+ const lo = parseInt(hex[2], 16);
87
+ return ipv4IsPrivateOrLocal(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
88
+ }
89
+ const first = addr.split(":")[0];
90
+ if (first.startsWith("fc") || first.startsWith("fd"))
91
+ return true; // ULA fc00::/7
92
+ if (/^fe[89ab]/.test(first))
93
+ return true; // link-local fe80::/10
94
+ return false;
95
+ }
96
+ function hostIsPrivateOrLocal(host) {
97
+ // Normalize once: lower-case, then strip the FQDN root dot(s) the parser keeps on named hosts
98
+ // (`localhost.`) but not on IP literals — else `localhost.` / `foo.internal.` would read as public.
99
+ const h = host.toLowerCase().replace(/\.+$/, "");
100
+ // localhost + its RFC 6761 `*.localhost` namespace, plus the reserved `.local` (mDNS) / `.internal`.
101
+ if (h === "localhost" || h.endsWith(".localhost"))
102
+ return true;
103
+ if (h.endsWith(".local") || h.endsWith(".internal"))
104
+ return true;
105
+ if (h === "0.0.0.0" || h === "::1" || h === "[::1]")
106
+ return true;
107
+ if (h.includes(":"))
108
+ return ipv6IsPrivateOrLocal(h);
109
+ return ipv4IsPrivateOrLocal(h);
110
+ }
111
+ /** https + public (non-loopback, non-private) host. */
112
+ export function isSafeHttpUrl(raw) {
113
+ let url;
114
+ try {
115
+ url = new URL(raw);
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ if (url.protocol !== "https:")
121
+ return false;
122
+ return !hostIsPrivateOrLocal(url.hostname);
123
+ }
124
+ /** Like isSafeHttpUrl but also permits secure WebSocket endpoints (`wss:`, `ws:`) — base-layer chain
125
+ * endpoints (subtensor RPC/WSS/archive) are probed via JSON-RPC, not HTTP. Same SSRF host/IP guard. */
126
+ export function isSafeEndpointUrl(raw) {
127
+ let url;
128
+ try {
129
+ url = new URL(raw);
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ if (!["https:", "wss:", "ws:"].includes(url.protocol))
135
+ return false;
136
+ return !hostIsPrivateOrLocal(url.hostname);
137
+ }
@@ -0,0 +1,87 @@
1
+ import type { ScreenshotTableGateAction, ScreenshotTableGateConfig } from "../types/manifest-deps-types.js";
2
+ export type { ScreenshotTableGateAction, ScreenshotTableGateConfig } from "../types/manifest-deps-types.js";
3
+ export declare const DEFAULT_SCREENSHOT_TABLE_GATE: ScreenshotTableGateConfig;
4
+ export declare function isScreenshotTableGateAction(value: unknown): value is ScreenshotTableGateAction;
5
+ /** Normalize a raw `requireScreenshotTable` value (DB JSON or `.gittensory.yml`) into a validated config. Never
6
+ * throws: malformed fields fall back to the default (disabled/empty), matching every other settings normalizer
7
+ * in this codebase. */
8
+ export declare function normalizeScreenshotTableGateConfig(input: unknown, warnings: string[]): ScreenshotTableGateConfig;
9
+ /** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells
10
+ * embed image markup — either `![alt](url)` or an `<img ...>` tag — inside the table. A screenshot pasted as a
11
+ * bare inline image OUTSIDE any table does not count (the contract requires captioned thumbnails INSIDE a
12
+ * table, not a wall of raw images). Deliberately simple/regex-based (no markdown AST dependency) — false
13
+ * negatives fail toward "no table found" (in-scope PRs still need a real table), false positives fail toward
14
+ * "table found" (never blocks a PR that plausibly complied); both directions are acceptable for a
15
+ * first-pass deterministic heuristic that a maintainer can always override by hand. */
16
+ export declare function hasImageBearingMarkdownTable(body: string | null | undefined): boolean;
17
+ /** True when `body` has a large inline image OUTSIDE of any markdown table — a common way contributors dodge
18
+ * the table requirement (paste screenshots directly into the body instead of inside a captioned table row). */
19
+ export declare function hasImageOutsideTable(body: string | null | undefined): boolean;
20
+ /** True when any changed file path is an image under a scoped path (a screenshot committed to the repo instead
21
+ * of uploaded to the PR body via GitHub's CDN, per the contract). `scopedPaths` should be the SAME glob list
22
+ * used for scope matching (`whenPaths`) so this only flags an image landing where visual work is expected —
23
+ * not an unrelated asset (e.g. a favicon) added anywhere else in the repo. Empty `scopedPaths` (no path scoping
24
+ * configured) checks every changed path. */
25
+ export declare function hasCommittedImageFile(changedFiles: string[], scopedPaths: string[]): boolean;
26
+ /** One data row of a detected markdown table: the cell texts in source order (leading/trailing pipes and
27
+ * whitespace stripped). Deliberately a SEPARATE table-detection pass from {@link hasImageBearingMarkdownTable}
28
+ * rather than a shared refactor of it -- that function's exact behavior is pinned by existing tests, and this
29
+ * one needs actual cell contents (not just "does some cell have an image"), so duplicating its short
30
+ * header+separator detection loop keeps both independently simple instead of risking a regression in either
31
+ * from a shared-code change. */
32
+ export declare function extractTableRows(body: string | null | undefined): string[][];
33
+ /** The image URLs found in each detected table row (source order), for rows with at least two — a real
34
+ * before/after pair worth comparing, not a single decorative image or caption-only row. Reuses
35
+ * {@link extractTableRows}'s own header+separator detection rather than re-scanning the body. A row with
36
+ * MORE than two images (e.g. a desktop+mobile matrix row) keeps every image; callers that only want a pair
37
+ * slice it themselves. */
38
+ export declare function extractTableRowImageUrls(body: string | null | undefined): string[][];
39
+ /** One (viewport, theme) combination the matrix must cover. `theme: null` means the theme dimension isn't
40
+ * required at all (a repo can require viewport coverage without color-mode coverage). */
41
+ export type ScreenshotMatrixPair = {
42
+ viewport: string;
43
+ theme: string | null;
44
+ };
45
+ /** The full set of (viewport, theme) pairs `config` requires, or `[]` when matrix mode is off. Matrix mode
46
+ * turns on via `requireViewports` alone -- `requireThemes` with an empty `requireViewports` has no effect,
47
+ * since there is no viewport to cross it against. */
48
+ export declare function requiredScreenshotMatrixPairs(config: ScreenshotTableGateConfig): ScreenshotMatrixPair[];
49
+ /** The subset of `pairs` with NO satisfying row anywhere in `body`'s tables. Empty ⇒ full coverage. */
50
+ export declare function missingScreenshotMatrixPairs(body: string | null | undefined, pairs: ScreenshotMatrixPair[]): ScreenshotMatrixPair[];
51
+ /** Build the rejection reason for a matrix violation, naming exactly which viewport/theme combinations are
52
+ * still missing a real before+after pair -- so the contributor knows precisely what to add, not just that
53
+ * "something" is missing. */
54
+ export declare function buildScreenshotMatrixMessage(missing: ScreenshotMatrixPair[]): string;
55
+ /** True when the PR is IN SCOPE for the gate: it carries one of `config.whenLabels` OR touches a path matching
56
+ * one of `config.whenPaths`. Both empty ⇒ every PR is in scope (an operator who enables the gate with no
57
+ * scoping at all wants it enforced everywhere). Only one non-empty list configured ⇒ that list alone decides
58
+ * scope (the other, empty list can never exclude a PR the configured one matched). */
59
+ export declare function isScreenshotTableGateInScope(config: ScreenshotTableGateConfig, prLabels: string[], changedFiles: string[]): boolean;
60
+ export declare const DEFAULT_SCREENSHOT_CONTRACT_MESSAGE: string;
61
+ export type ScreenshotTableGateResult = {
62
+ violated: boolean;
63
+ reason: string | null;
64
+ };
65
+ /** PURE evaluator. Off (`enabled: false`) or out-of-scope (no configured label/path match) ⇒ no violation.
66
+ * `botCaptureSatisfied` ⇒ no violation regardless of mode (an automated capture is equivalent to a
67
+ * hand-authored table, and the bot doesn't (yet) shoot a full viewport/theme matrix -- see #4535's scope note).
68
+ *
69
+ * Two modes, chosen by whether `config.requireViewports` is non-empty (#4535):
70
+ * - MATRIX mode: every required (viewport, theme) pair (`requiredScreenshotMatrixPairs`) must have a labeled
71
+ * before/after row. Violated ⇒ the reason names exactly which pairs are still missing.
72
+ * - PRESENCE mode (the original #2006 behavior, unchanged): in scope AND (no image-bearing table in the body
73
+ * OR an image pasted outside a table OR a committed image file under a scoped path) ⇒ violated, with the
74
+ * configured (or default) templated message as the reason. */
75
+ export declare function evaluateScreenshotTableGate(input: {
76
+ config: ScreenshotTableGateConfig;
77
+ prBody: string | null | undefined;
78
+ prLabels: string[];
79
+ changedFiles: string[];
80
+ /** #4110: true when the bot's own before/after capture pipeline (review.visual.enabled) already produced a
81
+ * REAL before+after render pair for this PR's current head — evidence equivalent to a hand-authored table.
82
+ * A successful automated capture satisfies the gate on its own, ahead of (and regardless of) the body-table
83
+ * anti-gaming checks below — those exist to stop a contributor from FAKING compliance without the bot's
84
+ * help, which doesn't apply once the bot has already proven the change visually. Absent/false ⇒
85
+ * byte-identical to pre-#4110 behavior (body-table evidence only). */
86
+ botCaptureSatisfied?: boolean | undefined;
87
+ }): ScreenshotTableGateResult;
@@ -0,0 +1,340 @@
1
+ import { matchesAny } from "../signals/change-guardrail.js";
2
+ // Config-driven before/after screenshot-table gate (#2006). Contributor visual/frontend PRs are unreviewable
3
+ // at a glance without before/after evidence — this is a DETERMINISTIC (no AI, zero hallucination risk) check
4
+ // that a PR's body contains a markdown table with image markup, scoped to the repo's configured labels/paths.
5
+ // Mirrors the shape of contributor-blacklist.ts / linked-issue-hard-rules-config.ts: a normalizer (DB JSON or
6
+ // `.gittensory.yml` → validated config) plus a pure evaluator the trigger calls with live PR facts. Off by
7
+ // default (`enabled: false`) — a self-hoster opts in per repo, never hard-coded for any one project.
8
+ const MAX_LABELS = 50;
9
+ const MAX_PATHS = 50;
10
+ const MAX_LABEL_CHARS = 100;
11
+ const MAX_PATH_CHARS = 300;
12
+ const MAX_MATRIX_DIMENSION = 12;
13
+ const MAX_MATRIX_TOKEN_CHARS = 40;
14
+ const MAX_SKILL_FILE_URL_CHARS = 300;
15
+ // Extensions treated as "an image file" for the committed-image-file check below. Deliberately excludes SVG:
16
+ // an SVG can embed script/foreign-object content, so it is never accepted as review evidence anywhere in this
17
+ // repo (see the PR template's own UI Evidence rule) — a committed .svg is caught by neither this check nor the
18
+ // body-table one, exactly like the template's existing screenshots-must-be-raster rule.
19
+ const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
20
+ export const DEFAULT_SCREENSHOT_TABLE_GATE = {
21
+ enabled: false,
22
+ whenLabels: [],
23
+ whenPaths: [],
24
+ action: "close",
25
+ requireViewports: [],
26
+ requireThemes: [],
27
+ };
28
+ const VALID_ACTIONS = ["close", "advisory"];
29
+ export function isScreenshotTableGateAction(value) {
30
+ return typeof value === "string" && VALID_ACTIONS.includes(value);
31
+ }
32
+ function normalizeStringList(value, field, max, maxChars, warnings) {
33
+ if (value === undefined)
34
+ return [];
35
+ if (!Array.isArray(value)) {
36
+ warnings.push(`settings.requireScreenshotTable.${field} must be an array; ignoring it.`);
37
+ return [];
38
+ }
39
+ const out = [];
40
+ for (const [index, item] of value.entries()) {
41
+ if (out.length >= max) {
42
+ warnings.push(`settings.requireScreenshotTable.${field} is capped at ${max} entries; dropping the rest.`);
43
+ break;
44
+ }
45
+ if (typeof item !== "string" || item.trim().length === 0) {
46
+ warnings.push(`settings.requireScreenshotTable.${field}[${index}] must be a non-empty string; ignoring it.`);
47
+ continue;
48
+ }
49
+ out.push(item.trim().slice(0, maxChars));
50
+ }
51
+ return out;
52
+ }
53
+ /** Normalize a raw `requireScreenshotTable` value (DB JSON or `.gittensory.yml`) into a validated config. Never
54
+ * throws: malformed fields fall back to the default (disabled/empty), matching every other settings normalizer
55
+ * in this codebase. */
56
+ export function normalizeScreenshotTableGateConfig(input, warnings) {
57
+ if (input === undefined || input === null)
58
+ return { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [], requireViewports: [], requireThemes: [] };
59
+ if (typeof input !== "object" || Array.isArray(input)) {
60
+ warnings.push("settings.requireScreenshotTable must be an object; using the default (disabled).");
61
+ return { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [], requireViewports: [], requireThemes: [] };
62
+ }
63
+ const record = input;
64
+ const enabled = typeof record.enabled === "boolean" ? record.enabled : DEFAULT_SCREENSHOT_TABLE_GATE.enabled;
65
+ if (record.enabled !== undefined && typeof record.enabled !== "boolean") {
66
+ warnings.push(`settings.requireScreenshotTable.enabled must be a boolean; using the default "${DEFAULT_SCREENSHOT_TABLE_GATE.enabled}".`);
67
+ }
68
+ const action = isScreenshotTableGateAction(record.action)
69
+ ? record.action
70
+ : (() => {
71
+ if (record.action !== undefined)
72
+ warnings.push(`settings.requireScreenshotTable.action must be "close" or "advisory" (#4110 removed request_changes/comment as dead config surface); using the default "close".`);
73
+ return DEFAULT_SCREENSHOT_TABLE_GATE.action;
74
+ })();
75
+ const message = typeof record.message === "string" && record.message.trim().length > 0 ? record.message.trim() : undefined;
76
+ if (record.message !== undefined && message === undefined) {
77
+ warnings.push("settings.requireScreenshotTable.message must be a non-empty string; using the default message.");
78
+ }
79
+ const skillFileUrl = normalizeSkillFileUrl(record.skillFileUrl, warnings);
80
+ return {
81
+ enabled,
82
+ whenLabels: normalizeStringList(record.whenLabels, "whenLabels", MAX_LABELS, MAX_LABEL_CHARS, warnings),
83
+ whenPaths: normalizeStringList(record.whenPaths, "whenPaths", MAX_PATHS, MAX_PATH_CHARS, warnings),
84
+ action,
85
+ requireViewports: normalizeStringList(record.requireViewports, "requireViewports", MAX_MATRIX_DIMENSION, MAX_MATRIX_TOKEN_CHARS, warnings),
86
+ requireThemes: normalizeStringList(record.requireThemes, "requireThemes", MAX_MATRIX_DIMENSION, MAX_MATRIX_TOKEN_CHARS, warnings),
87
+ ...(message !== undefined ? { message } : {}),
88
+ ...(skillFileUrl !== undefined ? { skillFileUrl } : {}),
89
+ };
90
+ }
91
+ /** Validate a `skillFileUrl` override: same trust/validation level as `message` above (a trusted
92
+ * maintainer-authored config value, never fetched server-side -- it is only ever embedded as TEXT in a
93
+ * GitHub comment/close reason, so there is no SSRF surface here to guard against, unlike a URL the
94
+ * server would dereference). Malformed values are dropped with a warning, never silently coerced. */
95
+ function normalizeSkillFileUrl(value, warnings) {
96
+ if (value === undefined)
97
+ return undefined;
98
+ if (typeof value !== "string" || value.trim().length === 0 || value.trim().length > MAX_SKILL_FILE_URL_CHARS) {
99
+ warnings.push(`settings.requireScreenshotTable.skillFileUrl must be a non-empty string no longer than ${MAX_SKILL_FILE_URL_CHARS} characters; ignoring it.`);
100
+ return undefined;
101
+ }
102
+ return value.trim();
103
+ }
104
+ /** Linear-time markdown table separator check. The previous single-regex form nested unbounded `\\s*` inside a
105
+ * repeated group and could catastrophically backtrack on attacker-controlled PR bodies; this splits on `|` and
106
+ * validates each cell independently instead. */
107
+ const TABLE_SEPARATOR_CELL = /^\s*:?-{3,}:?\s*$/;
108
+ function isMarkdownTableSeparatorRow(line) {
109
+ const trimmed = line.trim();
110
+ if (!trimmed || !/-{3,}/.test(trimmed))
111
+ return false;
112
+ const withoutEdgePipes = trimmed.replace(/^\|/, "").replace(/\|$/, "").trim();
113
+ const cells = withoutEdgePipes.split("|");
114
+ return cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell));
115
+ }
116
+ /** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells
117
+ * embed image markup — either `![alt](url)` or an `<img ...>` tag — inside the table. A screenshot pasted as a
118
+ * bare inline image OUTSIDE any table does not count (the contract requires captioned thumbnails INSIDE a
119
+ * table, not a wall of raw images). Deliberately simple/regex-based (no markdown AST dependency) — false
120
+ * negatives fail toward "no table found" (in-scope PRs still need a real table), false positives fail toward
121
+ * "table found" (never blocks a PR that plausibly complied); both directions are acceptable for a
122
+ * first-pass deterministic heuristic that a maintainer can always override by hand. */
123
+ export function hasImageBearingMarkdownTable(body) {
124
+ if (!body)
125
+ return false;
126
+ const lines = body.split(/\r?\n/);
127
+ const tableRowPattern = /^\s*\|.*\|\s*$/;
128
+ const imagePattern = /!\[[^\]]*\]\([^)]+\)|<img\b[^>]*>/i;
129
+ for (let i = 0; i < lines.length - 1; i += 1) {
130
+ // `i < lines.length - 1` guarantees both indices are in bounds; the `?? ""` fallbacks only exist to
131
+ // satisfy noUncheckedIndexedAccess and are never actually reached.
132
+ /* v8 ignore next -- defensive: the loop bound above guarantees lines[i] always exists here. */
133
+ const header = lines[i] ?? "";
134
+ /* v8 ignore next -- defensive: the loop bound above guarantees lines[i + 1] always exists here. */
135
+ const separator = lines[i + 1] ?? "";
136
+ if (!tableRowPattern.test(header) || !isMarkdownTableSeparatorRow(separator))
137
+ continue;
138
+ // Found a table (header + separator). Scan its body rows (until a blank line or a non-table line) for
139
+ // image markup in any cell.
140
+ let j = i + 2;
141
+ /* v8 ignore next -- defensive: the `j < lines.length` guard above guarantees lines[j] always exists here. */
142
+ while (j < lines.length && tableRowPattern.test(lines[j] ?? "")) {
143
+ if (imagePattern.test(lines[j] ?? ""))
144
+ return true;
145
+ j += 1;
146
+ }
147
+ }
148
+ return false;
149
+ }
150
+ /** True when `body` has a large inline image OUTSIDE of any markdown table — a common way contributors dodge
151
+ * the table requirement (paste screenshots directly into the body instead of inside a captioned table row). */
152
+ export function hasImageOutsideTable(body) {
153
+ if (!body)
154
+ return false;
155
+ const lines = body.split(/\r?\n/);
156
+ const tableRowPattern = /^\s*\|.*\|\s*$/;
157
+ const imagePattern = /!\[[^\]]*\]\([^)]+\)|<img\b[^>]*>/i;
158
+ return lines.some((line) => imagePattern.test(line) && !tableRowPattern.test(line));
159
+ }
160
+ /** True when any changed file path is an image under a scoped path (a screenshot committed to the repo instead
161
+ * of uploaded to the PR body via GitHub's CDN, per the contract). `scopedPaths` should be the SAME glob list
162
+ * used for scope matching (`whenPaths`) so this only flags an image landing where visual work is expected —
163
+ * not an unrelated asset (e.g. a favicon) added anywhere else in the repo. Empty `scopedPaths` (no path scoping
164
+ * configured) checks every changed path. */
165
+ export function hasCommittedImageFile(changedFiles, scopedPaths) {
166
+ return changedFiles.some((file) => {
167
+ const lower = file.toLowerCase();
168
+ if (!IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)))
169
+ return false;
170
+ return scopedPaths.length === 0 || matchesAny(file, scopedPaths);
171
+ });
172
+ }
173
+ const IMAGE_CELL_PATTERN = /!\[[^\]]*\]\([^)]+\)|<img\b[^>]*>/i;
174
+ /** One data row of a detected markdown table: the cell texts in source order (leading/trailing pipes and
175
+ * whitespace stripped). Deliberately a SEPARATE table-detection pass from {@link hasImageBearingMarkdownTable}
176
+ * rather than a shared refactor of it -- that function's exact behavior is pinned by existing tests, and this
177
+ * one needs actual cell contents (not just "does some cell have an image"), so duplicating its short
178
+ * header+separator detection loop keeps both independently simple instead of risking a regression in either
179
+ * from a shared-code change. */
180
+ export function extractTableRows(body) {
181
+ if (!body)
182
+ return [];
183
+ const lines = body.split(/\r?\n/);
184
+ const tableRowPattern = /^\s*\|.*\|\s*$/;
185
+ const rows = [];
186
+ for (let i = 0; i < lines.length - 1; i += 1) {
187
+ /* v8 ignore next -- defensive: the loop bound above guarantees lines[i] always exists here. */
188
+ const header = lines[i] ?? "";
189
+ /* v8 ignore next -- defensive: the loop bound above guarantees lines[i + 1] always exists here. */
190
+ const separator = lines[i + 1] ?? "";
191
+ if (!tableRowPattern.test(header) || !isMarkdownTableSeparatorRow(separator))
192
+ continue;
193
+ let j = i + 2;
194
+ /* v8 ignore next -- defensive: the `j < lines.length` guard above guarantees lines[j] always exists here. */
195
+ while (j < lines.length && tableRowPattern.test(lines[j] ?? "")) {
196
+ /* v8 ignore next -- defensive: same loop-bound guarantee as above. */
197
+ const line = lines[j] ?? "";
198
+ const cells = line
199
+ .trim()
200
+ .replace(/^\|/, "")
201
+ .replace(/\|$/, "")
202
+ .split("|")
203
+ .map((cell) => cell.trim());
204
+ rows.push(cells);
205
+ j += 1;
206
+ }
207
+ i = j - 1;
208
+ }
209
+ return rows;
210
+ }
211
+ // Matches EITHER markdown image syntax (`![alt](url)`, optionally with a trailing `"title"`) OR an `<img
212
+ // src="...">` tag, capturing the URL from whichever alternative matched -- covers a bare `![]()` cell and the
213
+ // PR template's own clickable-thumbnail convention (`[![before](url)](url)`, where the OUTER `[...](...)` is
214
+ // the click-through link and this pattern correctly targets the INNER `!`-prefixed image markup instead).
215
+ const CELL_IMAGE_URL_PATTERN = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)|<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/i;
216
+ function extractCellImageUrl(cell) {
217
+ const match = cell.match(CELL_IMAGE_URL_PATTERN);
218
+ if (!match)
219
+ return null;
220
+ /* v8 ignore next -- defensive: whichever alternative of CELL_IMAGE_URL_PATTERN matched always captures a
221
+ * non-empty group (both require at least one non-`)`/non-`"` character), so this fallback is unreachable. */
222
+ return match[1] ?? match[2] ?? null;
223
+ }
224
+ /** The image URLs found in each detected table row (source order), for rows with at least two — a real
225
+ * before/after pair worth comparing, not a single decorative image or caption-only row. Reuses
226
+ * {@link extractTableRows}'s own header+separator detection rather than re-scanning the body. A row with
227
+ * MORE than two images (e.g. a desktop+mobile matrix row) keeps every image; callers that only want a pair
228
+ * slice it themselves. */
229
+ export function extractTableRowImageUrls(body) {
230
+ return extractTableRows(body)
231
+ .map((row) => row.map(extractCellImageUrl).filter((url) => url !== null))
232
+ .filter((urls) => urls.length >= 2);
233
+ }
234
+ /** The full set of (viewport, theme) pairs `config` requires, or `[]` when matrix mode is off. Matrix mode
235
+ * turns on via `requireViewports` alone -- `requireThemes` with an empty `requireViewports` has no effect,
236
+ * since there is no viewport to cross it against. */
237
+ export function requiredScreenshotMatrixPairs(config) {
238
+ if (config.requireViewports.length === 0)
239
+ return [];
240
+ if (config.requireThemes.length === 0)
241
+ return config.requireViewports.map((viewport) => ({ viewport, theme: null }));
242
+ const pairs = [];
243
+ for (const viewport of config.requireViewports) {
244
+ for (const theme of config.requireThemes)
245
+ pairs.push({ viewport, theme });
246
+ }
247
+ return pairs;
248
+ }
249
+ /** True when some row's first cell (the row LABEL, e.g. "Desktop · Light") mentions both `pair.viewport` and
250
+ * `pair.theme` (case-insensitive substring match -- tolerant of whatever separator character the contributor
251
+ * used between them) AND that row has at least two image-bearing cells among the rest (before + after). */
252
+ function rowSatisfiesMatrixPair(row, pair) {
253
+ // `?? ""` only exists to satisfy noUncheckedIndexedAccess -- `extractTableRows`'s `.split("|")` always
254
+ // produces at least one cell, even for an empty-string row, so `row[0]` is never actually undefined here.
255
+ /* v8 ignore next -- defensive: see the comment above. */
256
+ const label = (row[0] ?? "").toLowerCase();
257
+ if (!label.includes(pair.viewport.toLowerCase()))
258
+ return false;
259
+ if (pair.theme !== null && !label.includes(pair.theme.toLowerCase()))
260
+ return false;
261
+ const imageCells = row.slice(1).filter((cell) => IMAGE_CELL_PATTERN.test(cell)).length;
262
+ return imageCells >= 2;
263
+ }
264
+ /** The subset of `pairs` with NO satisfying row anywhere in `body`'s tables. Empty ⇒ full coverage. */
265
+ export function missingScreenshotMatrixPairs(body, pairs) {
266
+ if (pairs.length === 0)
267
+ return [];
268
+ const rows = extractTableRows(body);
269
+ return pairs.filter((pair) => !rows.some((row) => rowSatisfiesMatrixPair(row, pair)));
270
+ }
271
+ function formatMatrixPair(pair) {
272
+ return pair.theme === null ? pair.viewport : `${pair.viewport} · ${pair.theme}`;
273
+ }
274
+ /** Build the rejection reason for a matrix violation, naming exactly which viewport/theme combinations are
275
+ * still missing a real before+after pair -- so the contributor knows precisely what to add, not just that
276
+ * "something" is missing. */
277
+ export function buildScreenshotMatrixMessage(missing) {
278
+ const list = missing.map(formatMatrixPair).join(", ");
279
+ const dimensionLabel = missing.some((pair) => pair.theme !== null) ? "viewport × theme" : "viewport";
280
+ return ("This pull request changes UI/visual code but its screenshot evidence is incomplete. Every required " +
281
+ `${dimensionLabel} combination needs its own before/after image pair in a labeled table row (e.g. ` +
282
+ '"Desktop · Light | before | after"). Still missing: ' +
283
+ `${list}.\n\nPlease resubmit with the remaining rows filled in.`);
284
+ }
285
+ /** Append a contributor skill-file link to an auto-generated rejection message (#4540 follow-up). A no-op
286
+ * when `skillFileUrl` is unset -- callers only reach this on the AUTO-GENERATED path (a `message`
287
+ * override already owns its entire text and is never passed through here). */
288
+ function appendSkillLink(text, skillFileUrl) {
289
+ return skillFileUrl ? `${text}\n\nSee ${skillFileUrl} for the exact format and examples.` : text;
290
+ }
291
+ /** True when the PR is IN SCOPE for the gate: it carries one of `config.whenLabels` OR touches a path matching
292
+ * one of `config.whenPaths`. Both empty ⇒ every PR is in scope (an operator who enables the gate with no
293
+ * scoping at all wants it enforced everywhere). Only one non-empty list configured ⇒ that list alone decides
294
+ * scope (the other, empty list can never exclude a PR the configured one matched). */
295
+ export function isScreenshotTableGateInScope(config, prLabels, changedFiles) {
296
+ if (config.whenLabels.length === 0 && config.whenPaths.length === 0)
297
+ return true;
298
+ const wantedLabels = new Set(config.whenLabels.map((label) => label.toLowerCase()));
299
+ const labelMatch = config.whenLabels.length > 0 && prLabels.some((label) => wantedLabels.has(label.toLowerCase()));
300
+ const pathMatch = config.whenPaths.length > 0 && changedFiles.some((file) => matchesAny(file, config.whenPaths));
301
+ return labelMatch || pathMatch;
302
+ }
303
+ export const DEFAULT_SCREENSHOT_CONTRACT_MESSAGE = "This pull request changes UI/visual code but its description is missing a before/after screenshot table. " +
304
+ "Every changed page/feature needs a **markdown table** with a before column and an after column, each cell a " +
305
+ "clickable thumbnail (uploaded to the PR, not committed to the repo) with a caption below — for example:\n\n" +
306
+ "| Before | After |\n| --- | --- |\n| [![before](url)](url) — caption | [![after](url)](url) — caption |\n\n" +
307
+ "Please resubmit with the table filled in.";
308
+ const NO_VIOLATION = { violated: false, reason: null };
309
+ /** PURE evaluator. Off (`enabled: false`) or out-of-scope (no configured label/path match) ⇒ no violation.
310
+ * `botCaptureSatisfied` ⇒ no violation regardless of mode (an automated capture is equivalent to a
311
+ * hand-authored table, and the bot doesn't (yet) shoot a full viewport/theme matrix -- see #4535's scope note).
312
+ *
313
+ * Two modes, chosen by whether `config.requireViewports` is non-empty (#4535):
314
+ * - MATRIX mode: every required (viewport, theme) pair (`requiredScreenshotMatrixPairs`) must have a labeled
315
+ * before/after row. Violated ⇒ the reason names exactly which pairs are still missing.
316
+ * - PRESENCE mode (the original #2006 behavior, unchanged): in scope AND (no image-bearing table in the body
317
+ * OR an image pasted outside a table OR a committed image file under a scoped path) ⇒ violated, with the
318
+ * configured (or default) templated message as the reason. */
319
+ export function evaluateScreenshotTableGate(input) {
320
+ const { config } = input;
321
+ if (!config.enabled)
322
+ return NO_VIOLATION;
323
+ if (!isScreenshotTableGateInScope(config, input.prLabels, input.changedFiles))
324
+ return NO_VIOLATION;
325
+ if (input.botCaptureSatisfied === true)
326
+ return NO_VIOLATION;
327
+ const matrixPairs = requiredScreenshotMatrixPairs(config);
328
+ if (matrixPairs.length > 0) {
329
+ const missing = missingScreenshotMatrixPairs(input.prBody, matrixPairs);
330
+ if (missing.length === 0)
331
+ return NO_VIOLATION;
332
+ return { violated: true, reason: config.message ?? appendSkillLink(buildScreenshotMatrixMessage(missing), config.skillFileUrl) };
333
+ }
334
+ const hasTable = hasImageBearingMarkdownTable(input.prBody);
335
+ const outsideTable = hasImageOutsideTable(input.prBody);
336
+ const committedImage = hasCommittedImageFile(input.changedFiles, config.whenPaths);
337
+ if (hasTable && !outsideTable && !committedImage)
338
+ return NO_VIOLATION;
339
+ return { violated: true, reason: config.message ?? appendSkillLink(DEFAULT_SCREENSHOT_CONTRACT_MESSAGE, config.skillFileUrl) };
340
+ }
@@ -0,0 +1,9 @@
1
+ import type { UnlinkedIssueGuardrailConfig, UnlinkedIssueGuardrailMode } from "../types/manifest-deps-types.js";
2
+ export declare const DEFAULT_UNLINKED_ISSUE_GUARDRAIL: UnlinkedIssueGuardrailConfig;
3
+ export declare function isUnlinkedIssueGuardrailMode(value: unknown): value is UnlinkedIssueGuardrailMode;
4
+ /**
5
+ * Normalize a raw `.gittensory.yml settings.unlinkedIssueGuardrail` value into a typed config,
6
+ * fail-safe: any malformed field falls back to its own default and pushes a warning rather than
7
+ * rejecting the whole block. Mirrors `normalizeLinkedIssueHardRulesConfig`'s per-field discipline.
8
+ */
9
+ export declare function normalizeUnlinkedIssueGuardrailConfig(input: unknown, warnings: string[]): UnlinkedIssueGuardrailConfig;
@@ -0,0 +1,44 @@
1
+ const VALID_UNLINKED_ISSUE_GUARDRAIL_MODES = ["hold", "off"];
2
+ const DEFAULT_MIN_CONFIDENCE = 0.85;
3
+ export const DEFAULT_UNLINKED_ISSUE_GUARDRAIL = {
4
+ mode: "off",
5
+ minConfidence: DEFAULT_MIN_CONFIDENCE,
6
+ };
7
+ export function isUnlinkedIssueGuardrailMode(value) {
8
+ return typeof value === "string" && VALID_UNLINKED_ISSUE_GUARDRAIL_MODES.includes(value);
9
+ }
10
+ function normalizeMode(value, warnings) {
11
+ if (value === undefined)
12
+ return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode;
13
+ if (isUnlinkedIssueGuardrailMode(value))
14
+ return value;
15
+ warnings.push(`settings.unlinkedIssueGuardrail.mode must be one of hold, off; using the default "${DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode}".`);
16
+ return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode;
17
+ }
18
+ function normalizeMinConfidence(value, warnings) {
19
+ if (value === undefined)
20
+ return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.minConfidence;
21
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
22
+ warnings.push(`settings.unlinkedIssueGuardrail.minConfidence must be a number between 0 and 1; using the default "${DEFAULT_MIN_CONFIDENCE}".`);
23
+ return DEFAULT_MIN_CONFIDENCE;
24
+ }
25
+ return value;
26
+ }
27
+ /**
28
+ * Normalize a raw `.gittensory.yml settings.unlinkedIssueGuardrail` value into a typed config,
29
+ * fail-safe: any malformed field falls back to its own default and pushes a warning rather than
30
+ * rejecting the whole block. Mirrors `normalizeLinkedIssueHardRulesConfig`'s per-field discipline.
31
+ */
32
+ export function normalizeUnlinkedIssueGuardrailConfig(input, warnings) {
33
+ if (input === undefined)
34
+ return { ...DEFAULT_UNLINKED_ISSUE_GUARDRAIL };
35
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
36
+ warnings.push("settings.unlinkedIssueGuardrail must be an object; using the default off policy.");
37
+ return { ...DEFAULT_UNLINKED_ISSUE_GUARDRAIL };
38
+ }
39
+ const record = input;
40
+ return {
41
+ mode: normalizeMode(record.mode, warnings),
42
+ minConfidence: normalizeMinConfidence(record.minConfidence, warnings),
43
+ };
44
+ }