@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,2730 @@
1
+ /**
2
+ * Focus-manifest parse/compile core (#2280). Extracted to `@loopover/engine` so the maintainer
3
+ * review stack and the miner's goal-spec parser share identical, versioned manifest logic instead of drifting
4
+ * apart. This is the MINER-side parse-pattern template for {@link MinerGoalSpec} (`.gittensory-miner.yml`) —
5
+ * same tolerant-parser shape: typed config + safe defaults + warnings, never throws.
6
+ *
7
+ * App-local resolver/guidance functions (`resolveEffectiveSettings`, `buildFocusManifestGuidance`, etc.) remain
8
+ * in `src/signals/focus-manifest.ts` as a shim over this module.
9
+ */
10
+ import { parse as parseYaml } from "yaml";
11
+ import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "./settings/autonomy.js";
12
+ import { normalizeCommandAuthorizationPolicy } from "./settings/command-authorization.js";
13
+ import { normalizeContributorBlacklist } from "./settings/contributor-blacklist.js";
14
+ import { normalizeAutoCloseExemptLogins } from "./settings/auto-close-exempt.js";
15
+ import { MAX_TYPE_LABEL_NAME_LENGTH, normalizeTypeLabelSet } from "./settings/pr-type-label.js";
16
+ import { normalizeLinkedIssueLabelPropagationConfig, VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES, } from "./review/linked-issue-label-propagation.js";
17
+ import { isLinkedIssueHardRuleMode, normalizeLinkedIssueHardRulesConfig, } from "./review/linked-issue-hard-rules-config.js";
18
+ import { isUnlinkedIssueGuardrailMode, normalizeUnlinkedIssueGuardrailConfig, } from "./review/unlinked-issue-guardrail-config.js";
19
+ import { normalizeAdvisoryAiRoutingConfig } from "./review/advisory-ai-routing-config.js";
20
+ import { isScreenshotTableGateAction, normalizeScreenshotTableGateConfig, } from "./review/screenshot-table-gate.js";
21
+ import { normalizeModerationLabel, normalizeModerationRules } from "./settings/moderation-rules.js";
22
+ import { REES_ANALYZER_NAME_SET } from "./review/enrichment-analyzer-names.js";
23
+ import { hasUnsafeWildcardCount } from "./signals/change-guardrail.js";
24
+ import { isSafeHttpUrl } from "./review/safe-url.js";
25
+ /** Canonical local-filesystem-root vocabulary for public-safety filtering (from `src/signals/redaction.ts`). */
26
+ const PUBLIC_LOCAL_PATH_INLINE = String.raw `/Users/|/home/|/root/|/var/|/opt/|/tmp/|/private/|[A-Za-z]:[\\/]Users[\\/]|[A-Za-z]:[\\/]Program Files[\\/]`;
27
+ // The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private
28
+ // `.gittensory.yml`. Each feature ALSO has a GLOBAL env flag (GITTENSORY_REVIEW_*) that stays a master
29
+ // kill-switch (the feature never runs when its env flag is off, regardless of this block). See
30
+ // review/feature-activation.ts for the resolver (env kill-switch → per-repo override → env-allowlist default).
31
+ // NOTE: only the per-PR REVIEW features whose every activation site is migrated are listed here. grounding
32
+ // (#4100) is now migrated too — its original "coupled to the merge/close DISPOSITION path" blocker was the
33
+ // removed AI CI-refutation path (grounding-wire.ts's aiCiRefutationActive is now a vestigial historical-
34
+ // compatibility helper with zero real callers); grounding today only shapes reviewer PROMPT content, same
35
+ // shape as rag/reputation. contentLane got its own richer `contentLane:` block below (#2435) instead of a
36
+ // boolean here, since it resolves to a whole RegistryLaneSpec, not an on/off toggle — see
37
+ // resolveRegistryLaneSpec in review/content-lane/spec-resolver.ts (its own precedence already matches this
38
+ // block's env-kill-switch → override → allowlist-default shape one-for-one; it just isn't literally routed
39
+ // through resolveConvergedFeature yet — a disclosed, low-priority fast-follow, #4616). `selftune` (#4104)
40
+ // ALSO deliberately lives outside this block, as its own top-level `review.selftune` field below — it has no
41
+ // `GITTENSORY_REVIEW_REPOS` allowlist to fall back to (its own repo scoping is `isAgentConfigured`, a
42
+ // different consent boundary), so it doesn't fit this resolver's env-kill-switch → override → allowlist-
43
+ // default shape; see `selfTuneRepos` in `review/selftune-wire.ts`. `e2eTests` (#4190, part of the #4189
44
+ // E2E-test-generation epic) fits this shape exactly as a plain symmetric override — unlike `safety`/
45
+ // `grounding` it has no force-on-only or force-off-only floor/ceiling, since AI-generated test content
46
+ // carries no security-hardening or full-file-fetch rationale to protect from a repo-controlled override.
47
+ // `screenshots` (#4616) joined this block for the SAME reason `e2eTests` fits it plainly: capturing a
48
+ // before/after render of the PR's own web-visible files carries no security-hardening or full-file-fetch
49
+ // rationale either, so it gets the standard override, not an asymmetric one. Before #4616 it had NO
50
+ // `features:` override at all (env flag AND allowlist only) despite being documented right next to its six
51
+ // siblings in `.gittensory.yml.example` — a self-hoster who guessed `features.screenshots: true` (a natural
52
+ // guess given the sibling keys) found it silently did nothing. `features.screenshots` is layered UNDER the
53
+ // separate, richer `review.visual.*` block (route/preview-URL config, #3609/#3610, and `review.visual.enabled:
54
+ // false` as an always-available additional force-off, #4083) — that block still narrows/disables capture
55
+ // AFTER this key decides whether capture is even attempted for the repo at all; the two are independent and
56
+ // `review.visual.enabled` keeps its own existing force-off-only semantics untouched by this change.
57
+ // `improvementSignal` (#4738, foundation phase of the #4737 PR-improvement-signal epic) is likewise a plain
58
+ // symmetric override: it is a READ-ONLY advisory quality-delta signal, not a security control, so a repo-
59
+ // level `false` behaves like any other plain override with no floor/ceiling. This is activation wiring only
60
+ // -- no tier reads the resolved value yet (sibling sub-issues #4739-#4746 build the deterministic/LLM/panel
61
+ // behavior that will gate on it).
62
+ export const CONVERGED_FEATURE_KEYS = ["rag", "reputation", "unifiedComment", "safety", "grounding", "e2eTests", "screenshots", "improvementSignal"];
63
+ /** Optional ecosystem/network integrations under the `experimental:` block — plugins that couple gittensory to
64
+ * an external system rather than core review behavior. Starts with `gittensor` (the subnet mining-registry/
65
+ * scoring integration gittensory originally shipped with); future plugins land in this same array as the
66
+ * product broadens beyond gittensor. Deliberately a SEPARATE block from `features:` (converged review
67
+ * capabilities) — an operator (especially self-host) should be able to see at a glance which toggles are "how
68
+ * gittensory reviews PRs" vs "which external network/ecosystem this instance opts into." */
69
+ export const EXPERIMENTAL_PLUGIN_KEYS = ["gittensor"];
70
+ /** Field keys for the public review-panel rows a maintainer can show/hide via `review.fields`. `improvementSignal`
71
+ * (#4744) is the newest: the optional row combining the deterministic structural-improvement tier (#4742) and,
72
+ * where also active, the LLM tier's composed value judgment (#4743) -- default-shown, like every sibling here,
73
+ * when the `improvementSignal` converged feature itself is active for the repo. */
74
+ export const REVIEW_FIELD_KEYS = ["linkedIssue", "relatedWork", "reviewLoad", "validationEvidence", "openPrQueue", "contributorContext", "gateResult", "improvementSignal"];
75
+ // `review.profile` (#review-profile): how nitpicky the AI maintainer review is. `chill` = surface only blocking
76
+ // defects (bugs/security/breakage), suppress style nits; `assertive` = also raise minor improvements & nits;
77
+ // `balanced` (default / absent) leaves the reviewer prompt byte-identical. A presentation knob only — it NEVER
78
+ // changes the gate verdict, only how much advisory detail the review write-up carries.
79
+ export const REVIEW_PROFILES = ["chill", "balanced", "assertive"];
80
+ export const REVIEW_FINDING_SEVERITY_LADDER = ["critical", "major", "minor", "nitpick"];
81
+ /** `review.linkedIssueSatisfaction` modes (#2173). `off` = not evaluated (same as unset). */
82
+ export const LINKED_ISSUE_SATISFACTION_MODES = ["off", "advisory", "block"];
83
+ /** `review.comment_verbosity` levels (#2047). `normal` = today's behavior (same as unset). */
84
+ export const COMMENT_VERBOSITY_LEVELS = ["quiet", "normal", "detailed"];
85
+ /** `review.e2e_test_delivery` modes (#4197). `comment` = today's behavior (same as unset). */
86
+ export const E2E_TEST_DELIVERY_MODES = ["comment", "commit"];
87
+ /** `review.auto_review.cadence` (#one-shot-review-cadence). `one_shot` = the AI-generated content (main review,
88
+ * slop advisory, linked-issue satisfaction) is produced once per PR and never automatically regenerated
89
+ * afterward — not on a new push, not on CI-check completion, not on a scheduled sweep tick; only an explicit
90
+ * maintainer retrigger (the PR-panel checkbox or `@gittensory review` as a maintainer) spends a fresh call.
91
+ * `continuous` = the traditional behavior — every trigger re-runs AI content generation, subject to each
92
+ * feature's own head-SHA cache. Orthogonal to `aiReviewMode`'s enforcement-strictness axis (off/advisory/
93
+ * block) — the deterministic gate (CI status, mergeability, static-rule blockers) is NEVER affected by this
94
+ * and always re-evaluates on every pass regardless of cadence. */
95
+ export const AI_REVIEW_CADENCES = ["one_shot", "continuous"];
96
+ export const EMPTY_MAX_FINDINGS_CONFIG = { blockers: null, nits: null };
97
+ export const EMPTY_AUTO_REVIEW_CONFIG = {
98
+ skipDrafts: null,
99
+ cadence: null,
100
+ ignoreAuthors: [],
101
+ ignoreTitleKeywords: [],
102
+ skipLabels: [],
103
+ skipDocsOnly: null,
104
+ maxAddedLines: 0,
105
+ maxFiles: 0,
106
+ baseBranches: [],
107
+ autoPauseAfterReviewedCommits: null,
108
+ };
109
+ export const EMPTY_SELF_HOST_AI_MODEL_CONFIG = {
110
+ claudeModel: null,
111
+ claudeEffort: null,
112
+ codexModel: null,
113
+ codexEffort: null,
114
+ ollamaModel: null,
115
+ openaiModel: null,
116
+ openaiCompatibleModel: null,
117
+ anthropicModel: null,
118
+ };
119
+ export const EMPTY_VISUAL_CONFIG = {
120
+ productionUrl: null,
121
+ preview: { urlTemplate: null },
122
+ routes: { paths: [], maxRoutes: null },
123
+ themes: [],
124
+ gif: false,
125
+ enabled: null,
126
+ themeStorageKey: null,
127
+ actionsFallback: false,
128
+ };
129
+ // A hard cap so a hostile/huge manifest can't bloat the reviewer prompt (mirrors REVIEW_FIELD_KEYS discipline).
130
+ const MAX_PATH_INSTRUCTIONS = 50;
131
+ const MAX_LIST_ITEMS = 200;
132
+ const MAX_ITEM_LENGTH = 300;
133
+ const MAX_GLOBSTAR_SLASH_ALTERNATIVES = 128;
134
+ // 128 KiB, not 64 KiB: gittensory.full.yml (our own reference doc, parsed by config-templates.test.ts as a
135
+ // round-trip check) organically grows every time a new review.* knob ships and had already reached 65522/65536
136
+ // bytes on main before this comment was written -- one doc line from any PR would trip the old ceiling. A real
137
+ // per-repo .gittensory.yml never needs anywhere near this size, so the DoS-guard intent is unaffected (#2006).
138
+ export const MAX_FOCUS_MANIFEST_BYTES = 128 * 1024;
139
+ const EMPTY_GATE_CONFIG = {
140
+ present: false,
141
+ enabled: null,
142
+ checkMode: null,
143
+ pack: null,
144
+ linkedIssue: null,
145
+ duplicates: null,
146
+ readinessMode: null,
147
+ readinessMinScore: null,
148
+ slopMode: null,
149
+ slopMinScore: null,
150
+ slopAiAdvisory: null,
151
+ sizeMode: null,
152
+ lockfileIntegrityMode: null,
153
+ aiReviewMode: null,
154
+ aiReviewByok: null,
155
+ aiReviewProvider: null,
156
+ aiReviewModel: null,
157
+ aiReviewAllAuthors: null,
158
+ aiReviewCloseConfidence: null,
159
+ aiReviewLowConfidenceDisposition: null,
160
+ aiReviewCombine: null,
161
+ aiReviewOnMerge: null,
162
+ aiReviewReviewers: null,
163
+ mergeReadiness: null,
164
+ manifestPolicy: null,
165
+ selfAuthoredLinkedIssue: null,
166
+ linkedIssueSatisfaction: null,
167
+ dryRun: null,
168
+ firstTimeContributorGrace: null,
169
+ premergeContentRecheck: null,
170
+ requireFreshRebaseWindowMinutes: null,
171
+ claMode: null,
172
+ claConsentPhrase: null,
173
+ claCheckRunName: null,
174
+ claCheckRunAppSlug: null,
175
+ expectedCiContexts: null,
176
+ aiJudgmentBlockersMode: null,
177
+ copycatMode: null,
178
+ copycatMinScore: null,
179
+ };
180
+ const EMPTY_FEATURES_CONFIG = {
181
+ present: false,
182
+ rag: null,
183
+ reputation: null,
184
+ unifiedComment: null,
185
+ safety: null,
186
+ grounding: null,
187
+ e2eTests: null,
188
+ screenshots: null,
189
+ improvementSignal: null,
190
+ };
191
+ const EMPTY_EXPERIMENTAL_CONFIG = {
192
+ present: false,
193
+ gittensor: null,
194
+ };
195
+ const EMPTY_CONTENT_LANE_CONFIG = {
196
+ present: false,
197
+ entryFileGlob: null,
198
+ providerFileGlob: null,
199
+ artifactGlob: null,
200
+ collectionField: null,
201
+ maxAppendedEntries: null,
202
+ duplicateKeyFields: [],
203
+ validatorId: null,
204
+ };
205
+ const DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS = 7;
206
+ const EMPTY_REPO_DOC_GENERATION_CONFIG = {
207
+ present: false,
208
+ enabled: false,
209
+ scope: ["agents"],
210
+ allowOverwriteExisting: false,
211
+ refreshIntervalDays: DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS,
212
+ };
213
+ const DEFAULT_REVIEW_RECAP_CADENCE_DAYS = 7;
214
+ const EMPTY_REVIEW_RECAP_CONFIG = {
215
+ present: false,
216
+ enabled: false,
217
+ cadenceDays: DEFAULT_REVIEW_RECAP_CADENCE_DAYS,
218
+ };
219
+ const DEFAULT_MAINTAINER_RECAP_CADENCE = "weekly";
220
+ const DEFAULT_MAINTAINER_RECAP_CHANNEL = "discord";
221
+ const EMPTY_MAINTAINER_RECAP_CONFIG = {
222
+ present: false,
223
+ enabled: false,
224
+ cadence: DEFAULT_MAINTAINER_RECAP_CADENCE,
225
+ channel: DEFAULT_MAINTAINER_RECAP_CHANNEL,
226
+ };
227
+ const EMPTY_MANIFEST = {
228
+ present: false,
229
+ source: "none",
230
+ wantedPaths: [],
231
+ preferredLabels: [],
232
+ linkedIssuePolicy: "optional",
233
+ testExpectations: [],
234
+ issueDiscoveryPolicy: "neutral",
235
+ maintainerNotes: [],
236
+ publicNotes: [],
237
+ gate: { ...EMPTY_GATE_CONFIG },
238
+ settings: {},
239
+ review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null },
240
+ features: { ...EMPTY_FEATURES_CONFIG },
241
+ experimental: { ...EMPTY_EXPERIMENTAL_CONFIG },
242
+ contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
243
+ repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
244
+ reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG },
245
+ maintainerRecap: { ...EMPTY_MAINTAINER_RECAP_CONFIG },
246
+ warnings: [],
247
+ };
248
+ // This surface's economic/identity term vocabulary is intentionally richer than the canonical
249
+ // PUBLIC_UNSAFE_TERMS (extra phrases like "public score estimate"), so it stays a local literal. The local
250
+ // filesystem paths, however, compose from the canonical PUBLIC_LOCAL_PATH_INLINE in redaction.ts (which also
251
+ // covers `/var/`, previously missed here, plus `/root/` and the forward-slash Windows form `C:/Users/`) so this
252
+ // guard cannot drift from the canonical boundary on a leaking root.
253
+ const FOCUS_MANIFEST_TERMS = /\b(reward\w*|score\w*|wallets?|hotkeys?|coldkeys?|seed[-\s]?phrases?|mnemonics?|private[-\s]?keys?|farming|payouts?|rankings?|raw[-\s]?trust(?:[-\s]?scores?)?|trust[-\s]?scores?|private[-\s]?reviewability|reviewability(?:[-\s]?internals?)?|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)s?|estimated[-\s]?scores?|score[-\s]?(?:estimate|prediction|preview)s?)\b/i;
254
+ const FOCUS_MANIFEST_LOCAL_PATH_PATTERN = new RegExp(PUBLIC_LOCAL_PATH_INLINE, "i");
255
+ /**
256
+ * Public-safe redaction guard shared with the local-branch packet renderer. Public manifest
257
+ * text must not leak reward, wallet/key, ranking, or local filesystem path material.
258
+ */
259
+ export function isFocusManifestPublicSafe(text) {
260
+ return !FOCUS_MANIFEST_TERMS.test(text) && !FOCUS_MANIFEST_LOCAL_PATH_PATTERN.test(text);
261
+ }
262
+ function emptyManifest(source, warnings = []) {
263
+ return {
264
+ ...EMPTY_MANIFEST,
265
+ source,
266
+ warnings,
267
+ gate: { ...EMPTY_GATE_CONFIG },
268
+ settings: {},
269
+ review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null },
270
+ features: { ...EMPTY_FEATURES_CONFIG },
271
+ experimental: { ...EMPTY_EXPERIMENTAL_CONFIG },
272
+ contentLane: { ...EMPTY_CONTENT_LANE_CONFIG },
273
+ repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG },
274
+ reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG },
275
+ maintainerRecap: { ...EMPTY_MAINTAINER_RECAP_CONFIG },
276
+ };
277
+ }
278
+ function normalizeStringList(value, field, warnings) {
279
+ if (value === undefined || value === null)
280
+ return [];
281
+ if (!Array.isArray(value)) {
282
+ warnings.push(`Manifest field "${field}" must be a list; ignoring a ${typeof value} value.`);
283
+ return [];
284
+ }
285
+ const result = [];
286
+ for (const entry of value) {
287
+ if (typeof entry !== "string") {
288
+ warnings.push(`Manifest field "${field}" skipped a non-string entry.`);
289
+ continue;
290
+ }
291
+ const trimmed = entry.trim();
292
+ if (!trimmed)
293
+ continue;
294
+ // Truncate in place, then flow through the same de-dup and cap logic. Falling through (rather than
295
+ // `continue`-ing) keeps over-long entries subject to both limits, so untrusted manifests cannot
296
+ // bypass de-duplication or the MAX_LIST_ITEMS safety cap via pathological long entries.
297
+ let normalized = trimmed;
298
+ if (normalized.length > MAX_ITEM_LENGTH) {
299
+ warnings.push(`Manifest field "${field}" truncated an over-long entry.`);
300
+ normalized = normalized.slice(0, MAX_ITEM_LENGTH);
301
+ }
302
+ if (!result.includes(normalized))
303
+ result.push(normalized);
304
+ if (result.length >= MAX_LIST_ITEMS) {
305
+ warnings.push(`Manifest field "${field}" exceeded ${MAX_LIST_ITEMS} entries; extra entries ignored.`);
306
+ break;
307
+ }
308
+ }
309
+ return result;
310
+ }
311
+ /** Like {@link normalizeStringList}, but returns `null` (not `[]`) when unset or when nothing survives
312
+ * validation — the convention every OTHER `FocusManifestGateConfig` field uses for "not configured", so
313
+ * the resolver's `!== null` overlay checks work uniformly. */
314
+ function normalizeOptionalStringList(value, field, warnings) {
315
+ if (value === undefined || value === null)
316
+ return null;
317
+ const list = normalizeStringList(value, field, warnings);
318
+ return list.length > 0 ? list : null;
319
+ }
320
+ function normalizeEnum(value, field, allowed, fallback, warnings) {
321
+ if (value === undefined || value === null)
322
+ return fallback;
323
+ if (typeof value !== "string" || !allowed.includes(value)) {
324
+ warnings.push(`Manifest field "${field}" must be one of ${allowed.join(", ")}; falling back to "${fallback}".`);
325
+ return fallback;
326
+ }
327
+ return value;
328
+ }
329
+ function normalizeSource(raw, value, warnings) {
330
+ if (raw)
331
+ return raw;
332
+ return normalizeEnum(value, "source", ["repo_file", "api_record", "none"], "api_record", warnings);
333
+ }
334
+ function normalizeOptionalGateMode(value, field, warnings) {
335
+ if (value === undefined || value === null)
336
+ return null;
337
+ if (typeof value === "string") {
338
+ const normalized = value.trim().toLowerCase();
339
+ if (normalized === "off" || normalized === "advisory" || normalized === "block")
340
+ return normalized;
341
+ }
342
+ warnings.push(`Manifest gate field "${field}" must be one of off, advisory, block; ignoring "${String(value)}".`);
343
+ return null;
344
+ }
345
+ /** `gate.readiness.mode` (and its `settings.qualityGateMode` alias below) is documented and parsed as the shared
346
+ * off/advisory/block tri-state, but buildQualityGateWarning (src/rules/advisory.ts) always produces a
347
+ * warning-severity finding — never a blocker — and isConfiguredGateBlocker has no branch for it: readiness/
348
+ * quality is intentionally informational-only and can never hard-block a PR. Without this, a maintainer who
349
+ * sets `mode: block` believes a real quality floor is enforced when the effective behavior is silently
350
+ * advisory-only (#2267). Downgrade "block" to "advisory" here, with a clear deprecation warning, so the parsed
351
+ * config always matches what the gate actually does. Exported so the settings-write API routes (the
352
+ * dashboard/API path for the SAME `qualityGateMode` field) can apply the identical downgrade before persisting. */
353
+ export function normalizeReadinessGateMode(value, field, warnings) {
354
+ const mode = normalizeOptionalGateMode(value, field, warnings);
355
+ if (mode !== "block")
356
+ return mode;
357
+ warnings.push(`Manifest gate field "${field}" no longer accepts "block" — readiness/quality is informational-only and can never hard-block a PR; downgrading to "advisory". Use gate.manifestPolicy or another enforceable gate for a real quality floor.`);
358
+ return "advisory";
359
+ }
360
+ function normalizeOptionalBoolean(value, field, warnings) {
361
+ if (value === undefined || value === null)
362
+ return null;
363
+ if (typeof value === "boolean")
364
+ return value;
365
+ warnings.push(`Manifest gate field "${field}" must be a boolean; ignoring a ${typeof value} value.`);
366
+ return null;
367
+ }
368
+ function normalizeOptionalScore(value, field, warnings) {
369
+ if (value === undefined || value === null)
370
+ return null;
371
+ if (typeof value !== "number" || !Number.isFinite(value)) {
372
+ warnings.push(`Manifest gate field "${field}" must be a number between 0 and 100; ignoring it.`);
373
+ return null;
374
+ }
375
+ return Math.max(0, Math.min(100, Math.round(value)));
376
+ }
377
+ function normalizeOptionalNonNegativeInt(value, field, warnings) {
378
+ if (value === undefined || value === null)
379
+ return null;
380
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
381
+ warnings.push(`Manifest field "${field}" must be a non-negative integer; ignoring it.`);
382
+ return null;
383
+ }
384
+ return value;
385
+ }
386
+ /** Parse auto-review size caps where 0 means disabled (byte-identical default). (#2065) */
387
+ function normalizeAutoReviewSizeCap(value, field, warnings) {
388
+ if (value === undefined || value === null)
389
+ return 0;
390
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
391
+ warnings.push(`Manifest field "${field}" must be a non-negative integer; ignoring it.`);
392
+ return 0;
393
+ }
394
+ return value;
395
+ }
396
+ /** Normalize an optional confidence threshold in [0,1] (#7) — a fractional value (NOT a 0-100 score), so it is
397
+ * clamped into range WITHOUT rounding. Absent/null ⇒ null (the resolver leaves the gate's 0.93 default in place);
398
+ * a non-finite/non-number value is ignored with a warning. */
399
+ function normalizeOptionalConfidence(value, field, warnings) {
400
+ if (value === undefined || value === null)
401
+ return null;
402
+ if (typeof value !== "number" || !Number.isFinite(value)) {
403
+ warnings.push(`Manifest gate field "${field}" must be a number between 0 and 1; ignoring it.`);
404
+ return null;
405
+ }
406
+ return Math.max(0, Math.min(1, value));
407
+ }
408
+ // A hard cap on `gate.aiReview.reviewers` entries — the combiner only ever addresses reviewer[0]/[1] (single runs
409
+ // one, consensus/synthesis run two), so anything beyond 2 is inert; capping at 4 leaves headroom without letting a
410
+ // hostile/huge manifest bloat the parsed config for no functional gain.
411
+ const MAX_AI_REVIEW_REVIEWERS = 4;
412
+ /** Normalize `gate.aiReview.reviewers` (#2567) — a list of `{ model, fallback? }` entries naming self-host
413
+ * providers (e.g. `claude-code`, `codex`) to run in place of the operator's `AI_REVIEW_PLAN.reviewers`. Each
414
+ * entry needs a non-empty string `model`; `fallback` is optional and, when present, must also be a non-empty
415
+ * string. Invalid entries are dropped with a warning rather than failing the whole list, mirroring the other
416
+ * manifest list parsers. Absent/empty/all-invalid ⇒ null (so the resolver's `??` fallback to the operator's
417
+ * plan is untouched). */
418
+ function normalizeOptionalReviewers(value, field, warnings) {
419
+ if (value === undefined || value === null)
420
+ return null;
421
+ if (!Array.isArray(value)) {
422
+ warnings.push(`Manifest gate field "${field}" must be a list of { model, fallback? }; ignoring it.`);
423
+ return null;
424
+ }
425
+ const out = [];
426
+ for (const [index, entry] of value.entries()) {
427
+ if (out.length >= MAX_AI_REVIEW_REVIEWERS) {
428
+ warnings.push(`Manifest gate field "${field}" is capped at ${MAX_AI_REVIEW_REVIEWERS} entries; dropping the rest.`);
429
+ break;
430
+ }
431
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
432
+ warnings.push(`Manifest gate field "${field}[${index}]" must be a mapping with a "model" string; ignoring it.`);
433
+ continue;
434
+ }
435
+ const e = entry;
436
+ const model = typeof e.model === "string" ? e.model.trim() : "";
437
+ if (!model) {
438
+ warnings.push(`Manifest gate field "${field}[${index}].model" must be a non-empty string; ignoring the entry.`);
439
+ continue;
440
+ }
441
+ const fallback = typeof e.fallback === "string" && e.fallback.trim() ? e.fallback.trim() : undefined;
442
+ out.push(fallback ? { model, fallback } : { model });
443
+ }
444
+ return out.length > 0 ? out : null;
445
+ }
446
+ /**
447
+ * Parse the optional `gate:` mapping. Every field stays `null` when unset so the resolver can layer
448
+ * this OVER DB settings without clobbering. A nested `readiness: { mode, minScore }` block is accepted.
449
+ */
450
+ function parseGateConfig(value, warnings) {
451
+ if (value === undefined || value === null)
452
+ return { ...EMPTY_GATE_CONFIG };
453
+ if (typeof value !== "object" || Array.isArray(value)) {
454
+ warnings.push(`Manifest field "gate" must be a mapping; ignoring it.`);
455
+ return { ...EMPTY_GATE_CONFIG };
456
+ }
457
+ const record = value;
458
+ const readiness = record.readiness;
459
+ const readinessRecord = readiness !== null && typeof readiness === "object" && !Array.isArray(readiness) ? readiness : undefined;
460
+ if (readiness !== undefined && readiness !== null && readinessRecord === undefined) {
461
+ warnings.push(`Manifest gate field "gate.readiness" must be a mapping; ignoring it.`);
462
+ }
463
+ const aiReview = record.aiReview;
464
+ const aiReviewRecord = aiReview !== null && typeof aiReview === "object" && !Array.isArray(aiReview) ? aiReview : undefined;
465
+ if (aiReview !== undefined && aiReview !== null && aiReviewRecord === undefined) {
466
+ warnings.push(`Manifest gate field "gate.aiReview" must be a mapping; ignoring it.`);
467
+ }
468
+ const slop = record.slop;
469
+ const slopRecord = slop !== null && typeof slop === "object" && !Array.isArray(slop) ? slop : undefined;
470
+ if (slop !== undefined && slop !== null && slopRecord === undefined) {
471
+ warnings.push(`Manifest gate field "gate.slop" must be a mapping; ignoring it.`);
472
+ }
473
+ const copycat = record.copycat;
474
+ const copycatRecord = copycat !== null && typeof copycat === "object" && !Array.isArray(copycat) ? copycat : undefined;
475
+ if (copycat !== undefined && copycat !== null && copycatRecord === undefined) {
476
+ warnings.push(`Manifest gate field "gate.copycat" must be a mapping; ignoring it.`);
477
+ }
478
+ const size = record.size;
479
+ const sizeRecord = size !== null && typeof size === "object" && !Array.isArray(size) ? size : undefined;
480
+ if (size !== undefined && size !== null && sizeRecord === undefined) {
481
+ warnings.push(`Manifest gate field "gate.size" must be a mapping; ignoring it.`);
482
+ }
483
+ const cla = record.cla;
484
+ const claRecord = cla !== null && typeof cla === "object" && !Array.isArray(cla) ? cla : undefined;
485
+ if (cla !== undefined && cla !== null && claRecord === undefined) {
486
+ warnings.push(`Manifest gate field "gate.cla" must be a mapping; ignoring it.`);
487
+ }
488
+ const gate = {
489
+ present: false,
490
+ enabled: normalizeOptionalBoolean(record.enabled, "gate.enabled", warnings),
491
+ checkMode: normalizeOptionalEnum(record.checkMode, "gate.checkMode", ["required", "visible", "disabled"], warnings),
492
+ pack: normalizeOptionalEnum(record.pack, "gate.pack", ["gittensor", "oss-anti-slop"], warnings),
493
+ linkedIssue: normalizeOptionalGateMode(record.linkedIssue, "gate.linkedIssue", warnings),
494
+ duplicates: normalizeOptionalGateMode(record.duplicates, "gate.duplicates", warnings),
495
+ readinessMode: normalizeReadinessGateMode(readinessRecord?.mode, "gate.readiness.mode", warnings),
496
+ readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings),
497
+ slopMode: normalizeOptionalGateMode(slopRecord?.mode, "gate.slop.mode", warnings),
498
+ slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings),
499
+ slopAiAdvisory: normalizeOptionalBoolean(slopRecord?.aiAdvisory, "gate.slop.aiAdvisory", warnings),
500
+ sizeMode: normalizeOptionalGateMode(sizeRecord?.mode, "gate.size.mode", warnings),
501
+ lockfileIntegrityMode: normalizeOptionalGateMode(record.lockfileIntegrity, "gate.lockfileIntegrity", warnings),
502
+ aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings),
503
+ aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings),
504
+ aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"], warnings),
505
+ aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings),
506
+ aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings),
507
+ aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings),
508
+ aiReviewLowConfidenceDisposition: normalizeOptionalEnum(aiReviewRecord?.lowConfidenceDisposition, "gate.aiReview.lowConfidenceDisposition", ["one_shot", "hold_for_review", "advisory_only"], warnings),
509
+ aiReviewCombine: normalizeOptionalEnum(aiReviewRecord?.combine, "gate.aiReview.combine", ["single", "consensus", "synthesis"], warnings),
510
+ aiReviewOnMerge: normalizeOptionalEnum(aiReviewRecord?.onMerge, "gate.aiReview.onMerge", ["either", "both"], warnings),
511
+ aiReviewReviewers: normalizeOptionalReviewers(aiReviewRecord?.reviewers, "gate.aiReview.reviewers", warnings),
512
+ mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings),
513
+ manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings),
514
+ selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings),
515
+ linkedIssueSatisfaction: normalizeOptionalGateMode(record.linkedIssueSatisfaction, "gate.linkedIssueSatisfaction", warnings),
516
+ dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings),
517
+ firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings),
518
+ premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings),
519
+ requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings),
520
+ claMode: normalizeOptionalGateMode(record.claMode, "gate.claMode", warnings),
521
+ claConsentPhrase: parsePublicSafeText(claRecord?.consentPhrase, "gate.cla.consentPhrase", warnings),
522
+ claCheckRunName: parsePublicSafeText(claRecord?.checkRunName, "gate.cla.checkRunName", warnings),
523
+ claCheckRunAppSlug: parsePublicSafeText(claRecord?.checkRunAppSlug, "gate.cla.checkRunAppSlug", warnings),
524
+ expectedCiContexts: normalizeOptionalStringList(record.expectedCiContexts, "gate.expectedCiContexts", warnings),
525
+ aiJudgmentBlockersMode: normalizeOptionalEnum(record.aiJudgmentBlockers, "gate.aiJudgmentBlockers", ["gate", "advisory"], warnings),
526
+ copycatMode: normalizeOptionalEnum(copycatRecord?.mode, "gate.copycat.mode", ["off", "warn", "label", "block"], warnings),
527
+ copycatMinScore: normalizeOptionalScore(copycatRecord?.minScore, "gate.copycat.minScore", warnings),
528
+ };
529
+ // #2266: the flag is parsed, clamped, and threaded end-to-end, but the gate evaluator never reads it — a
530
+ // maintainer who sets it to true believing it softens a blocker for newcomers gets no such effect. Surface
531
+ // that inertness at parse time rather than leaving it silently no-op; `false`/unset matches the (also inert)
532
+ // default, so only an explicit `true` is worth flagging.
533
+ if (gate.firstTimeContributorGrace === true) {
534
+ warnings.push(`Manifest field "gate.firstTimeContributorGrace" is currently reserved/inert — it does not soften a blocker outcome for first-time contributors.`);
535
+ }
536
+ // gate.enabled only controls whether the "LoopOver Orb Review Agent" check-run publishes (the legacy
537
+ // enabled -> reviewCheckMode alias in applyGateConfigOverrides) -- it does NOT gate spend, merge, comment,
538
+ // label, or close behavior, and it cannot express checkMode's "visible" state. checkMode always wins when
539
+ // both are set, so once an operator sets checkMode, a lingering enabled becomes pure inert noise they have
540
+ // no reason to notice or remove -- only warn while checkMode is still absent (a paired enabled+checkMode
541
+ // config, the documented convention, is not worth flagging). Caused two real incidents under this exact
542
+ // ambiguity (an operator flipping enabled to restore unrelated spend/gate behavior got a surprise check-run
543
+ // side effect instead, twice).
544
+ if (gate.enabled !== null && gate.checkMode === null) {
545
+ warnings.push(`Manifest field "gate.enabled" only controls whether the LoopOver Orb Review Agent check-run publishes — it does not gate spend, merge, comment, label, or close behavior, and it cannot express reviewCheckMode's "visible" state. Set "gate.checkMode" (required/visible/disabled) explicitly instead of "gate.enabled".`);
546
+ }
547
+ gate.present =
548
+ gate.enabled !== null ||
549
+ gate.checkMode !== null ||
550
+ gate.pack !== null ||
551
+ gate.linkedIssue !== null ||
552
+ gate.duplicates !== null ||
553
+ gate.readinessMode !== null ||
554
+ gate.readinessMinScore !== null ||
555
+ gate.slopMode !== null ||
556
+ gate.slopMinScore !== null ||
557
+ gate.slopAiAdvisory !== null ||
558
+ gate.sizeMode !== null ||
559
+ gate.lockfileIntegrityMode !== null ||
560
+ gate.aiReviewMode !== null ||
561
+ gate.aiReviewByok !== null ||
562
+ gate.aiReviewProvider !== null ||
563
+ gate.aiReviewModel !== null ||
564
+ gate.aiReviewAllAuthors !== null ||
565
+ gate.aiReviewCloseConfidence !== null ||
566
+ gate.aiReviewLowConfidenceDisposition !== null ||
567
+ gate.aiReviewCombine !== null ||
568
+ gate.aiReviewOnMerge !== null ||
569
+ gate.aiReviewReviewers !== null ||
570
+ gate.mergeReadiness !== null ||
571
+ gate.manifestPolicy !== null ||
572
+ gate.selfAuthoredLinkedIssue !== null ||
573
+ gate.linkedIssueSatisfaction !== null ||
574
+ gate.dryRun !== null ||
575
+ gate.firstTimeContributorGrace !== null ||
576
+ gate.premergeContentRecheck !== null ||
577
+ gate.requireFreshRebaseWindowMinutes !== null ||
578
+ gate.claMode !== null ||
579
+ gate.claConsentPhrase !== null ||
580
+ gate.claCheckRunName !== null ||
581
+ gate.claCheckRunAppSlug !== null ||
582
+ gate.expectedCiContexts !== null ||
583
+ gate.aiJudgmentBlockersMode !== null ||
584
+ gate.copycatMode !== null ||
585
+ gate.copycatMinScore !== null;
586
+ return gate;
587
+ }
588
+ /**
589
+ * Serialize a gate config back into the parse-compatible `gate:` shape so a cached manifest snapshot
590
+ * round-trips through {@link parseGateConfig} unchanged. Returns null when nothing is configured.
591
+ */
592
+ export function gateConfigToJson(gate) {
593
+ if (!gate.present)
594
+ return null;
595
+ const out = {};
596
+ if (gate.enabled !== null)
597
+ out.enabled = gate.enabled;
598
+ if (gate.checkMode !== null)
599
+ out.checkMode = gate.checkMode;
600
+ if (gate.pack !== null)
601
+ out.pack = gate.pack;
602
+ if (gate.linkedIssue !== null)
603
+ out.linkedIssue = gate.linkedIssue;
604
+ if (gate.duplicates !== null)
605
+ out.duplicates = gate.duplicates;
606
+ if (gate.readinessMode !== null || gate.readinessMinScore !== null) {
607
+ const readiness = {};
608
+ if (gate.readinessMode !== null)
609
+ readiness.mode = gate.readinessMode;
610
+ if (gate.readinessMinScore !== null)
611
+ readiness.minScore = gate.readinessMinScore;
612
+ out.readiness = readiness;
613
+ }
614
+ if (gate.sizeMode !== null)
615
+ out.size = { mode: gate.sizeMode };
616
+ if (gate.lockfileIntegrityMode !== null)
617
+ out.lockfileIntegrity = gate.lockfileIntegrityMode;
618
+ if (gate.slopMode !== null || gate.slopMinScore !== null || gate.slopAiAdvisory !== null) {
619
+ const slop = {};
620
+ if (gate.slopMode !== null)
621
+ slop.mode = gate.slopMode;
622
+ if (gate.slopMinScore !== null)
623
+ slop.minScore = gate.slopMinScore;
624
+ if (gate.slopAiAdvisory !== null)
625
+ slop.aiAdvisory = gate.slopAiAdvisory;
626
+ out.slop = slop;
627
+ }
628
+ if (gate.aiReviewMode !== null ||
629
+ gate.aiReviewByok !== null ||
630
+ gate.aiReviewProvider !== null ||
631
+ gate.aiReviewModel !== null ||
632
+ gate.aiReviewAllAuthors !== null ||
633
+ gate.aiReviewCloseConfidence !== null ||
634
+ gate.aiReviewLowConfidenceDisposition !== null ||
635
+ gate.aiReviewCombine !== null ||
636
+ gate.aiReviewOnMerge !== null ||
637
+ gate.aiReviewReviewers !== null) {
638
+ const aiReview = {};
639
+ if (gate.aiReviewMode !== null)
640
+ aiReview.mode = gate.aiReviewMode;
641
+ if (gate.aiReviewByok !== null)
642
+ aiReview.byok = gate.aiReviewByok;
643
+ if (gate.aiReviewProvider !== null)
644
+ aiReview.provider = gate.aiReviewProvider;
645
+ if (gate.aiReviewModel !== null)
646
+ aiReview.model = gate.aiReviewModel;
647
+ if (gate.aiReviewAllAuthors !== null)
648
+ aiReview.allAuthors = gate.aiReviewAllAuthors;
649
+ if (gate.aiReviewCloseConfidence !== null)
650
+ aiReview.closeConfidence = gate.aiReviewCloseConfidence;
651
+ if (gate.aiReviewLowConfidenceDisposition !== null)
652
+ aiReview.lowConfidenceDisposition = gate.aiReviewLowConfidenceDisposition;
653
+ if (gate.aiReviewCombine !== null)
654
+ aiReview.combine = gate.aiReviewCombine;
655
+ if (gate.aiReviewOnMerge !== null)
656
+ aiReview.onMerge = gate.aiReviewOnMerge;
657
+ if (gate.aiReviewReviewers !== null) {
658
+ aiReview.reviewers = gate.aiReviewReviewers.map((r) => r.fallback ? { model: r.model, fallback: r.fallback } : { model: r.model });
659
+ }
660
+ out.aiReview = aiReview;
661
+ }
662
+ if (gate.mergeReadiness !== null)
663
+ out.mergeReadiness = gate.mergeReadiness;
664
+ if (gate.manifestPolicy !== null)
665
+ out.manifestPolicy = gate.manifestPolicy;
666
+ if (gate.selfAuthoredLinkedIssue !== null)
667
+ out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue;
668
+ if (gate.linkedIssueSatisfaction !== null)
669
+ out.linkedIssueSatisfaction = gate.linkedIssueSatisfaction;
670
+ if (gate.dryRun !== null)
671
+ out.dryRun = gate.dryRun;
672
+ if (gate.firstTimeContributorGrace !== null)
673
+ out.firstTimeContributorGrace = gate.firstTimeContributorGrace;
674
+ if (gate.premergeContentRecheck !== null)
675
+ out.premergeContentRecheck = gate.premergeContentRecheck;
676
+ if (gate.requireFreshRebaseWindowMinutes !== null)
677
+ out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes;
678
+ if (gate.claMode !== null)
679
+ out.claMode = gate.claMode;
680
+ if (gate.claConsentPhrase !== null || gate.claCheckRunName !== null || gate.claCheckRunAppSlug !== null) {
681
+ const cla = {};
682
+ if (gate.claConsentPhrase !== null)
683
+ cla.consentPhrase = gate.claConsentPhrase;
684
+ if (gate.claCheckRunName !== null)
685
+ cla.checkRunName = gate.claCheckRunName;
686
+ if (gate.claCheckRunAppSlug !== null)
687
+ cla.checkRunAppSlug = gate.claCheckRunAppSlug;
688
+ out.cla = cla;
689
+ }
690
+ if (gate.expectedCiContexts !== null)
691
+ out.expectedCiContexts = gate.expectedCiContexts;
692
+ if (gate.aiJudgmentBlockersMode !== null)
693
+ out.aiJudgmentBlockers = gate.aiJudgmentBlockersMode;
694
+ if (gate.copycatMode !== null || gate.copycatMinScore !== null) {
695
+ const copycat = {};
696
+ if (gate.copycatMode !== null)
697
+ copycat.mode = gate.copycatMode;
698
+ if (gate.copycatMinScore !== null)
699
+ copycat.minScore = gate.copycatMinScore;
700
+ out.copycat = copycat;
701
+ }
702
+ return out;
703
+ }
704
+ /**
705
+ * Parse the optional `features:` mapping — per-repo activation overrides for the converged review features.
706
+ * Each recognized key becomes a tri-state (`true`/`false`/`null`); unknown keys and non-boolean values are
707
+ * dropped with a warning. `present` is true when at least one key was explicitly set, so an operator can make
708
+ * the manifest "present" with only a `features:` block.
709
+ */
710
+ function parseFeaturesConfig(value, warnings) {
711
+ const features = { ...EMPTY_FEATURES_CONFIG };
712
+ if (value === undefined || value === null)
713
+ return features;
714
+ if (typeof value !== "object" || Array.isArray(value)) {
715
+ warnings.push('Manifest "features" must be a mapping; ignoring it.');
716
+ return features;
717
+ }
718
+ const record = value;
719
+ for (const key of CONVERGED_FEATURE_KEYS) {
720
+ features[key] = normalizeOptionalBoolean(record[key], `features.${key}`, warnings);
721
+ }
722
+ features.present = CONVERGED_FEATURE_KEYS.some((key) => features[key] !== null);
723
+ return features;
724
+ }
725
+ /** Serialize a features config back into the parse-compatible `features:` shape so a cached snapshot round-trips
726
+ * through {@link parseFeaturesConfig} unchanged. Returns null when nothing is configured. */
727
+ export function featuresConfigToJson(features) {
728
+ if (!features.present)
729
+ return null;
730
+ const out = {};
731
+ for (const key of CONVERGED_FEATURE_KEYS) {
732
+ if (features[key] !== null)
733
+ out[key] = features[key];
734
+ }
735
+ return out;
736
+ }
737
+ /**
738
+ * Parse the optional `experimental:` mapping — per-repo activation for optional ecosystem/network plugins
739
+ * (starting with `gittensor`, the subnet mining/scoring integration). Mirrors parseFeaturesConfig's shape and
740
+ * validation; kept as a SEPARATE top-level block from `features:` so plugin integrations that couple gittensory
741
+ * to an external network stay visibly distinct from the converged REVIEW capabilities `features:` toggles, and
742
+ * so future plugins land in the same place without touching `features:`'s semantics.
743
+ */
744
+ function parseExperimentalConfig(value, warnings) {
745
+ const experimental = { ...EMPTY_EXPERIMENTAL_CONFIG };
746
+ if (value === undefined || value === null)
747
+ return experimental;
748
+ if (typeof value !== "object" || Array.isArray(value)) {
749
+ warnings.push('Manifest "experimental" must be a mapping; ignoring it.');
750
+ return experimental;
751
+ }
752
+ const record = value;
753
+ for (const key of EXPERIMENTAL_PLUGIN_KEYS) {
754
+ experimental[key] = normalizeOptionalBoolean(record[key], `experimental.${key}`, warnings);
755
+ }
756
+ experimental.present = EXPERIMENTAL_PLUGIN_KEYS.some((key) => experimental[key] !== null);
757
+ return experimental;
758
+ }
759
+ /** Serialize an experimental config back into the parse-compatible `experimental:` shape so a cached snapshot
760
+ * round-trips through {@link parseExperimentalConfig} unchanged. Returns null when nothing is configured. */
761
+ export function experimentalConfigToJson(experimental) {
762
+ if (!experimental.present)
763
+ return null;
764
+ const out = {};
765
+ for (const key of EXPERIMENTAL_PLUGIN_KEYS) {
766
+ if (experimental[key] !== null)
767
+ out[key] = experimental[key];
768
+ }
769
+ return out;
770
+ }
771
+ /** A positive INTEGER count (not a score/confidence) — e.g. `contentLane.maxAppendedEntries` counts discrete
772
+ * surfaces[] entries, so a fractional value (a likely typo) would render a nonsensical contributor-facing close
773
+ * message ("append between 1 and 2.5 entries"). Rejects fractional and non-positive values alike. */
774
+ function normalizeOptionalPositiveInteger(value, field, warnings) {
775
+ if (value === undefined || value === null)
776
+ return null;
777
+ if (typeof value === "number" && Number.isInteger(value) && value > 0)
778
+ return value;
779
+ warnings.push(`Manifest field "${field}" must be a positive whole number; ignoring it.`);
780
+ return null;
781
+ }
782
+ const MAX_CONTRIBUTOR_OPEN_ITEM_CAP = 100;
783
+ function normalizeOptionalContributorOpenItemCap(value, field, warnings) {
784
+ const parsed = normalizeOptionalPositiveInteger(value, field, warnings);
785
+ if (parsed === null)
786
+ return null;
787
+ return Math.min(parsed, MAX_CONTRIBUTOR_OPEN_ITEM_CAP);
788
+ }
789
+ const REVIEW_VISUAL_MAX_ROUTES_LIMIT = 5;
790
+ function normalizeOptionalVisualMaxRoutes(value, warnings) {
791
+ const maxRoutes = normalizeOptionalPositiveInteger(value, "review.visual.routes.max_routes", warnings);
792
+ if (maxRoutes === null)
793
+ return null;
794
+ if (maxRoutes <= REVIEW_VISUAL_MAX_ROUTES_LIMIT)
795
+ return maxRoutes;
796
+ warnings.push(`Manifest field "review.visual.routes.max_routes" must be at most ${REVIEW_VISUAL_MAX_ROUTES_LIMIT}; clamping it.`);
797
+ return REVIEW_VISUAL_MAX_ROUTES_LIMIT;
798
+ }
799
+ /** Normalize + bound a maintainer-supplied glob string: trims/length-caps like any other string field, AND
800
+ * rejects one globToRegExp (review/content-lane/spec-resolver.ts's reuse of the guardrail-path compiler) would
801
+ * itself refuse to compile safely. Reuses `hasUnsafeWildcardCount` — globToRegExp's OWN safety predicate —
802
+ * rather than a locally-counted threshold: a caller that counts wildcards differently (e.g. raw `*` characters,
803
+ * which double-counts a `**` pair as 2 groups instead of 1) can accept a glob globToRegExp then silently
804
+ * compiles to NEVER_MATCHES, configuring a lane that is "present" but can never activate on any changed file
805
+ * (#confirmed-bug). A glob over the cap is REJECTED (warns, returns null) rather than truncated — silently
806
+ * cutting wildcards out of a maintainer's pattern would silently change its meaning, which is worse than making
807
+ * them fix an over-complex glob. */
808
+ function normalizeOptionalGlob(value, field, warnings) {
809
+ const normalized = normalizeOptionalString(value, field, warnings);
810
+ if (normalized === null)
811
+ return null;
812
+ if (normalized.length > MAX_ITEM_LENGTH) {
813
+ // REJECT, not truncate: cutting characters out of a glob changes which files it matches (e.g. a
814
+ // mid-directory-name cut can turn a narrow, intended pattern into one that matches an unrelated path
815
+ // prefix, or one that never matches anything) — silently compiling a DIFFERENT pattern than the
816
+ // maintainer configured is worse than making them shorten an over-complex glob.
817
+ warnings.push(`Manifest field "${field}" is an over-long glob (${normalized.length} > ${MAX_ITEM_LENGTH} chars); ignoring it.`);
818
+ return null;
819
+ }
820
+ if (hasUnsafeWildcardCount(normalized)) {
821
+ warnings.push(`Manifest field "${field}" has too many wildcards to compile safely; ignoring it.`);
822
+ return null;
823
+ }
824
+ return normalized;
825
+ }
826
+ /**
827
+ * Parse the optional `contentLane:` mapping — per-repo registry-review lane configuration (#2435). `entryFileGlob`
828
+ * and `collectionField` are REQUIRED to build a usable spec; a config missing either — including a glob rejected
829
+ * by `normalizeOptionalGlob`'s wildcard cap — degrades to "not configured" (a warning, falling through to the
830
+ * allowlist default) rather than a broken half-spec. Glob fields stay plain strings here — compiling them to
831
+ * RegExp is the resolver's job (`review/content-lane/spec-resolver.ts`), not the parser's, so this file stays
832
+ * free of a RegExp-from-config compile step; it's still this file's job to keep an over-complex glob from ever
833
+ * reaching that compile step at all.
834
+ */
835
+ function parseContentLaneConfig(value, warnings) {
836
+ if (value === undefined || value === null)
837
+ return { ...EMPTY_CONTENT_LANE_CONFIG };
838
+ if (typeof value !== "object" || Array.isArray(value)) {
839
+ warnings.push('Manifest field "contentLane" must be a mapping; ignoring it.');
840
+ return { ...EMPTY_CONTENT_LANE_CONFIG };
841
+ }
842
+ const record = value;
843
+ const entryFileGlob = normalizeOptionalGlob(record.entryFileGlob, "contentLane.entryFileGlob", warnings);
844
+ const providerFileGlob = normalizeOptionalGlob(record.providerFileGlob, "contentLane.providerFileGlob", warnings);
845
+ const artifactGlob = normalizeOptionalGlob(record.artifactGlob, "contentLane.artifactGlob", warnings);
846
+ const collectionField = normalizeOptionalString(record.collectionField, "contentLane.collectionField", warnings);
847
+ const maxAppendedEntries = normalizeOptionalPositiveInteger(record.maxAppendedEntries, "contentLane.maxAppendedEntries", warnings);
848
+ const duplicateKeyFields = normalizeStringList(record.duplicateKeyFields, "contentLane.duplicateKeyFields", warnings);
849
+ const validatorId = normalizeOptionalString(record.validatorId, "contentLane.validatorId", warnings);
850
+ if (!entryFileGlob || !collectionField) {
851
+ warnings.push('Manifest field "contentLane" requires both entryFileGlob and collectionField; ignoring it.');
852
+ return { ...EMPTY_CONTENT_LANE_CONFIG };
853
+ }
854
+ return { present: true, entryFileGlob, providerFileGlob, artifactGlob, collectionField, maxAppendedEntries, duplicateKeyFields, validatorId };
855
+ }
856
+ /** Serialize a contentLane config back into the parse-compatible `contentLane:` shape so a cached snapshot
857
+ * round-trips through {@link parseContentLaneConfig} unchanged. Returns null when nothing is configured. */
858
+ export function contentLaneConfigToJson(contentLane) {
859
+ if (!contentLane.present || !contentLane.entryFileGlob || !contentLane.collectionField)
860
+ return null;
861
+ const out = { entryFileGlob: contentLane.entryFileGlob, collectionField: contentLane.collectionField };
862
+ if (contentLane.providerFileGlob !== null)
863
+ out.providerFileGlob = contentLane.providerFileGlob;
864
+ if (contentLane.artifactGlob !== null)
865
+ out.artifactGlob = contentLane.artifactGlob;
866
+ if (contentLane.maxAppendedEntries !== null)
867
+ out.maxAppendedEntries = contentLane.maxAppendedEntries;
868
+ if (contentLane.duplicateKeyFields.length > 0)
869
+ out.duplicateKeyFields = contentLane.duplicateKeyFields;
870
+ if (contentLane.validatorId !== null)
871
+ out.validatorId = contentLane.validatorId;
872
+ return out;
873
+ }
874
+ const REPO_DOC_GENERATION_SCOPES = ["agents", "skills"];
875
+ /** `undefined`/`null` (key omitted) falls back to the default scope; a non-list value is a genuine type error
876
+ * and ALSO falls back to the default (rather than emptying it out, which would silently disable an otherwise
877
+ * `enabled: true` config); an actual list -- even an explicitly empty one, or one where every entry is
878
+ * invalid -- is respected as "nothing in scope", since that is a deliberate, well-typed value. */
879
+ function parseRepoDocGenerationScope(value, warnings) {
880
+ if (value === undefined || value === null)
881
+ return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope];
882
+ if (!Array.isArray(value)) {
883
+ warnings.push('Manifest field "repoDocGeneration.scope" must be a list; falling back to the default scope.');
884
+ return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope];
885
+ }
886
+ const raw = normalizeStringList(value, "repoDocGeneration.scope", warnings);
887
+ return raw.filter((entry) => {
888
+ if (REPO_DOC_GENERATION_SCOPES.includes(entry))
889
+ return true;
890
+ warnings.push(`Manifest field "repoDocGeneration.scope" has an unrecognized entry "${entry}"; ignoring it.`);
891
+ return false;
892
+ });
893
+ }
894
+ /**
895
+ * Parse the optional `repoDocGeneration:` mapping (#3002). Unlike `gate:`/`settings:`, every field here has a
896
+ * concrete default rather than a null "unconfigured" sentinel -- there is no DB layer to overlay onto, so the
897
+ * parsed value (or the default, when a key is omitted) IS the effective value. An explicitly empty `scope: []`
898
+ * is honored as "nothing in scope" (not coerced back to the default); only an OMITTED `scope` key falls back to
899
+ * `["agents"]`, mirroring how `undefined`/`null` mean "unset" everywhere else in this file.
900
+ */
901
+ function parseRepoDocGenerationConfig(value, warnings) {
902
+ if (value === undefined || value === null)
903
+ return { ...EMPTY_REPO_DOC_GENERATION_CONFIG };
904
+ if (typeof value !== "object" || Array.isArray(value)) {
905
+ warnings.push('Manifest field "repoDocGeneration" must be a mapping; ignoring it.');
906
+ return { ...EMPTY_REPO_DOC_GENERATION_CONFIG };
907
+ }
908
+ const record = value;
909
+ const enabled = normalizeOptionalBoolean(record.enabled, "repoDocGeneration.enabled", warnings) ?? false;
910
+ const allowOverwriteExisting = normalizeOptionalBoolean(record.allowOverwriteExisting, "repoDocGeneration.allowOverwriteExisting", warnings) ?? false;
911
+ const scope = parseRepoDocGenerationScope(record.scope, warnings);
912
+ const refreshIntervalDays = normalizeOptionalPositiveInteger(record.refreshIntervalDays, "repoDocGeneration.refreshIntervalDays", warnings) ?? DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS;
913
+ return { present: true, enabled, scope, allowOverwriteExisting, refreshIntervalDays };
914
+ }
915
+ /** Serialize a repoDocGeneration config back into the parse-compatible shape so a cached snapshot round-trips
916
+ * through {@link parseRepoDocGenerationConfig} unchanged. Returns null when nothing is configured. */
917
+ export function repoDocGenerationConfigToJson(config) {
918
+ if (!config.present)
919
+ return null;
920
+ return { enabled: config.enabled, scope: config.scope, allowOverwriteExisting: config.allowOverwriteExisting, refreshIntervalDays: config.refreshIntervalDays };
921
+ }
922
+ /**
923
+ * Parse the optional `reviewRecap:` mapping (#1963). Mirrors {@link parseRepoDocGenerationConfig}: every
924
+ * field has a concrete default (no DB layer to overlay onto), so the parsed value IS the effective value.
925
+ */
926
+ function parseReviewRecapConfig(value, warnings) {
927
+ if (value === undefined || value === null)
928
+ return { ...EMPTY_REVIEW_RECAP_CONFIG };
929
+ if (typeof value !== "object" || Array.isArray(value)) {
930
+ warnings.push('Manifest field "reviewRecap" must be a mapping; ignoring it.');
931
+ return { ...EMPTY_REVIEW_RECAP_CONFIG };
932
+ }
933
+ const record = value;
934
+ const enabled = normalizeOptionalBoolean(record.enabled, "reviewRecap.enabled", warnings) ?? false;
935
+ const cadenceDays = normalizeOptionalPositiveInteger(record.cadenceDays, "reviewRecap.cadenceDays", warnings) ?? DEFAULT_REVIEW_RECAP_CADENCE_DAYS;
936
+ return { present: true, enabled, cadenceDays };
937
+ }
938
+ /** Serialize a reviewRecap config back into the parse-compatible shape so a cached snapshot round-trips
939
+ * through {@link parseReviewRecapConfig} unchanged. Returns null when nothing is configured. */
940
+ export function reviewRecapConfigToJson(config) {
941
+ if (!config.present)
942
+ return null;
943
+ return { enabled: config.enabled, cadenceDays: config.cadenceDays };
944
+ }
945
+ /**
946
+ * Parse the optional `maintainerRecap:` mapping (#1963, #2250). Mirrors {@link parseReviewRecapConfig}: every
947
+ * field has a concrete default (no DB layer to overlay onto), so the parsed value IS the effective value. An
948
+ * invalid `cadence`/`channel` falls back to its default via {@link normalizeEnum} (with a warning) rather than
949
+ * silently firing more often or targeting an unsupported channel.
950
+ */
951
+ function parseMaintainerRecapConfig(value, warnings) {
952
+ if (value === undefined || value === null)
953
+ return { ...EMPTY_MAINTAINER_RECAP_CONFIG };
954
+ if (typeof value !== "object" || Array.isArray(value)) {
955
+ warnings.push('Manifest field "maintainerRecap" must be a mapping; ignoring it.');
956
+ return { ...EMPTY_MAINTAINER_RECAP_CONFIG };
957
+ }
958
+ const record = value;
959
+ const enabled = normalizeOptionalBoolean(record.enabled, "maintainerRecap.enabled", warnings) ?? false;
960
+ const cadence = normalizeEnum(record.cadence, "maintainerRecap.cadence", ["daily", "weekly"], DEFAULT_MAINTAINER_RECAP_CADENCE, warnings);
961
+ const channel = normalizeEnum(record.channel, "maintainerRecap.channel", ["discord"], DEFAULT_MAINTAINER_RECAP_CHANNEL, warnings);
962
+ return { present: true, enabled, cadence, channel };
963
+ }
964
+ /** Serialize a maintainerRecap config back into the parse-compatible shape so a cached snapshot round-trips
965
+ * through {@link parseMaintainerRecapConfig} unchanged. Returns null when nothing is configured. */
966
+ export function maintainerRecapConfigToJson(config) {
967
+ if (!config.present)
968
+ return null;
969
+ return { enabled: config.enabled, cadence: config.cadence, channel: config.channel };
970
+ }
971
+ function normalizeOptionalEnum(value, field, allowed, warnings) {
972
+ if (value === undefined || value === null)
973
+ return null;
974
+ if (typeof value === "string" && allowed.includes(value))
975
+ return value;
976
+ warnings.push(`Manifest settings field "${field}" must be one of ${allowed.join(", ")}; ignoring "${String(value)}".`);
977
+ return null;
978
+ }
979
+ function normalizeOptionalString(value, field, warnings) {
980
+ if (value === undefined || value === null)
981
+ return null;
982
+ if (typeof value === "string" && value.trim().length > 0)
983
+ return value.trim();
984
+ warnings.push(`Manifest settings field "${field}" must be a non-empty string; ignoring it.`);
985
+ return null;
986
+ }
987
+ // Keep the review-nag lookback operationally bounded so repo-controlled config cannot overflow Date
988
+ // arithmetic. Duplicated from settings/agent-actions.ts's own MAX_REVIEW_NAG_COOLDOWN_DAYS (same value,
989
+ // same rationale) rather than imported: this module is part of the UI package's typechecked closure, and
990
+ // agent-actions.ts transitively imports github/commands.ts -> utils/crypto.ts, pulling a heavier
991
+ // GitHub-App-specific dependency chain into the UI build for one small constant.
992
+ const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365;
993
+ /**
994
+ * Parse the optional `settings:` mapping — a partial repository-settings override. Only recognized
995
+ * fields are kept; unknown/invalid values are dropped with a warning and never throw.
996
+ */
997
+ function parseSettingsOverride(value, warnings, source) {
998
+ if (value === undefined || value === null)
999
+ return {};
1000
+ if (typeof value !== "object" || Array.isArray(value)) {
1001
+ warnings.push(`Manifest field "settings" must be a mapping; ignoring it.`);
1002
+ return {};
1003
+ }
1004
+ const r = value;
1005
+ const out = {};
1006
+ const commentMode = normalizeOptionalEnum(r.commentMode, "settings.commentMode", ["off", "detected_contributors_only", "all_prs"], warnings);
1007
+ if (commentMode !== null)
1008
+ out.commentMode = commentMode;
1009
+ const publicAudienceMode = normalizeOptionalEnum(r.publicAudienceMode, "settings.publicAudienceMode", ["oss_maintainer", "gittensor_only"], warnings);
1010
+ if (publicAudienceMode !== null)
1011
+ out.publicAudienceMode = publicAudienceMode;
1012
+ const publicSignalLevel = normalizeOptionalEnum(r.publicSignalLevel, "settings.publicSignalLevel", ["minimal", "standard"], warnings);
1013
+ if (publicSignalLevel !== null)
1014
+ out.publicSignalLevel = publicSignalLevel;
1015
+ const checkRunMode = normalizeOptionalEnum(r.checkRunMode, "settings.checkRunMode", ["off", "enabled"], warnings);
1016
+ if (checkRunMode !== null)
1017
+ out.checkRunMode = checkRunMode;
1018
+ const checkRunDetailLevel = normalizeOptionalEnum(r.checkRunDetailLevel, "settings.checkRunDetailLevel", ["minimal", "standard"], warnings);
1019
+ if (checkRunDetailLevel !== null)
1020
+ out.checkRunDetailLevel = checkRunDetailLevel;
1021
+ const regateSweepOrderMode = normalizeOptionalEnum(r.regateSweepOrderMode, "settings.regateSweepOrderMode", ["staleness", "oldest-first"], warnings);
1022
+ if (regateSweepOrderMode !== null)
1023
+ out.regateSweepOrderMode = regateSweepOrderMode;
1024
+ // Same tri-state field as gate.checkMode above (the friendly gate alias overlays onto it in
1025
+ // resolveEffectiveSettings, and wins when both are set).
1026
+ const reviewCheckMode = normalizeOptionalEnum(r.reviewCheckMode, "settings.reviewCheckMode", ["required", "visible", "disabled"], warnings);
1027
+ if (reviewCheckMode !== null)
1028
+ out.reviewCheckMode = reviewCheckMode;
1029
+ const autoProjectMilestoneMatch = normalizeOptionalEnum(r.autoProjectMilestoneMatch, "settings.autoProjectMilestoneMatch", ["off", "suggest", "auto"], warnings);
1030
+ if (autoProjectMilestoneMatch !== null)
1031
+ out.autoProjectMilestoneMatch = autoProjectMilestoneMatch;
1032
+ const autoProjectMilestoneMatchBackend = normalizeOptionalEnum(r.autoProjectMilestoneMatchBackend, "settings.autoProjectMilestoneMatchBackend", ["github", "linear"], warnings);
1033
+ if (autoProjectMilestoneMatchBackend !== null)
1034
+ out.autoProjectMilestoneMatchBackend = autoProjectMilestoneMatchBackend;
1035
+ const linkedIssueGateMode = normalizeOptionalGateMode(r.linkedIssueGateMode, "settings.linkedIssueGateMode", warnings);
1036
+ if (linkedIssueGateMode !== null)
1037
+ out.linkedIssueGateMode = linkedIssueGateMode;
1038
+ const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings);
1039
+ if (duplicatePrGateMode !== null)
1040
+ out.duplicatePrGateMode = duplicatePrGateMode;
1041
+ const selfAuthoredLinkedIssueGateMode = normalizeOptionalGateMode(r.selfAuthoredLinkedIssueGateMode, "settings.selfAuthoredLinkedIssueGateMode", warnings);
1042
+ if (selfAuthoredLinkedIssueGateMode !== null)
1043
+ out.selfAuthoredLinkedIssueGateMode = selfAuthoredLinkedIssueGateMode;
1044
+ // Same tri-state field as gate.readiness.mode above (the friendly gate alias overlays onto it in
1045
+ // resolveEffectiveSettings) — apply the identical "block" → "advisory" downgrade here too, so a maintainer
1046
+ // setting `settings.qualityGateMode: block` directly hits the same deprecation warning (#2267).
1047
+ const qualityGateMode = normalizeReadinessGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings);
1048
+ if (qualityGateMode !== null)
1049
+ out.qualityGateMode = qualityGateMode;
1050
+ const qualityGateMinScore = normalizeOptionalScore(r.qualityGateMinScore, "settings.qualityGateMinScore", warnings);
1051
+ if (qualityGateMinScore !== null)
1052
+ out.qualityGateMinScore = qualityGateMinScore;
1053
+ const aiReviewMode = normalizeOptionalGateMode(r.aiReviewMode, "settings.aiReviewMode", warnings);
1054
+ if (aiReviewMode !== null)
1055
+ out.aiReviewMode = aiReviewMode;
1056
+ const aiReviewProvider = normalizeOptionalEnum(r.aiReviewProvider, "settings.aiReviewProvider", ["anthropic", "openai"], warnings);
1057
+ if (aiReviewProvider !== null)
1058
+ out.aiReviewProvider = aiReviewProvider;
1059
+ const aiReviewModel = normalizeOptionalString(r.aiReviewModel, "settings.aiReviewModel", warnings);
1060
+ if (aiReviewModel !== null)
1061
+ out.aiReviewModel = aiReviewModel;
1062
+ const gittensorLabel = normalizeOptionalString(r.gittensorLabel, "settings.gittensorLabel", warnings);
1063
+ if (gittensorLabel !== null)
1064
+ out.gittensorLabel = gittensorLabel;
1065
+ // #label-scoping: an explicit yml `null` is load-bearing (closes WITHOUT any label), matching
1066
+ // contributorOpenPrCap's own null-vs-omitted distinction — must be checked BEFORE normalizeOptionalString,
1067
+ // which otherwise collapses null and undefined to the same "unset" result.
1068
+ if (r.blacklistLabel === null) {
1069
+ out.blacklistLabel = null;
1070
+ }
1071
+ else {
1072
+ const blacklistLabel = normalizeOptionalString(r.blacklistLabel, "settings.blacklistLabel", warnings);
1073
+ if (blacklistLabel !== null)
1074
+ out.blacklistLabel = blacklistLabel;
1075
+ }
1076
+ const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"], warnings);
1077
+ if (publicSurface !== null)
1078
+ out.publicSurface = publicSurface;
1079
+ for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun"]) {
1080
+ const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings);
1081
+ if (flag !== null)
1082
+ out[key] = flag;
1083
+ }
1084
+ // agentGlobalFreezeOverride is deliberately NOT in the generic boolean loop above (#4372/#4391/operator-only-
1085
+ // freeze-fix): it is an OPERATOR-ONLY emergency lever ("re-activate this one repo while the fleet-wide kill-
1086
+ // switch stays on elsewhere"), and every OTHER settings field in that loop is readable from BOTH the public,
1087
+ // maintainer-owned `.gittensory.yml` committed in the repo's own git history (source: "repo_file") AND the
1088
+ // operator's private, container-local self-host config (source: "api_record") -- see loadRepoFocusManifestWithCachePolicy
1089
+ // in focus-manifest-loader.ts for how each source is produced. A repo MAINTAINER must never be able to grant
1090
+ // their own repo an exemption from the operator's fleet-wide freeze via their own committed yml (that is
1091
+ // exactly the "scope leak" #4391 closed by stripping this field from the shared loop entirely). But the
1092
+ // OPERATOR's own private config source is a fundamentally different trust boundary -- it is edited only by
1093
+ // whoever has filesystem access to the container's private config directory, not by any repo's maintainers --
1094
+ // and #4391 over-corrected by also removing the operator's own legitimate, config-as-code path for this lever,
1095
+ // forcing raw undocumented DB writes as the only remaining mechanism (violating this project's config-as-code
1096
+ // convention: every operator-facing control belongs in the global-default + per-repo-override config files,
1097
+ // env vars are for bootstrap only). Restore it, gated STRICTLY to the private source.
1098
+ if (source === "api_record") {
1099
+ const agentGlobalFreezeOverride = normalizeOptionalBoolean(r.agentGlobalFreezeOverride, "settings.agentGlobalFreezeOverride", warnings);
1100
+ if (agentGlobalFreezeOverride !== null)
1101
+ out.agentGlobalFreezeOverride = agentGlobalFreezeOverride;
1102
+ }
1103
+ else if (r.agentGlobalFreezeOverride !== undefined) {
1104
+ // A public/maintainer-owned manifest attempting to set this is silently dropped, not surfaced as a normal
1105
+ // "invalid value" warning -- warnings are public-safe text that can reach a contributor-facing preview, and
1106
+ // this should not teach a non-operator that the field exists or that they almost bypassed the fleet freeze.
1107
+ warnings.push("Ignored settings.agentGlobalFreezeOverride: operator-only, not settable from a repo-owned manifest.");
1108
+ }
1109
+ // Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it
1110
+ // when at least one valid class→level pair survives normalization, so a malformed block never blanks the
1111
+ // DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay.
1112
+ if (r.autonomy !== undefined) {
1113
+ const autonomy = normalizeAutonomyPolicy(r.autonomy);
1114
+ if (Object.keys(autonomy).length > 0)
1115
+ out.autonomy = autonomy;
1116
+ }
1117
+ // Auto-maintain policy (#774): `settings.autoMaintain` declares the full policy (defaults fill any unset
1118
+ // field) and overlays the DB value via the resolver. Only a mapping is honoured; anything else is ignored.
1119
+ if (typeof r.autoMaintain === "object" && r.autoMaintain !== null && !Array.isArray(r.autoMaintain)) {
1120
+ out.autoMaintain = normalizeAutoMaintainPolicy(r.autoMaintain);
1121
+ }
1122
+ // Command authorization policy (#2268 config-as-code parity): `settings.commandAuthorization` declares the
1123
+ // full role policy the same way `autoMaintain` does — the normalizer fills any unset/invalid FIELD from
1124
+ // DEFAULT_COMMAND_AUTHORIZATION_POLICY, so a partially-valid mapping yields a complete, safe policy that
1125
+ // overlays the DB value via the resolver's `{...dbSettings, ...manifest.settings}` spread. But an invalid
1126
+ // TOP-LEVEL shape (not a mapping at all) is a different case: normalizeCommandAuthorizationPolicy's own
1127
+ // fallback there is meant for callers with no DB value to fall back to, not for this overlay — applying it
1128
+ // here would let a typo'd config silently overwrite a stricter DB-persisted policy with the built-in
1129
+ // default. So only apply the normalized policy when the raw value was actually a mapping; otherwise warn
1130
+ // and leave `out.commandAuthorization` unset so the resolver preserves whatever the DB already has.
1131
+ if (typeof r.commandAuthorization === "object" && r.commandAuthorization !== null && !Array.isArray(r.commandAuthorization)) {
1132
+ const { policy, warnings: commandAuthorizationWarnings } = normalizeCommandAuthorizationPolicy(r.commandAuthorization);
1133
+ warnings.push(...commandAuthorizationWarnings);
1134
+ out.commandAuthorization = policy;
1135
+ }
1136
+ else if (r.commandAuthorization !== undefined) {
1137
+ warnings.push(`Manifest "settings.commandAuthorization" must be an object; ignoring it and keeping any existing policy.`);
1138
+ }
1139
+ // TYPE label category overrides (#priority-linked-issue-gate, #label-modularity): unlike
1140
+ // commandAuthorization/autoMaintain above, this is deliberately kept SPARSE -- only the keys actually
1141
+ // present AND validly-shaped in the raw YAML are copied onto `out.typeLabels` (via
1142
+ // `normalizeTypeLabelSet`, which still fills in the built-in bug/feature/priority keys to run its own
1143
+ // shape checks, but those defaults-filled values are discarded here). A manifest naming only
1144
+ // `typeLabels.priority` must inherit `bug`/`feature` from the DB-persisted value in
1145
+ // `resolveEffectiveSettings`, not have them silently reset to the built-in gittensor:* names -- assigning
1146
+ // the normalizer's complete object here would do exactly that via the resolver's wholesale
1147
+ // `{...dbSettings, ...manifest.settings}` spread. The per-field shape check below (not just "is the key
1148
+ // present") matters too: a malformed value (e.g. `typeLabels.priority: 123`) is present but invalid, so
1149
+ // `normalizeTypeLabelSet` warns and reports its OWN built-in-default fallback for that key -- copying
1150
+ // that fallback into the sparse override would silently overwrite a DB-customized value with the
1151
+ // built-in default on a config typo, instead of leaving the DB value alone. The loop is generic over
1152
+ // whatever keys the raw object actually has (not hardcoded to bug/feature/priority), so an arbitrary
1153
+ // custom category (e.g. `security`) sparse-overrides exactly like a built-in one. The normalizer
1154
+ // enforces the category-count and label-name caps before a sparse key can survive into the override.
1155
+ if (typeof r.typeLabels === "object" && r.typeLabels !== null && !Array.isArray(r.typeLabels)) {
1156
+ const rawTypeLabels = r.typeLabels;
1157
+ if (Object.keys(rawTypeLabels).length === 0) {
1158
+ // A literal `typeLabels: {}` is a DELIBERATE, complete declaration -- "zero configured categories
1159
+ // for this repo" -- distinct from a sparse override whose named keys all failed validation (the
1160
+ // `else` branch below, which must NOT wipe the DB value). Represented as `null` so
1161
+ // `resolveEffectiveSettings` can tell the two apart even though both would otherwise collapse to
1162
+ // the same empty-object shape (#label-modularity).
1163
+ out.typeLabels = null;
1164
+ }
1165
+ else {
1166
+ const validated = normalizeTypeLabelSet(rawTypeLabels, warnings);
1167
+ const isValidLabelName = (value) => typeof value === "string" && value.trim().length > 0 && value.trim().length <= MAX_TYPE_LABEL_NAME_LENGTH;
1168
+ const sparseTypeLabels = {};
1169
+ for (const key of Object.keys(rawTypeLabels)) {
1170
+ if (isValidLabelName(rawTypeLabels[key]) && validated[key] !== undefined)
1171
+ sparseTypeLabels[key] = validated[key];
1172
+ }
1173
+ out.typeLabels = sparseTypeLabels;
1174
+ }
1175
+ }
1176
+ else if (r.typeLabels !== undefined) {
1177
+ warnings.push(`Manifest "settings.typeLabels" must be an object; ignoring it and keeping any existing label names.`);
1178
+ }
1179
+ // Linked-issue label propagation (#priority-linked-issue-gate): same sparse-partial shape as typeLabels
1180
+ // above, for the same reason -- this is the ONLY mechanism that can ever select a maintainer-reward
1181
+ // label like gittensor:priority (never inferred from title/files/AI/PR-labels), so a manifest overriding
1182
+ // just one field (e.g. `enabled`) must not silently reset `mappings` back to the built-in empty default
1183
+ // and discard a DB-configured mapping list. Each field is gated on its OWN raw shape being valid (not
1184
+ // just "is the key present"), for the same reason as typeLabels above -- e.g. a typo'd
1185
+ // `mappings: "oops"` must never silently replace a DB-configured mapping list with the normalizer's
1186
+ // empty-array fallback. A validly-shaped `mappings` array is still a complete replacement when present
1187
+ // (arrays have no per-item precedence semantics here, and any individually-invalid entries inside it
1188
+ // are dropped by the normalizer, not the array itself), matching the array-replace-wholesale overlay
1189
+ // behavior documented for the private-config layer.
1190
+ if (typeof r.linkedIssueLabelPropagation === "object" && r.linkedIssueLabelPropagation !== null && !Array.isArray(r.linkedIssueLabelPropagation)) {
1191
+ const rawPropagation = r.linkedIssueLabelPropagation;
1192
+ const validated = normalizeLinkedIssueLabelPropagationConfig(rawPropagation, warnings);
1193
+ const sparsePropagation = {};
1194
+ if (typeof rawPropagation.enabled === "boolean")
1195
+ sparsePropagation.enabled = validated.enabled;
1196
+ if (typeof rawPropagation.mode === "string" && VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES.includes(rawPropagation.mode)) {
1197
+ sparsePropagation.mode = validated.mode;
1198
+ }
1199
+ if (Array.isArray(rawPropagation.mappings))
1200
+ sparsePropagation.mappings = validated.mappings;
1201
+ out.linkedIssueLabelPropagation = sparsePropagation;
1202
+ }
1203
+ else if (r.linkedIssueLabelPropagation !== undefined) {
1204
+ warnings.push(`Manifest "settings.linkedIssueLabelPropagation" must be an object; ignoring it and keeping any existing policy.`);
1205
+ }
1206
+ // Linked-issue hard rules: same sparse-partial overlay contract as linkedIssueLabelPropagation. A global config
1207
+ // can enable the policy and set label lists; a repo override can toggle one mode without resetting those lists.
1208
+ if (typeof r.linkedIssueHardRules === "object" && r.linkedIssueHardRules !== null && !Array.isArray(r.linkedIssueHardRules)) {
1209
+ const rawRules = r.linkedIssueHardRules;
1210
+ const validated = normalizeLinkedIssueHardRulesConfig(rawRules, warnings);
1211
+ const sparseRules = {};
1212
+ if (isLinkedIssueHardRuleMode(rawRules.ownerAssignedClose))
1213
+ sparseRules.ownerAssignedClose = validated.ownerAssignedClose;
1214
+ if (isLinkedIssueHardRuleMode(rawRules.assignedIssueClose))
1215
+ sparseRules.assignedIssueClose = validated.assignedIssueClose;
1216
+ if (isLinkedIssueHardRuleMode(rawRules.missingPointLabelClose))
1217
+ sparseRules.missingPointLabelClose = validated.missingPointLabelClose;
1218
+ if (isLinkedIssueHardRuleMode(rawRules.maintainerOnlyLabelClose))
1219
+ sparseRules.maintainerOnlyLabelClose = validated.maintainerOnlyLabelClose;
1220
+ if (Array.isArray(rawRules.pointBearingLabels))
1221
+ sparseRules.pointBearingLabels = validated.pointBearingLabels;
1222
+ if (Array.isArray(rawRules.maintainerOnlyLabels))
1223
+ sparseRules.maintainerOnlyLabels = validated.maintainerOnlyLabels;
1224
+ if (typeof rawRules.defaultLabelRepo === "boolean")
1225
+ sparseRules.defaultLabelRepo = validated.defaultLabelRepo;
1226
+ if (typeof rawRules.verifyBeforeClose === "boolean")
1227
+ sparseRules.verifyBeforeClose = validated.verifyBeforeClose;
1228
+ if (typeof rawRules.closeDelaySeconds === "number" && Number.isFinite(rawRules.closeDelaySeconds) && rawRules.closeDelaySeconds >= 0) {
1229
+ sparseRules.closeDelaySeconds = validated.closeDelaySeconds;
1230
+ }
1231
+ out.linkedIssueHardRules = sparseRules;
1232
+ }
1233
+ else if (r.linkedIssueHardRules !== undefined) {
1234
+ warnings.push(`Manifest "settings.linkedIssueHardRules" must be an object; ignoring it and keeping any existing policy.`);
1235
+ }
1236
+ // Unlinked-issue guardrail (#unlinked-issue-guardrail): same sparse-partial overlay contract as
1237
+ // linkedIssueHardRules above -- a repo naming only `mode` must not silently reset `minConfidence` back to
1238
+ // the built-in default.
1239
+ if (typeof r.unlinkedIssueGuardrail === "object" && r.unlinkedIssueGuardrail !== null && !Array.isArray(r.unlinkedIssueGuardrail)) {
1240
+ const rawGuardrail = r.unlinkedIssueGuardrail;
1241
+ const validated = normalizeUnlinkedIssueGuardrailConfig(rawGuardrail, warnings);
1242
+ const sparseGuardrail = {};
1243
+ if (isUnlinkedIssueGuardrailMode(rawGuardrail.mode))
1244
+ sparseGuardrail.mode = validated.mode;
1245
+ if (typeof rawGuardrail.minConfidence === "number" && Number.isFinite(rawGuardrail.minConfidence) && rawGuardrail.minConfidence >= 0 && rawGuardrail.minConfidence <= 1) {
1246
+ sparseGuardrail.minConfidence = validated.minConfidence;
1247
+ }
1248
+ out.unlinkedIssueGuardrail = sparseGuardrail;
1249
+ }
1250
+ else if (r.unlinkedIssueGuardrail !== undefined) {
1251
+ warnings.push(`Manifest "settings.unlinkedIssueGuardrail" must be an object; ignoring it and keeping any existing policy.`);
1252
+ }
1253
+ // Screenshot-table gate (#2006): same sparse-partial overlay contract as unlinkedIssueGuardrail above -- a
1254
+ // repo naming only `enabled` must not silently reset `whenLabels`/`whenPaths`/`action`/`message`.
1255
+ if (typeof r.screenshotTableGate === "object" && r.screenshotTableGate !== null && !Array.isArray(r.screenshotTableGate)) {
1256
+ const rawGate = r.screenshotTableGate;
1257
+ const validated = normalizeScreenshotTableGateConfig(rawGate, warnings);
1258
+ const sparseGate = {};
1259
+ if (typeof rawGate.enabled === "boolean")
1260
+ sparseGate.enabled = validated.enabled;
1261
+ if (Array.isArray(rawGate.whenLabels))
1262
+ sparseGate.whenLabels = validated.whenLabels;
1263
+ if (Array.isArray(rawGate.whenPaths))
1264
+ sparseGate.whenPaths = validated.whenPaths;
1265
+ if (isScreenshotTableGateAction(rawGate.action))
1266
+ sparseGate.action = validated.action;
1267
+ if (typeof rawGate.message === "string" && rawGate.message.trim().length > 0)
1268
+ sparseGate.message = validated.message;
1269
+ if (Array.isArray(rawGate.requireViewports))
1270
+ sparseGate.requireViewports = validated.requireViewports;
1271
+ if (Array.isArray(rawGate.requireThemes))
1272
+ sparseGate.requireThemes = validated.requireThemes;
1273
+ if (typeof rawGate.skillFileUrl === "string" && rawGate.skillFileUrl.trim().length > 0)
1274
+ sparseGate.skillFileUrl = validated.skillFileUrl;
1275
+ out.screenshotTableGate = sparseGate;
1276
+ }
1277
+ else if (r.screenshotTableGate !== undefined) {
1278
+ warnings.push(`Manifest "settings.screenshotTableGate" must be an object; ignoring it and keeping any existing policy.`);
1279
+ }
1280
+ // Advisory-AI routing (#4364): same sparse-partial overlay contract as screenshotTableGate above -- a repo
1281
+ // naming only `slop` must not silently reset `e2eTestGen`/`planner`/`summaries` back to their defaults.
1282
+ if (typeof r.advisoryAiRouting === "object" && r.advisoryAiRouting !== null && !Array.isArray(r.advisoryAiRouting)) {
1283
+ const rawRouting = r.advisoryAiRouting;
1284
+ const validated = normalizeAdvisoryAiRoutingConfig(rawRouting, warnings);
1285
+ const sparseRouting = {};
1286
+ if (typeof rawRouting.slop === "boolean")
1287
+ sparseRouting.slop = validated.slop;
1288
+ if (typeof rawRouting.e2eTestGen === "boolean")
1289
+ sparseRouting.e2eTestGen = validated.e2eTestGen;
1290
+ if (typeof rawRouting.planner === "boolean")
1291
+ sparseRouting.planner = validated.planner;
1292
+ if (typeof rawRouting.summaries === "boolean")
1293
+ sparseRouting.summaries = validated.summaries;
1294
+ if (typeof rawRouting.chatQa === "boolean")
1295
+ sparseRouting.chatQa = validated.chatQa;
1296
+ if (typeof rawRouting.chatQaFrontierFallback === "boolean")
1297
+ sparseRouting.chatQaFrontierFallback = validated.chatQaFrontierFallback;
1298
+ if (typeof rawRouting.intentRouting === "boolean")
1299
+ sparseRouting.intentRouting = validated.intentRouting;
1300
+ out.advisoryAiRouting = sparseRouting;
1301
+ }
1302
+ else if (r.advisoryAiRouting !== undefined) {
1303
+ warnings.push(`Manifest "settings.advisoryAiRouting" must be an object; ignoring it and keeping any existing policy.`);
1304
+ }
1305
+ // Contributor blacklist (#1425): `settings.contributorBlacklist` is a list of banned-login entries. Only set it
1306
+ // when at least one VALID entry survives normalization, so a malformed block never blanks the DB-configured
1307
+ // list via the resolver's `{...dbSettings, ...manifest.settings}` overlay. Normalization warnings are folded in.
1308
+ if (r.contributorBlacklist !== undefined) {
1309
+ const { entries, warnings: blacklistWarnings } = normalizeContributorBlacklist(r.contributorBlacklist);
1310
+ warnings.push(...blacklistWarnings);
1311
+ if (entries.length > 0)
1312
+ out.contributorBlacklist = entries;
1313
+ }
1314
+ // Per-contributor open PR/issue caps (#2270): discrete counts, not scores — reuse the same positive-integer
1315
+ // shape as contentLane.maxAppendedEntries so a fractional/non-positive typo is dropped with a warning
1316
+ // instead of configuring a nonsensical cap. Valid counts clamp to the fixed live-verification budget. UNLIKE
1317
+ // contributorBlacklist above, an explicit yml `null` here is
1318
+ // load-bearing (not the same as omitting the key): the documented `yml > DB > null` precedence means a
1319
+ // maintainer must be able to force a DB-configured cap back to "no cap" via `.gittensory.yml` without deleting
1320
+ // the DB row. `normalizeOptionalPositiveInteger` collapses "absent" and "null" to the same silent `null`
1321
+ // return, so that distinction has to be made HERE, before calling it: a literal `null` sets the key to `null`
1322
+ // (clears); omitted (`undefined`) leaves the key unset (preserves the DB value via the resolver's spread); an
1323
+ // invalid non-null value (fractional/non-positive/wrong type) warns and also leaves the key unset.
1324
+ if (r.contributorOpenPrCap === null) {
1325
+ out.contributorOpenPrCap = null;
1326
+ }
1327
+ else {
1328
+ const contributorOpenPrCap = normalizeOptionalContributorOpenItemCap(r.contributorOpenPrCap, "settings.contributorOpenPrCap", warnings);
1329
+ if (contributorOpenPrCap !== null)
1330
+ out.contributorOpenPrCap = contributorOpenPrCap;
1331
+ }
1332
+ if (r.contributorOpenIssueCap === null) {
1333
+ out.contributorOpenIssueCap = null;
1334
+ }
1335
+ else {
1336
+ const contributorOpenIssueCap = normalizeOptionalContributorOpenItemCap(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings);
1337
+ if (contributorOpenIssueCap !== null)
1338
+ out.contributorOpenIssueCap = contributorOpenIssueCap;
1339
+ }
1340
+ // #label-scoping: same load-bearing-null idiom as blacklistLabel above.
1341
+ if (r.contributorCapLabel === null) {
1342
+ out.contributorCapLabel = null;
1343
+ }
1344
+ else {
1345
+ const contributorCapLabel = normalizeOptionalString(r.contributorCapLabel, "settings.contributorCapLabel", warnings);
1346
+ if (contributorCapLabel !== null)
1347
+ out.contributorCapLabel = contributorCapLabel;
1348
+ }
1349
+ // CI-run cancellation on a contributor_cap close (#2462): an explicit yml `null` is load-bearing (clears a
1350
+ // DB-configured value back to "unset", falling through to the CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var),
1351
+ // matching contributorOpenPrCap's own null-vs-omitted distinction above.
1352
+ if (r.contributorCapCancelCi === null) {
1353
+ out.contributorCapCancelCi = null;
1354
+ }
1355
+ else {
1356
+ const contributorCapCancelCi = normalizeOptionalBoolean(r.contributorCapCancelCi, "settings.contributorCapCancelCi", warnings);
1357
+ if (contributorCapCancelCi !== null)
1358
+ out.contributorCapCancelCi = contributorCapCancelCi;
1359
+ }
1360
+ // Review-request nagging cooldown (#2463): throttle a contributor repeatedly pinging @gittensory for review.
1361
+ const reviewNagPolicy = normalizeOptionalEnum(r.reviewNagPolicy, "settings.reviewNagPolicy", ["off", "hold", "close"], warnings);
1362
+ if (reviewNagPolicy !== null)
1363
+ out.reviewNagPolicy = reviewNagPolicy;
1364
+ const reviewNagMaxPings = normalizeOptionalPositiveInteger(r.reviewNagMaxPings, "settings.reviewNagMaxPings", warnings);
1365
+ if (reviewNagMaxPings !== null)
1366
+ out.reviewNagMaxPings = reviewNagMaxPings;
1367
+ const reviewNagCooldownDays = normalizeOptionalPositiveInteger(r.reviewNagCooldownDays, "settings.reviewNagCooldownDays", warnings);
1368
+ if (reviewNagCooldownDays !== null && reviewNagCooldownDays <= MAX_REVIEW_NAG_COOLDOWN_DAYS)
1369
+ out.reviewNagCooldownDays = reviewNagCooldownDays;
1370
+ if (reviewNagCooldownDays !== null && reviewNagCooldownDays > MAX_REVIEW_NAG_COOLDOWN_DAYS) {
1371
+ warnings.push(`Manifest field "settings.reviewNagCooldownDays" must be at most ${MAX_REVIEW_NAG_COOLDOWN_DAYS}; ignoring it.`);
1372
+ }
1373
+ // #label-scoping: same load-bearing-null idiom as blacklistLabel above.
1374
+ if (r.reviewNagLabel === null) {
1375
+ out.reviewNagLabel = null;
1376
+ }
1377
+ else {
1378
+ const reviewNagLabel = normalizeOptionalString(r.reviewNagLabel, "settings.reviewNagLabel", warnings);
1379
+ if (reviewNagLabel !== null)
1380
+ out.reviewNagLabel = reviewNagLabel;
1381
+ }
1382
+ // Maintainer-mention nag moderation (#label-scoping): GitHub logins ALSO throttled under the review-nag
1383
+ // cooldown above, on top of the bot's own @gittensory handle. Only set it when at least one VALID login
1384
+ // survives normalization, so a malformed block never blanks the DB-configured list via the resolver's
1385
+ // `{...dbSettings, ...manifest.settings}` overlay (same reasoning as autoCloseExemptLogins below).
1386
+ if (r.reviewNagMonitoredMentions !== undefined) {
1387
+ const { logins: monitoredMentions, warnings: monitoredMentionWarnings } = normalizeAutoCloseExemptLogins(r.reviewNagMonitoredMentions);
1388
+ warnings.push(...monitoredMentionWarnings);
1389
+ if (monitoredMentions.length > 0)
1390
+ out.reviewNagMonitoredMentions = monitoredMentions;
1391
+ }
1392
+ // Shared repo-scoped exemption list (#2463): only set it when at least one VALID login survives
1393
+ // normalization, so a malformed block never blanks the DB-configured list via the resolver's overlay.
1394
+ if (r.autoCloseExemptLogins !== undefined) {
1395
+ const { logins, warnings: exemptWarnings } = normalizeAutoCloseExemptLogins(r.autoCloseExemptLogins);
1396
+ warnings.push(...exemptWarnings);
1397
+ if (logins.length > 0)
1398
+ out.autoCloseExemptLogins = logins;
1399
+ }
1400
+ // Hard manual-review guardrails are config-as-code only. Arrays replace lower layers wholesale, so only an
1401
+ // explicit [] or a non-empty valid list replaces a private global setting. Null/malformed values are ignored
1402
+ // instead of clearing.
1403
+ if (Array.isArray(r.hardGuardrailGlobs)) {
1404
+ const hardGuardrailGlobs = normalizeStringList(r.hardGuardrailGlobs, "settings.hardGuardrailGlobs", warnings);
1405
+ if (r.hardGuardrailGlobs.length === 0 || hardGuardrailGlobs.length > 0) {
1406
+ out.hardGuardrailGlobs = hardGuardrailGlobs;
1407
+ }
1408
+ else {
1409
+ warnings.push(`Manifest "settings.hardGuardrailGlobs" did not contain any valid path globs; ignoring it and keeping any existing guardrails.`);
1410
+ }
1411
+ }
1412
+ else if (r.hardGuardrailGlobs !== undefined) {
1413
+ warnings.push(`Manifest "settings.hardGuardrailGlobs" must be an array of path globs; ignoring it and keeping any existing guardrails.`);
1414
+ }
1415
+ // Manual-review label is deliberately separate from review_state_label so operators can use one hold label
1416
+ // without enabling the old ready/changes disposition labels. Null disables only the label, not the hold.
1417
+ if (r.manualReviewLabel === null) {
1418
+ out.manualReviewLabel = null;
1419
+ }
1420
+ else {
1421
+ const manualReviewLabel = normalizeOptionalString(r.manualReviewLabel, "settings.manualReviewLabel", warnings);
1422
+ if (manualReviewLabel !== null)
1423
+ out.manualReviewLabel = manualReviewLabel;
1424
+ }
1425
+ if (r.readyToMergeLabel === null) {
1426
+ out.readyToMergeLabel = null;
1427
+ }
1428
+ else {
1429
+ const readyToMergeLabel = normalizeOptionalString(r.readyToMergeLabel, "settings.readyToMergeLabel", warnings);
1430
+ if (readyToMergeLabel !== null)
1431
+ out.readyToMergeLabel = readyToMergeLabel;
1432
+ }
1433
+ if (r.changesRequestedLabel === null) {
1434
+ out.changesRequestedLabel = null;
1435
+ }
1436
+ else {
1437
+ const changesRequestedLabel = normalizeOptionalString(r.changesRequestedLabel, "settings.changesRequestedLabel", warnings);
1438
+ if (changesRequestedLabel !== null)
1439
+ out.changesRequestedLabel = changesRequestedLabel;
1440
+ }
1441
+ if (r.migrationCollisionLabel === null) {
1442
+ out.migrationCollisionLabel = null;
1443
+ }
1444
+ else {
1445
+ const migrationCollisionLabel = normalizeOptionalString(r.migrationCollisionLabel, "settings.migrationCollisionLabel", warnings);
1446
+ if (migrationCollisionLabel !== null)
1447
+ out.migrationCollisionLabel = migrationCollisionLabel;
1448
+ }
1449
+ if (r.pendingClosureLabel === null) {
1450
+ out.pendingClosureLabel = null;
1451
+ }
1452
+ else {
1453
+ const pendingClosureLabel = normalizeOptionalString(r.pendingClosureLabel, "settings.pendingClosureLabel", warnings);
1454
+ if (pendingClosureLabel !== null)
1455
+ out.pendingClosureLabel = pendingClosureLabel;
1456
+ }
1457
+ // Account-age throttle (#2561): an explicit yml `null` is load-bearing (clears a DB-configured threshold
1458
+ // back to "off"), matching contributorOpenPrCap's own null-vs-omitted distinction above.
1459
+ if (r.accountAgeThresholdDays === null) {
1460
+ out.accountAgeThresholdDays = null;
1461
+ }
1462
+ else {
1463
+ const accountAgeThresholdDays = normalizeOptionalPositiveInteger(r.accountAgeThresholdDays, "settings.accountAgeThresholdDays", warnings);
1464
+ if (accountAgeThresholdDays !== null)
1465
+ out.accountAgeThresholdDays = accountAgeThresholdDays;
1466
+ }
1467
+ const newAccountLabel = normalizeOptionalString(r.newAccountLabel, "settings.newAccountLabel", warnings);
1468
+ if (newAccountLabel !== null)
1469
+ out.newAccountLabel = newAccountLabel;
1470
+ // Per-command @gittensory rate limit (#2560): generalizes review-nag's cooldown pattern to every command.
1471
+ const commandRateLimitPolicy = normalizeOptionalEnum(r.commandRateLimitPolicy, "settings.commandRateLimitPolicy", ["off", "hold"], warnings);
1472
+ if (commandRateLimitPolicy !== null)
1473
+ out.commandRateLimitPolicy = commandRateLimitPolicy;
1474
+ const commandRateLimitMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitMaxPerWindow, "settings.commandRateLimitMaxPerWindow", warnings);
1475
+ if (commandRateLimitMaxPerWindow !== null)
1476
+ out.commandRateLimitMaxPerWindow = commandRateLimitMaxPerWindow;
1477
+ const commandRateLimitAiMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitAiMaxPerWindow, "settings.commandRateLimitAiMaxPerWindow", warnings);
1478
+ if (commandRateLimitAiMaxPerWindow !== null)
1479
+ out.commandRateLimitAiMaxPerWindow = commandRateLimitAiMaxPerWindow;
1480
+ const commandRateLimitWindowHours = normalizeOptionalPositiveInteger(r.commandRateLimitWindowHours, "settings.commandRateLimitWindowHours", warnings);
1481
+ if (commandRateLimitWindowHours !== null)
1482
+ out.commandRateLimitWindowHours = commandRateLimitWindowHours;
1483
+ // Moderation-rules engine (#selfhost-mod-engine): per-repo override of the global moderation config.
1484
+ const moderationGateMode = normalizeOptionalEnum(r.moderationGateMode, "settings.moderationGateMode", ["inherit", "off", "enabled"], warnings);
1485
+ if (moderationGateMode !== null)
1486
+ out.moderationGateMode = moderationGateMode;
1487
+ // #gate-flagged: normalizeModerationRules returns an EMPTY rules array for two semantically different
1488
+ // inputs -- a genuinely empty yml list (`moderationRules: []`, an intentional "opt every rule out for this
1489
+ // repo") and a MALFORMED one (a non-array, or an array where every entry fails validation) that degrades to
1490
+ // empty as its safe fallback. Applying the malformed case as an override would silently disable every rule
1491
+ // for this repo instead of leaving the DB-configured value intact, so the two must be told apart by the RAW
1492
+ // input's own shape -- not just the normalized result -- before assigning. A PARTIAL list (some valid, some
1493
+ // invalid entries) still applies the surviving valid subset, mirroring autoCloseExemptLogins' behavior.
1494
+ if (r.moderationRules !== undefined) {
1495
+ const { rules, warnings: moderationRuleWarnings } = normalizeModerationRules(r.moderationRules);
1496
+ warnings.push(...moderationRuleWarnings);
1497
+ const intentionalEmptyList = Array.isArray(r.moderationRules) && r.moderationRules.length === 0;
1498
+ if (rules.length > 0 || intentionalEmptyList)
1499
+ out.moderationRules = rules;
1500
+ }
1501
+ const moderationWarningLabel = normalizeModerationLabel(r.moderationWarningLabel);
1502
+ if (moderationWarningLabel !== undefined)
1503
+ out.moderationWarningLabel = moderationWarningLabel;
1504
+ const moderationBannedLabel = normalizeModerationLabel(r.moderationBannedLabel);
1505
+ if (moderationBannedLabel !== undefined)
1506
+ out.moderationBannedLabel = moderationBannedLabel;
1507
+ // Review-evasion protection (#review-evasion-protection): a contributor closing/converting-to-draft their
1508
+ // own PR while gittensory has an active review pass running is dodging the one-shot review.
1509
+ const reviewEvasionProtection = normalizeOptionalEnum(r.reviewEvasionProtection, "settings.reviewEvasionProtection", ["off", "close"], warnings);
1510
+ if (reviewEvasionProtection !== null)
1511
+ out.reviewEvasionProtection = reviewEvasionProtection;
1512
+ // #label-scoping: same load-bearing-null idiom as blacklistLabel above.
1513
+ if (r.reviewEvasionLabel === null) {
1514
+ out.reviewEvasionLabel = null;
1515
+ }
1516
+ else {
1517
+ const reviewEvasionLabel = normalizeOptionalString(r.reviewEvasionLabel, "settings.reviewEvasionLabel", warnings);
1518
+ if (reviewEvasionLabel !== null)
1519
+ out.reviewEvasionLabel = reviewEvasionLabel;
1520
+ }
1521
+ const reviewEvasionComment = normalizeOptionalBoolean(r.reviewEvasionComment, "settings.reviewEvasionComment", warnings);
1522
+ if (reviewEvasionComment !== null)
1523
+ out.reviewEvasionComment = reviewEvasionComment;
1524
+ const mergeTrainMode = normalizeOptionalEnum(r.mergeTrainMode, "settings.mergeTrainMode", ["off", "audit", "enforce"], warnings);
1525
+ if (mergeTrainMode !== null)
1526
+ out.mergeTrainMode = mergeTrainMode;
1527
+ return out;
1528
+ }
1529
+ /** Serialize the settings override for the cache round-trip; returns null when nothing is set. */
1530
+ export function settingsOverrideToJson(settings) {
1531
+ if (Object.keys(settings).length === 0)
1532
+ return null;
1533
+ return { ...settings };
1534
+ }
1535
+ /** A bounded, PUBLIC-SAFE maintainer string (footer/note). Trimmed, length-capped, and rejected with a
1536
+ * warning if it contains any forbidden public term — it is then dropped, never published. */
1537
+ function parsePublicSafeText(value, field, warnings) {
1538
+ const text = normalizeOptionalString(value, field, warnings);
1539
+ if (text === null)
1540
+ return null;
1541
+ const bounded = text.length > MAX_ITEM_LENGTH ? text.slice(0, MAX_ITEM_LENGTH) : text;
1542
+ if (!isFocusManifestPublicSafe(bounded)) {
1543
+ warnings.push(`Manifest "${field}" contains content that is not public-safe; ignoring it.`);
1544
+ return null;
1545
+ }
1546
+ return bounded;
1547
+ }
1548
+ /**
1549
+ * Parse the optional `review:` block — maintainer overrides for the public review-panel content. Never
1550
+ * throws; invalid/unsafe values are dropped with warnings.
1551
+ */
1552
+ function parseReviewConfig(value, warnings) {
1553
+ const empty = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null };
1554
+ if (value === undefined || value === null)
1555
+ return empty;
1556
+ if (typeof value !== "object" || Array.isArray(value)) {
1557
+ warnings.push(`Manifest field "review" must be a mapping; ignoring it.`);
1558
+ return empty;
1559
+ }
1560
+ const r = value;
1561
+ const footerRecord = r.footer !== null && typeof r.footer === "object" && !Array.isArray(r.footer) ? r.footer : undefined;
1562
+ if (r.footer !== undefined && r.footer !== null && footerRecord === undefined)
1563
+ warnings.push(`Manifest "review.footer" must be a mapping; ignoring it.`);
1564
+ const fieldsRecord = r.fields !== null && typeof r.fields === "object" && !Array.isArray(r.fields) ? r.fields : undefined;
1565
+ if (r.fields !== undefined && r.fields !== null && fieldsRecord === undefined)
1566
+ warnings.push(`Manifest "review.fields" must be a mapping; ignoring it.`);
1567
+ const fields = {};
1568
+ if (fieldsRecord) {
1569
+ for (const key of REVIEW_FIELD_KEYS) {
1570
+ const flag = normalizeOptionalBoolean(fieldsRecord[key], `review.fields.${key}`, warnings);
1571
+ if (flag !== null)
1572
+ fields[key] = flag;
1573
+ }
1574
+ }
1575
+ const enrichmentRecord = r.enrichment !== null && typeof r.enrichment === "object" && !Array.isArray(r.enrichment) ? r.enrichment : undefined;
1576
+ if (r.enrichment !== undefined && r.enrichment !== null && enrichmentRecord === undefined)
1577
+ warnings.push(`Manifest "review.enrichment" must be a mapping; ignoring it.`);
1578
+ const enrichmentAnalyzers = {};
1579
+ if (enrichmentRecord) {
1580
+ for (const key of Object.keys(enrichmentRecord)) {
1581
+ if (!REES_ANALYZER_NAME_SET.has(key)) {
1582
+ warnings.push(`Manifest "review.enrichment" has unknown analyzer "${key}"; ignoring it.`);
1583
+ continue;
1584
+ }
1585
+ const flag = normalizeOptionalBoolean(enrichmentRecord[key], `review.enrichment.${key}`, warnings);
1586
+ if (flag !== null)
1587
+ enrichmentAnalyzers[key] = flag;
1588
+ }
1589
+ }
1590
+ const footerText = footerRecord ? parsePublicSafeText(footerRecord.text, "review.footer.text", warnings) : null;
1591
+ const note = parsePublicSafeText(r.note, "review.note", warnings);
1592
+ const profile = parseReviewProfile(r.profile, warnings);
1593
+ const tone = parsePublicSafeText(r.tone, "review.tone", warnings);
1594
+ const securityFocus = normalizeOptionalBoolean(r.security_focus, "review.security_focus", warnings);
1595
+ const inlineComments = normalizeOptionalBoolean(r.inline_comments, "review.inline_comments", warnings);
1596
+ const fixHandoff = normalizeOptionalBoolean(r.fixHandoff, "review.fixHandoff", warnings);
1597
+ const autoMergeSummary = normalizeOptionalBoolean(r.auto_merge_summary, "review.auto_merge_summary", warnings);
1598
+ const suggestions = normalizeOptionalBoolean(r.suggestions, "review.suggestions", warnings);
1599
+ const changedFilesSummary = normalizeOptionalBoolean(r.changed_files_summary, "review.changed_files_summary", warnings);
1600
+ const effortScore = normalizeOptionalBoolean(r.effort_score, "review.effort_score", warnings);
1601
+ const impactMap = normalizeOptionalBoolean(r.impact_map, "review.impact_map", warnings);
1602
+ const cultureProfile = normalizeOptionalBoolean(r.culture_profile, "review.culture_profile", warnings);
1603
+ const selftune = normalizeOptionalBoolean(r.selftune, "review.selftune", warnings);
1604
+ const reviewMemory = normalizeOptionalBoolean(r.memory, "review.memory", warnings);
1605
+ const findingCategories = normalizeOptionalBoolean(r.finding_categories, "review.finding_categories", warnings);
1606
+ const inlineCommentsPerCategory = normalizeOptionalNonNegativeInt(r.inline_comments_per_category, "review.inline_comments_per_category", warnings);
1607
+ const minFindingSeverity = normalizeOptionalEnum(r.min_finding_severity, "review.min_finding_severity", REVIEW_FINDING_SEVERITY_LADDER, warnings);
1608
+ const maxFindings = parseMaxFindingsConfig(r.max_findings, warnings);
1609
+ const commentVerbosity = normalizeOptionalEnum(r.comment_verbosity, "review.comment_verbosity", COMMENT_VERBOSITY_LEVELS, warnings);
1610
+ const e2eTestDelivery = normalizeOptionalEnum(r.e2e_test_delivery, "review.e2e_test_delivery", E2E_TEST_DELIVERY_MODES, warnings);
1611
+ const e2eTestAutoTrigger = normalizeOptionalBoolean(r.e2e_test_auto_trigger, "review.e2e_test_auto_trigger", warnings);
1612
+ const pathInstructions = parseReviewPathInstructions(r.path_instructions, warnings);
1613
+ const instructions = parsePublicSafeText(r.instructions, "review.instructions", warnings);
1614
+ const excludePaths = parseReviewExcludePaths(r.exclude_paths, warnings);
1615
+ const pathFilters = parseReviewPathFilters(r.path_filters, warnings);
1616
+ const preMergeChecks = parseReviewPreMergeChecks(r.pre_merge_checks, warnings);
1617
+ const autoReview = parseAutoReviewConfig(r.auto_review, warnings);
1618
+ const aiModel = parseSelfHostAiModelConfig(r.ai_model, warnings);
1619
+ const visual = parseVisualConfig(r.visual, warnings);
1620
+ const linkedIssueSatisfaction = normalizeOptionalEnum(r.linkedIssueSatisfaction, "review.linkedIssueSatisfaction", LINKED_ISSUE_SATISFACTION_MODES, warnings);
1621
+ return {
1622
+ present: footerText !== null ||
1623
+ note !== null ||
1624
+ profile !== null ||
1625
+ tone !== null ||
1626
+ securityFocus !== null ||
1627
+ inlineComments !== null ||
1628
+ fixHandoff !== null ||
1629
+ autoMergeSummary !== null ||
1630
+ suggestions !== null ||
1631
+ changedFilesSummary !== null ||
1632
+ effortScore !== null ||
1633
+ impactMap !== null ||
1634
+ cultureProfile !== null ||
1635
+ selftune !== null ||
1636
+ reviewMemory !== null ||
1637
+ findingCategories !== null ||
1638
+ inlineCommentsPerCategory !== null ||
1639
+ minFindingSeverity !== null ||
1640
+ maxFindingsPresent(maxFindings) ||
1641
+ commentVerbosity !== null ||
1642
+ e2eTestDelivery !== null ||
1643
+ e2eTestAutoTrigger !== null ||
1644
+ pathInstructions.length > 0 ||
1645
+ instructions !== null ||
1646
+ excludePaths.length > 0 ||
1647
+ pathFilters.length > 0 ||
1648
+ preMergeChecks.length > 0 ||
1649
+ autoReviewPresent(autoReview) ||
1650
+ selfHostAiModelPresent(aiModel) ||
1651
+ visualConfigPresent(visual) ||
1652
+ linkedIssueSatisfaction !== null ||
1653
+ Object.keys(fields).length > 0 ||
1654
+ Object.keys(enrichmentAnalyzers).length > 0,
1655
+ footerText,
1656
+ note,
1657
+ fields,
1658
+ autoReview,
1659
+ aiModel,
1660
+ visual,
1661
+ linkedIssueSatisfaction,
1662
+ enrichmentAnalyzers,
1663
+ profile,
1664
+ tone,
1665
+ securityFocus,
1666
+ inlineComments,
1667
+ fixHandoff,
1668
+ autoMergeSummary,
1669
+ suggestions,
1670
+ changedFilesSummary,
1671
+ effortScore,
1672
+ impactMap,
1673
+ cultureProfile,
1674
+ selftune,
1675
+ reviewMemory,
1676
+ findingCategories,
1677
+ inlineCommentsPerCategory,
1678
+ minFindingSeverity,
1679
+ maxFindings,
1680
+ commentVerbosity,
1681
+ e2eTestDelivery,
1682
+ e2eTestAutoTrigger,
1683
+ pathInstructions,
1684
+ instructions,
1685
+ excludePaths,
1686
+ pathFilters,
1687
+ preMergeChecks,
1688
+ sharedConfigSource: null,
1689
+ };
1690
+ }
1691
+ function pickOverlayNullable(override, base) {
1692
+ return override !== null ? override : base;
1693
+ }
1694
+ function pickOverlayStringList(override, base) {
1695
+ return override.length > 0 ? [...override] : [...base];
1696
+ }
1697
+ function pickOverlayPartialRecord(override, base) {
1698
+ return { ...base, ...override };
1699
+ }
1700
+ function overlayMaxFindingsConfig(base, override) {
1701
+ return {
1702
+ blockers: pickOverlayNullable(override.blockers, base.blockers),
1703
+ nits: pickOverlayNullable(override.nits, base.nits),
1704
+ };
1705
+ }
1706
+ function overlayAutoReviewConfig(base, override) {
1707
+ return {
1708
+ skipDrafts: pickOverlayNullable(override.skipDrafts, base.skipDrafts),
1709
+ cadence: pickOverlayNullable(override.cadence, base.cadence),
1710
+ ignoreAuthors: pickOverlayStringList(override.ignoreAuthors, base.ignoreAuthors),
1711
+ ignoreTitleKeywords: pickOverlayStringList(override.ignoreTitleKeywords, base.ignoreTitleKeywords),
1712
+ skipLabels: pickOverlayStringList(override.skipLabels, base.skipLabels),
1713
+ skipDocsOnly: pickOverlayNullable(override.skipDocsOnly, base.skipDocsOnly),
1714
+ maxAddedLines: override.maxAddedLines > 0 ? override.maxAddedLines : base.maxAddedLines,
1715
+ maxFiles: override.maxFiles > 0 ? override.maxFiles : base.maxFiles,
1716
+ baseBranches: pickOverlayStringList(override.baseBranches, base.baseBranches),
1717
+ autoPauseAfterReviewedCommits: pickOverlayNullable(override.autoPauseAfterReviewedCommits, base.autoPauseAfterReviewedCommits),
1718
+ };
1719
+ }
1720
+ function overlaySelfHostAiModelConfig(base, override) {
1721
+ return {
1722
+ claudeModel: pickOverlayNullable(override.claudeModel, base.claudeModel),
1723
+ claudeEffort: pickOverlayNullable(override.claudeEffort, base.claudeEffort),
1724
+ codexModel: pickOverlayNullable(override.codexModel, base.codexModel),
1725
+ codexEffort: pickOverlayNullable(override.codexEffort, base.codexEffort),
1726
+ ollamaModel: pickOverlayNullable(override.ollamaModel, base.ollamaModel),
1727
+ openaiModel: pickOverlayNullable(override.openaiModel, base.openaiModel),
1728
+ openaiCompatibleModel: pickOverlayNullable(override.openaiCompatibleModel, base.openaiCompatibleModel),
1729
+ anthropicModel: pickOverlayNullable(override.anthropicModel, base.anthropicModel),
1730
+ };
1731
+ }
1732
+ function overlayVisualConfig(base, override) {
1733
+ return {
1734
+ productionUrl: pickOverlayNullable(override.productionUrl, base.productionUrl),
1735
+ preview: { urlTemplate: pickOverlayNullable(override.preview.urlTemplate, base.preview.urlTemplate) },
1736
+ routes: {
1737
+ paths: pickOverlayStringList(override.routes.paths, base.routes.paths),
1738
+ maxRoutes: pickOverlayNullable(override.routes.maxRoutes, base.routes.maxRoutes),
1739
+ },
1740
+ themes: override.themes.length > 0 ? [...override.themes] : [...base.themes],
1741
+ gif: override.gif ? override.gif : base.gif,
1742
+ enabled: pickOverlayNullable(override.enabled, base.enabled),
1743
+ themeStorageKey: pickOverlayNullable(override.themeStorageKey, base.themeStorageKey),
1744
+ actionsFallback: override.actionsFallback ? override.actionsFallback : base.actionsFallback,
1745
+ };
1746
+ }
1747
+ function computeReviewConfigPresent(review) {
1748
+ return (review.footerText !== null ||
1749
+ review.note !== null ||
1750
+ review.profile !== null ||
1751
+ review.tone !== null ||
1752
+ review.securityFocus !== null ||
1753
+ review.inlineComments !== null ||
1754
+ review.fixHandoff !== null ||
1755
+ review.autoMergeSummary !== null ||
1756
+ review.suggestions !== null ||
1757
+ review.changedFilesSummary !== null ||
1758
+ review.effortScore !== null ||
1759
+ review.impactMap !== null ||
1760
+ review.cultureProfile !== null ||
1761
+ review.selftune !== null ||
1762
+ review.reviewMemory !== null ||
1763
+ review.findingCategories !== null ||
1764
+ review.inlineCommentsPerCategory !== null ||
1765
+ review.minFindingSeverity !== null ||
1766
+ maxFindingsPresent(review.maxFindings) ||
1767
+ review.commentVerbosity !== null ||
1768
+ review.e2eTestDelivery !== null ||
1769
+ review.e2eTestAutoTrigger !== null ||
1770
+ review.pathInstructions.length > 0 ||
1771
+ review.instructions !== null ||
1772
+ review.excludePaths.length > 0 ||
1773
+ review.pathFilters.length > 0 ||
1774
+ review.preMergeChecks.length > 0 ||
1775
+ autoReviewPresent(review.autoReview) ||
1776
+ selfHostAiModelPresent(review.aiModel) ||
1777
+ visualConfigPresent(review.visual) ||
1778
+ review.linkedIssueSatisfaction !== null ||
1779
+ Object.keys(review.fields).length > 0 ||
1780
+ Object.keys(review.enrichmentAnalyzers).length > 0);
1781
+ }
1782
+ /** Overlay a higher-priority `review:` config onto a shared/base layer (#2046). Per-field: override wins when set;
1783
+ * base fills gaps; defaults stay byte-identical. `sharedConfigSource` on the override is preserved when present. */
1784
+ export function overlayReviewConfig(base, override) {
1785
+ const merged = {
1786
+ footerText: pickOverlayNullable(override.footerText, base.footerText),
1787
+ note: pickOverlayNullable(override.note, base.note),
1788
+ fields: pickOverlayPartialRecord(override.fields, base.fields),
1789
+ enrichmentAnalyzers: pickOverlayPartialRecord(override.enrichmentAnalyzers, base.enrichmentAnalyzers),
1790
+ profile: pickOverlayNullable(override.profile, base.profile),
1791
+ tone: pickOverlayNullable(override.tone, base.tone),
1792
+ securityFocus: pickOverlayNullable(override.securityFocus, base.securityFocus),
1793
+ inlineComments: pickOverlayNullable(override.inlineComments, base.inlineComments),
1794
+ fixHandoff: pickOverlayNullable(override.fixHandoff, base.fixHandoff),
1795
+ autoMergeSummary: pickOverlayNullable(override.autoMergeSummary, base.autoMergeSummary),
1796
+ suggestions: pickOverlayNullable(override.suggestions, base.suggestions),
1797
+ changedFilesSummary: pickOverlayNullable(override.changedFilesSummary, base.changedFilesSummary),
1798
+ effortScore: pickOverlayNullable(override.effortScore, base.effortScore),
1799
+ impactMap: pickOverlayNullable(override.impactMap, base.impactMap),
1800
+ cultureProfile: pickOverlayNullable(override.cultureProfile, base.cultureProfile),
1801
+ selftune: pickOverlayNullable(override.selftune, base.selftune),
1802
+ reviewMemory: pickOverlayNullable(override.reviewMemory, base.reviewMemory),
1803
+ findingCategories: pickOverlayNullable(override.findingCategories, base.findingCategories),
1804
+ inlineCommentsPerCategory: pickOverlayNullable(override.inlineCommentsPerCategory, base.inlineCommentsPerCategory),
1805
+ minFindingSeverity: pickOverlayNullable(override.minFindingSeverity, base.minFindingSeverity),
1806
+ maxFindings: overlayMaxFindingsConfig(base.maxFindings, override.maxFindings),
1807
+ commentVerbosity: pickOverlayNullable(override.commentVerbosity, base.commentVerbosity),
1808
+ e2eTestDelivery: pickOverlayNullable(override.e2eTestDelivery, base.e2eTestDelivery),
1809
+ e2eTestAutoTrigger: pickOverlayNullable(override.e2eTestAutoTrigger, base.e2eTestAutoTrigger),
1810
+ pathInstructions: override.pathInstructions.length > 0 ? [...override.pathInstructions] : [...base.pathInstructions],
1811
+ instructions: pickOverlayNullable(override.instructions, base.instructions),
1812
+ excludePaths: pickOverlayStringList(override.excludePaths, base.excludePaths),
1813
+ pathFilters: pickOverlayStringList(override.pathFilters, base.pathFilters),
1814
+ preMergeChecks: override.preMergeChecks.length > 0 ? [...override.preMergeChecks] : [...base.preMergeChecks],
1815
+ autoReview: overlayAutoReviewConfig(base.autoReview, override.autoReview),
1816
+ aiModel: overlaySelfHostAiModelConfig(base.aiModel, override.aiModel),
1817
+ visual: overlayVisualConfig(base.visual, override.visual),
1818
+ linkedIssueSatisfaction: pickOverlayNullable(override.linkedIssueSatisfaction, base.linkedIssueSatisfaction),
1819
+ sharedConfigSource: override.sharedConfigSource ?? base.sharedConfigSource,
1820
+ present: false,
1821
+ };
1822
+ merged.present = computeReviewConfigPresent(merged);
1823
+ return merged;
1824
+ }
1825
+ /** Parse a raw `review:` mapping value. Exported for the private-config shared overlay (#2046). */
1826
+ export function parseReviewConfigMapping(value, warnings) {
1827
+ return parseReviewConfig(value, warnings);
1828
+ }
1829
+ function maxFindingsPresent(config) {
1830
+ return config.blockers !== null || config.nits !== null;
1831
+ }
1832
+ /** Parse `review.max_findings` — optional non-negative caps for blockers/nits display in the unified comment. */
1833
+ function parseMaxFindingsConfig(value, warnings) {
1834
+ if (value === undefined || value === null)
1835
+ return { ...EMPTY_MAX_FINDINGS_CONFIG };
1836
+ if (typeof value !== "object" || Array.isArray(value)) {
1837
+ warnings.push(`Manifest "review.max_findings" must be a mapping; ignoring it.`);
1838
+ return { ...EMPTY_MAX_FINDINGS_CONFIG };
1839
+ }
1840
+ const record = value;
1841
+ return {
1842
+ blockers: normalizeOptionalNonNegativeInt(record.blockers, "review.max_findings.blockers", warnings),
1843
+ nits: normalizeOptionalNonNegativeInt(record.nits, "review.max_findings.nits", warnings),
1844
+ };
1845
+ }
1846
+ function autoReviewPresent(config) {
1847
+ return (config.skipDrafts !== null ||
1848
+ config.cadence !== null ||
1849
+ config.ignoreAuthors.length > 0 ||
1850
+ config.ignoreTitleKeywords.length > 0 ||
1851
+ config.skipLabels.length > 0 ||
1852
+ config.skipDocsOnly !== null ||
1853
+ config.maxAddedLines > 0 ||
1854
+ config.maxFiles > 0 ||
1855
+ config.baseBranches.length > 0 ||
1856
+ config.autoPauseAfterReviewedCommits !== null);
1857
+ }
1858
+ /** Parse `review.auto_review` — deterministic AI review eligibility filters. (#1954 / #2038–#2041) */
1859
+ function parseAutoReviewConfig(value, warnings) {
1860
+ if (value === undefined || value === null)
1861
+ return { ...EMPTY_AUTO_REVIEW_CONFIG };
1862
+ if (typeof value !== "object" || Array.isArray(value)) {
1863
+ warnings.push(`Manifest field "review.auto_review" must be a mapping; ignoring it.`);
1864
+ return { ...EMPTY_AUTO_REVIEW_CONFIG };
1865
+ }
1866
+ const record = value;
1867
+ return {
1868
+ skipDrafts: normalizeOptionalBoolean(record.skip_drafts, "review.auto_review.skip_drafts", warnings),
1869
+ cadence: normalizeOptionalEnum(record.cadence, "review.auto_review.cadence", AI_REVIEW_CADENCES, warnings),
1870
+ ignoreAuthors: parseManifestGlobList(record.ignore_authors, "review.auto_review.ignore_authors", warnings),
1871
+ ignoreTitleKeywords: parseAutoReviewTitleKeywords(record.ignore_title_keywords, warnings),
1872
+ skipLabels: parseAutoReviewSkipLabels(record.skip_labels, warnings),
1873
+ skipDocsOnly: normalizeOptionalBoolean(record.skip_docs_only, "review.auto_review.skip_docs_only", warnings),
1874
+ maxAddedLines: normalizeAutoReviewSizeCap(record.max_added_lines, "review.auto_review.max_added_lines", warnings),
1875
+ maxFiles: normalizeAutoReviewSizeCap(record.max_files, "review.auto_review.max_files", warnings),
1876
+ baseBranches: parseManifestGlobList(record.base_branches, "review.auto_review.base_branches", warnings),
1877
+ autoPauseAfterReviewedCommits: normalizeOptionalNonNegativeInt(record.auto_pause_after_reviewed_commits, "review.auto_review.auto_pause_after_reviewed_commits", warnings),
1878
+ };
1879
+ }
1880
+ function selfHostAiModelPresent(config) {
1881
+ return (config.claudeModel !== null ||
1882
+ config.claudeEffort !== null ||
1883
+ config.codexModel !== null ||
1884
+ config.codexEffort !== null ||
1885
+ config.ollamaModel !== null ||
1886
+ config.openaiModel !== null ||
1887
+ config.openaiCompatibleModel !== null ||
1888
+ config.anthropicModel !== null);
1889
+ }
1890
+ /** Parse `review.ai_model` — per-repo self-host reviewer model/effort overrides. Values are opaque, bounded,
1891
+ * public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum here, so this
1892
+ * parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid effort value
1893
+ * degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve time).
1894
+ * (#selfhost-ai-model-override) */
1895
+ function parseSelfHostAiModelConfig(value, warnings) {
1896
+ if (value === undefined || value === null)
1897
+ return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG };
1898
+ if (typeof value !== "object" || Array.isArray(value)) {
1899
+ warnings.push(`Manifest field "review.ai_model" must be a mapping; ignoring it.`);
1900
+ return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG };
1901
+ }
1902
+ const record = value;
1903
+ return {
1904
+ claudeModel: parsePublicSafeText(record.claude_model, "review.ai_model.claude_model", warnings),
1905
+ claudeEffort: parsePublicSafeText(record.claude_effort, "review.ai_model.claude_effort", warnings),
1906
+ codexModel: parsePublicSafeText(record.codex_model, "review.ai_model.codex_model", warnings),
1907
+ codexEffort: parsePublicSafeText(record.codex_effort, "review.ai_model.codex_effort", warnings),
1908
+ ollamaModel: parsePublicSafeText(record.ollama_model, "review.ai_model.ollama_model", warnings),
1909
+ openaiModel: parsePublicSafeText(record.openai_model, "review.ai_model.openai_model", warnings),
1910
+ openaiCompatibleModel: parsePublicSafeText(record.openai_compatible_model, "review.ai_model.openai_compatible_model", warnings),
1911
+ anthropicModel: parsePublicSafeText(record.anthropic_model, "review.ai_model.anthropic_model", warnings),
1912
+ };
1913
+ }
1914
+ function visualConfigPresent(config) {
1915
+ return (config.productionUrl !== null ||
1916
+ config.preview.urlTemplate !== null ||
1917
+ config.routes.paths.length > 0 ||
1918
+ config.routes.maxRoutes !== null ||
1919
+ config.themes.length > 0 ||
1920
+ config.gif ||
1921
+ config.enabled !== null ||
1922
+ config.themeStorageKey !== null ||
1923
+ config.actionsFallback);
1924
+ }
1925
+ const VISUAL_THEME_VALUES = ["light", "dark"];
1926
+ /** Parse `review.visual.themes` — which `prefers-color-scheme` variants to capture (#3678). Empty/default ⇒
1927
+ * the capture pipeline falls back to a single light-theme render, byte-identical to today. Unlike
1928
+ * `routes.paths` (an open-ended glob list), this is a closed 2-value enum, so entries are validated against
1929
+ * it directly rather than reusing the generic glob-list parser. */
1930
+ function parseVisualThemes(value, warnings) {
1931
+ if (value === undefined || value === null)
1932
+ return [];
1933
+ if (!Array.isArray(value)) {
1934
+ warnings.push(`Manifest "review.visual.themes" must be a list of "light"/"dark"; ignoring it.`);
1935
+ return [];
1936
+ }
1937
+ const out = [];
1938
+ for (const [index, entry] of value.entries()) {
1939
+ const theme = typeof entry === "string" ? entry.trim().toLowerCase() : undefined;
1940
+ if (!theme || !VISUAL_THEME_VALUES.includes(theme)) {
1941
+ warnings.push(`Manifest "review.visual.themes[${index}]" must be "light" or "dark"; ignoring it.`);
1942
+ continue;
1943
+ }
1944
+ if (!out.includes(theme))
1945
+ out.push(theme);
1946
+ }
1947
+ return out;
1948
+ }
1949
+ // `{number}`/`{head_sha}`/`{head_sha_short}` are GitHub-controlled facts about the PR (never attacker-supplied
1950
+ // free text), so substitution itself carries no injection risk. The dummy values here exist only to make the
1951
+ // TEMPLATE STRING (which a maintainer authored, and could still typo) validate as a well-formed HTTPS URL
1952
+ // before it's ever used — see parseVisualUrlTemplate below.
1953
+ const VISUAL_URL_TEMPLATE_DUMMY_VARS = {
1954
+ "{number}": "1",
1955
+ "{head_sha_short}": "0000000",
1956
+ "{head_sha}": "0000000000000000000000000000000000000000",
1957
+ };
1958
+ /** Parse `review.visual.production_url` — validated at CONFIG-READ time against the exact same SSRF guard
1959
+ * (`isSafeHttpUrl`) the renderer itself unconditionally applies to every URL it navigates to. Unlike
1960
+ * `preview.url_template`, this is a plain static origin with no `{number}`/`{head_sha}` placeholders to
1961
+ * substitute — the "before" shot is always the SAME production page, just at a different path per route. */
1962
+ function parseVisualProductionUrl(value, warnings) {
1963
+ const url = parsePublicSafeText(value, "review.visual.production_url", warnings);
1964
+ if (url === null)
1965
+ return null;
1966
+ if (!isSafeHttpUrl(url)) {
1967
+ warnings.push(`Manifest "review.visual.production_url" must be a valid HTTPS URL targeting a public host; ignoring it.`);
1968
+ return null;
1969
+ }
1970
+ return url;
1971
+ }
1972
+ /** Parse `review.visual.preview.url_template` — validated at CONFIG-READ time against the exact same SSRF
1973
+ * guard (`isSafeHttpUrl`) the renderer itself unconditionally applies to every URL it navigates to,
1974
+ * regardless of source (`src/review/visual/shot.ts`). This is deliberately redundant with that runtime
1975
+ * check, not a replacement for it — it exists so a maintainer sees a warning immediately for a malformed
1976
+ * template (e.g. a typo'd scheme, or an accidental internal host) instead of only discovering it later as
1977
+ * a silently-blank "after" cell. Placeholders are substituted with dummy values before validation since the
1978
+ * raw template (e.g. `https://pr-{number}.example.com`) is not itself a parseable URL. */
1979
+ function parseVisualUrlTemplate(value, warnings) {
1980
+ const template = parsePublicSafeText(value, "review.visual.preview.url_template", warnings);
1981
+ if (template === null)
1982
+ return null;
1983
+ let probe = template;
1984
+ for (const [placeholder, dummy] of Object.entries(VISUAL_URL_TEMPLATE_DUMMY_VARS))
1985
+ probe = probe.split(placeholder).join(dummy);
1986
+ if (!isSafeHttpUrl(probe)) {
1987
+ warnings.push(`Manifest "review.visual.preview.url_template" must be a valid HTTPS URL (with {number}/{head_sha}/{head_sha_short} placeholders substituted) targeting a public host; ignoring it.`);
1988
+ return null;
1989
+ }
1990
+ return template;
1991
+ }
1992
+ /** Parse `review.visual` — per-repo before/after screenshot-capture config (#3609 preview / #3610 routes /
1993
+ * #3678 themes). */
1994
+ function parseVisualConfig(value, warnings) {
1995
+ if (value === undefined || value === null)
1996
+ return { ...EMPTY_VISUAL_CONFIG };
1997
+ if (typeof value !== "object" || Array.isArray(value)) {
1998
+ warnings.push(`Manifest field "review.visual" must be a mapping; ignoring it.`);
1999
+ return { ...EMPTY_VISUAL_CONFIG };
2000
+ }
2001
+ const record = value;
2002
+ const productionUrl = parseVisualProductionUrl(record.production_url, warnings);
2003
+ const previewRecord = record.preview !== null && typeof record.preview === "object" && !Array.isArray(record.preview) ? record.preview : undefined;
2004
+ if (record.preview !== undefined && record.preview !== null && previewRecord === undefined) {
2005
+ warnings.push(`Manifest "review.visual.preview" must be a mapping; ignoring it.`);
2006
+ }
2007
+ const urlTemplate = previewRecord ? parseVisualUrlTemplate(previewRecord.url_template, warnings) : null;
2008
+ const routesRecord = record.routes !== null && typeof record.routes === "object" && !Array.isArray(record.routes) ? record.routes : undefined;
2009
+ if (record.routes !== undefined && record.routes !== null && routesRecord === undefined) {
2010
+ warnings.push(`Manifest "review.visual.routes" must be a mapping; ignoring it.`);
2011
+ }
2012
+ const paths = routesRecord ? parseManifestGlobList(routesRecord.paths, "review.visual.routes.paths", warnings) : [];
2013
+ const maxRoutes = routesRecord ? normalizeOptionalVisualMaxRoutes(routesRecord.max_routes, warnings) : null;
2014
+ const themes = parseVisualThemes(record.themes, warnings);
2015
+ const gif = normalizeOptionalBoolean(record.gif, "review.visual.gif", warnings) === true;
2016
+ const enabled = normalizeOptionalBoolean(record.enabled, "review.visual.enabled", warnings);
2017
+ const themeStorageKey = parsePublicSafeText(record.theme_storage_key, "review.visual.theme_storage_key", warnings);
2018
+ const actionsFallback = normalizeOptionalBoolean(record.actions_fallback, "review.visual.actions_fallback", warnings) === true;
2019
+ return { productionUrl, preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled, themeStorageKey, actionsFallback };
2020
+ }
2021
+ function parseAutoReviewTitleKeywords(value, warnings) {
2022
+ if (value === undefined || value === null)
2023
+ return [];
2024
+ if (!Array.isArray(value)) {
2025
+ warnings.push(`Manifest "review.auto_review.ignore_title_keywords" must be a list of strings; ignoring it.`);
2026
+ return [];
2027
+ }
2028
+ const out = [];
2029
+ for (const [index, entry] of value.entries()) {
2030
+ if (out.length >= MAX_PATH_INSTRUCTIONS) {
2031
+ warnings.push(`Manifest "review.auto_review.ignore_title_keywords" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
2032
+ break;
2033
+ }
2034
+ const raw = typeof entry === "string" ? entry.trim() : "";
2035
+ if (!raw) {
2036
+ warnings.push(`Manifest "review.auto_review.ignore_title_keywords[${index}]" must be a non-empty string; ignoring it.`);
2037
+ continue;
2038
+ }
2039
+ const safe = parsePublicSafeText(raw, `review.auto_review.ignore_title_keywords[${index}]`, warnings);
2040
+ if (safe !== null)
2041
+ out.push(safe);
2042
+ }
2043
+ return out;
2044
+ }
2045
+ function parseAutoReviewSkipLabels(value, warnings) {
2046
+ if (value === undefined || value === null)
2047
+ return [];
2048
+ if (!Array.isArray(value)) {
2049
+ warnings.push(`Manifest "review.auto_review.skip_labels" must be a list of strings; ignoring it.`);
2050
+ return [];
2051
+ }
2052
+ const seen = new Set();
2053
+ const out = [];
2054
+ for (const [index, entry] of value.entries()) {
2055
+ if (out.length >= MAX_PATH_INSTRUCTIONS) {
2056
+ warnings.push(`Manifest "review.auto_review.skip_labels" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
2057
+ break;
2058
+ }
2059
+ const raw = typeof entry === "string" ? entry.trim() : "";
2060
+ if (!raw) {
2061
+ warnings.push(`Manifest "review.auto_review.skip_labels[${index}]" must be a non-empty string; ignoring it.`);
2062
+ continue;
2063
+ }
2064
+ const safe = parsePublicSafeText(raw, `review.auto_review.skip_labels[${index}]`, warnings);
2065
+ if (safe === null)
2066
+ continue;
2067
+ const key = safe.toLowerCase();
2068
+ if (seen.has(key))
2069
+ continue;
2070
+ seen.add(key);
2071
+ out.push(key);
2072
+ }
2073
+ return out;
2074
+ }
2075
+ /** Parse `review.pre_merge_checks` — an array of DETERMINISTIC pre-merge assertions. Each entry needs a non-empty
2076
+ * public-safe `name` and at least ONE assertion (`title_contains` / `description_contains` / `require_label`,
2077
+ * each public-safe); `when_paths` (optional) gates the check to PRs touching a matching glob; `enforce` (default
2078
+ * false) makes a failure a hard blocker. Invalid entries are dropped with a warning; capped at
2079
+ * MAX_PATH_INSTRUCTIONS so a hostile manifest can't bloat the gate. (#review-pre-merge-checks) */
2080
+ function parseReviewPreMergeChecks(value, warnings) {
2081
+ if (value === undefined || value === null)
2082
+ return [];
2083
+ if (!Array.isArray(value)) {
2084
+ warnings.push(`Manifest "review.pre_merge_checks" must be a list of checks; ignoring it.`);
2085
+ return [];
2086
+ }
2087
+ const out = [];
2088
+ for (const [index, entry] of value.entries()) {
2089
+ if (out.length >= MAX_PATH_INSTRUCTIONS) {
2090
+ warnings.push(`Manifest "review.pre_merge_checks" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
2091
+ break;
2092
+ }
2093
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
2094
+ warnings.push(`Manifest "review.pre_merge_checks[${index}]" must be a mapping; ignoring it.`);
2095
+ continue;
2096
+ }
2097
+ const e = entry;
2098
+ if (e.name === undefined || e.name === null) {
2099
+ warnings.push(`Manifest "review.pre_merge_checks[${index}].name" is required; ignoring the entry.`);
2100
+ continue;
2101
+ }
2102
+ const name = parsePublicSafeText(e.name, `review.pre_merge_checks[${index}].name`, warnings);
2103
+ if (name === null)
2104
+ continue; // non-string / empty / not-public-safe → already warned
2105
+ const titleContains = e.title_contains === undefined || e.title_contains === null ? null : parsePublicSafeText(e.title_contains, `review.pre_merge_checks[${index}].title_contains`, warnings);
2106
+ const descriptionContains = e.description_contains === undefined || e.description_contains === null ? null : parsePublicSafeText(e.description_contains, `review.pre_merge_checks[${index}].description_contains`, warnings);
2107
+ const requireLabel = e.require_label === undefined || e.require_label === null ? null : parsePublicSafeText(e.require_label, `review.pre_merge_checks[${index}].require_label`, warnings);
2108
+ if (titleContains === null && descriptionContains === null && requireLabel === null) {
2109
+ warnings.push(`Manifest "review.pre_merge_checks[${index}]" needs at least one of title_contains / description_contains / require_label; ignoring it.`);
2110
+ continue;
2111
+ }
2112
+ const whenPaths = parseManifestGlobList(e.when_paths, `review.pre_merge_checks[${index}].when_paths`, warnings);
2113
+ const enforce = normalizeOptionalBoolean(e.enforce, `review.pre_merge_checks[${index}].enforce`, warnings) === true;
2114
+ out.push({ name, whenPaths, titleContains, descriptionContains, requireLabel, enforce });
2115
+ }
2116
+ return out;
2117
+ }
2118
+ /** Parse a manifest glob list (e.g. `review.exclude_paths`, a check's `when_paths`) — an array of non-empty
2119
+ * string globs; blanks/non-strings are dropped with a warning. Capped at MAX_PATH_INSTRUCTIONS so a hostile
2120
+ * manifest can't bloat the matcher. `fieldLabel` makes the warnings name the right field. */
2121
+ function parseManifestGlobList(value, fieldLabel, warnings) {
2122
+ if (value === undefined || value === null)
2123
+ return [];
2124
+ if (!Array.isArray(value)) {
2125
+ warnings.push(`Manifest "${fieldLabel}" must be a list of path globs; ignoring it.`);
2126
+ return [];
2127
+ }
2128
+ const out = [];
2129
+ const seen = new Set();
2130
+ for (const [index, entry] of value.entries()) {
2131
+ const glob = typeof entry === "string" ? entry.trim() : "";
2132
+ if (!glob) {
2133
+ warnings.push(`Manifest "${fieldLabel}[${index}]" must be a non-empty string; ignoring it.`);
2134
+ continue;
2135
+ }
2136
+ if (glob.length > MAX_ITEM_LENGTH) {
2137
+ warnings.push(`Manifest "${fieldLabel}[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`);
2138
+ continue;
2139
+ }
2140
+ const key = glob.toLowerCase();
2141
+ if (seen.has(key))
2142
+ continue;
2143
+ if (out.length >= MAX_PATH_INSTRUCTIONS) {
2144
+ warnings.push(`Manifest "${fieldLabel}" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
2145
+ break;
2146
+ }
2147
+ seen.add(key);
2148
+ out.push(glob);
2149
+ }
2150
+ return out;
2151
+ }
2152
+ /** Parse `review.exclude_paths` — globs whose matching files are excluded from the AI review. (#review-exclude-paths) */
2153
+ function parseReviewExcludePaths(value, warnings) {
2154
+ return parseManifestGlobList(value, "review.exclude_paths", warnings);
2155
+ }
2156
+ /** Parse `review.path_filters` — include globs plus optional leading-`!` negation entries. (#2043) */
2157
+ function parseReviewPathFilters(value, warnings) {
2158
+ if (value === undefined || value === null)
2159
+ return [];
2160
+ if (!Array.isArray(value)) {
2161
+ warnings.push(`Manifest "review.path_filters" must be a list of path globs; ignoring it.`);
2162
+ return [];
2163
+ }
2164
+ const out = [];
2165
+ for (const [index, entry] of value.entries()) {
2166
+ if (out.length >= MAX_PATH_INSTRUCTIONS) {
2167
+ warnings.push(`Manifest "review.path_filters" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
2168
+ break;
2169
+ }
2170
+ const raw = typeof entry === "string" ? entry.trim() : "";
2171
+ if (!raw) {
2172
+ warnings.push(`Manifest "review.path_filters[${index}]" must be a non-empty string; ignoring it.`);
2173
+ continue;
2174
+ }
2175
+ const negated = raw.startsWith("!");
2176
+ const glob = negated ? raw.slice(1).trim() : raw;
2177
+ if (!glob) {
2178
+ warnings.push(`Manifest "review.path_filters[${index}]" must include a glob after a leading '!'; ignoring it.`);
2179
+ continue;
2180
+ }
2181
+ if (glob.length > MAX_ITEM_LENGTH) {
2182
+ warnings.push(`Manifest "review.path_filters[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`);
2183
+ continue;
2184
+ }
2185
+ out.push(negated ? `!${glob}` : glob);
2186
+ }
2187
+ return out;
2188
+ }
2189
+ /** Parse `review.path_instructions` — an array of `{ path, instructions }` entries. Each must have a non-empty
2190
+ * string `path` (a manifest glob) and PUBLIC-SAFE string `instructions`; invalid/unsafe entries are dropped with
2191
+ * a warning. Capped at MAX_PATH_INSTRUCTIONS so a huge manifest can't bloat the reviewer prompt. */
2192
+ function parseReviewPathInstructions(value, warnings) {
2193
+ if (value === undefined || value === null)
2194
+ return [];
2195
+ if (!Array.isArray(value)) {
2196
+ warnings.push(`Manifest "review.path_instructions" must be a list of { path, instructions }; ignoring it.`);
2197
+ return [];
2198
+ }
2199
+ const out = [];
2200
+ for (const [index, entry] of value.entries()) {
2201
+ if (out.length >= MAX_PATH_INSTRUCTIONS) {
2202
+ warnings.push(`Manifest "review.path_instructions" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`);
2203
+ break;
2204
+ }
2205
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
2206
+ warnings.push(`Manifest "review.path_instructions[${index}]" must be a mapping with path + instructions; ignoring it.`);
2207
+ continue;
2208
+ }
2209
+ const e = entry;
2210
+ const path = typeof e.path === "string" ? e.path.trim() : "";
2211
+ if (!path) {
2212
+ warnings.push(`Manifest "review.path_instructions[${index}].path" must be a non-empty string; ignoring the entry.`);
2213
+ continue;
2214
+ }
2215
+ if (path.length > MAX_ITEM_LENGTH) {
2216
+ warnings.push(`Manifest "review.path_instructions[${index}].path" exceeds ${MAX_ITEM_LENGTH} chars; ignoring the entry.`);
2217
+ continue;
2218
+ }
2219
+ if (e.instructions === undefined || e.instructions === null) {
2220
+ warnings.push(`Manifest "review.path_instructions[${index}].instructions" is required; ignoring the entry.`);
2221
+ continue;
2222
+ }
2223
+ const instructions = parsePublicSafeText(e.instructions, `review.path_instructions[${index}].instructions`, warnings);
2224
+ if (instructions === null)
2225
+ continue; // non-string / empty / not-public-safe → already warned
2226
+ out.push({ path, instructions });
2227
+ }
2228
+ return out;
2229
+ }
2230
+ /** Parse `review.profile` — one of chill / balanced / assertive (case-insensitive). `balanced` normalizes to
2231
+ * null (the default, so the reviewer prompt stays byte-identical). Any other value is ignored with a warning. */
2232
+ function parseReviewProfile(value, warnings) {
2233
+ if (value === undefined || value === null)
2234
+ return null;
2235
+ if (typeof value !== "string") {
2236
+ warnings.push(`Manifest "review.profile" must be a string (chill | balanced | assertive); ignoring it.`);
2237
+ return null;
2238
+ }
2239
+ const normalized = value.trim().toLowerCase();
2240
+ if (normalized === "balanced")
2241
+ return null; // default → no prompt change
2242
+ if (normalized === "chill" || normalized === "assertive")
2243
+ return normalized;
2244
+ warnings.push(`Manifest "review.profile" must be one of chill / balanced / assertive; ignoring "${value.slice(0, 32)}".`);
2245
+ return null;
2246
+ }
2247
+ /** Serialize the review config for the cache round-trip; returns null when nothing is set. */
2248
+ export function reviewConfigToJson(review) {
2249
+ if (!review.present)
2250
+ return null;
2251
+ const out = {};
2252
+ if (review.footerText !== null)
2253
+ out.footer = { text: review.footerText };
2254
+ if (review.note !== null)
2255
+ out.note = review.note;
2256
+ if (review.profile !== null)
2257
+ out.profile = review.profile;
2258
+ if (review.tone !== null)
2259
+ out.tone = review.tone;
2260
+ if (review.securityFocus !== null)
2261
+ out.security_focus = review.securityFocus;
2262
+ if (review.inlineComments !== null)
2263
+ out.inline_comments = review.inlineComments;
2264
+ if (review.fixHandoff !== null)
2265
+ out.fixHandoff = review.fixHandoff;
2266
+ if (review.autoMergeSummary !== null)
2267
+ out.auto_merge_summary = review.autoMergeSummary;
2268
+ if (review.suggestions !== null)
2269
+ out.suggestions = review.suggestions;
2270
+ if (review.changedFilesSummary !== null)
2271
+ out.changed_files_summary = review.changedFilesSummary;
2272
+ if (review.effortScore !== null)
2273
+ out.effort_score = review.effortScore;
2274
+ if (review.impactMap !== null)
2275
+ out.impact_map = review.impactMap;
2276
+ if (review.cultureProfile !== null)
2277
+ out.culture_profile = review.cultureProfile;
2278
+ if (review.selftune !== null)
2279
+ out.selftune = review.selftune;
2280
+ if (review.reviewMemory !== null)
2281
+ out.memory = review.reviewMemory;
2282
+ if (review.findingCategories !== null)
2283
+ out.finding_categories = review.findingCategories;
2284
+ if (review.inlineCommentsPerCategory !== null)
2285
+ out.inline_comments_per_category = review.inlineCommentsPerCategory;
2286
+ if (review.minFindingSeverity !== null)
2287
+ out.min_finding_severity = review.minFindingSeverity;
2288
+ if (maxFindingsPresent(review.maxFindings)) {
2289
+ const maxFindings = {};
2290
+ if (review.maxFindings.blockers !== null)
2291
+ maxFindings.blockers = review.maxFindings.blockers;
2292
+ if (review.maxFindings.nits !== null)
2293
+ maxFindings.nits = review.maxFindings.nits;
2294
+ out.max_findings = maxFindings;
2295
+ }
2296
+ if (review.commentVerbosity !== null)
2297
+ out.comment_verbosity = review.commentVerbosity;
2298
+ if (review.e2eTestDelivery !== null)
2299
+ out.e2e_test_delivery = review.e2eTestDelivery;
2300
+ if (review.e2eTestAutoTrigger !== null)
2301
+ out.e2e_test_auto_trigger = review.e2eTestAutoTrigger;
2302
+ if (review.instructions !== null)
2303
+ out.instructions = review.instructions;
2304
+ if (review.pathInstructions.length > 0)
2305
+ out.path_instructions = review.pathInstructions.map((entry) => ({ path: entry.path, instructions: entry.instructions }));
2306
+ if (review.excludePaths.length > 0)
2307
+ out.exclude_paths = [...review.excludePaths];
2308
+ if (review.pathFilters.length > 0)
2309
+ out.path_filters = [...review.pathFilters];
2310
+ if (autoReviewPresent(review.autoReview)) {
2311
+ const autoReview = {};
2312
+ if (review.autoReview.skipDrafts !== null)
2313
+ autoReview.skip_drafts = review.autoReview.skipDrafts;
2314
+ if (review.autoReview.cadence !== null)
2315
+ autoReview.cadence = review.autoReview.cadence;
2316
+ if (review.autoReview.ignoreAuthors.length > 0)
2317
+ autoReview.ignore_authors = [...review.autoReview.ignoreAuthors];
2318
+ if (review.autoReview.ignoreTitleKeywords.length > 0)
2319
+ autoReview.ignore_title_keywords = [...review.autoReview.ignoreTitleKeywords];
2320
+ if (review.autoReview.skipLabels.length > 0)
2321
+ autoReview.skip_labels = [...review.autoReview.skipLabels];
2322
+ if (review.autoReview.skipDocsOnly !== null)
2323
+ autoReview.skip_docs_only = review.autoReview.skipDocsOnly;
2324
+ if (review.autoReview.maxAddedLines > 0)
2325
+ autoReview.max_added_lines = review.autoReview.maxAddedLines;
2326
+ if (review.autoReview.maxFiles > 0)
2327
+ autoReview.max_files = review.autoReview.maxFiles;
2328
+ if (review.autoReview.baseBranches.length > 0)
2329
+ autoReview.base_branches = [...review.autoReview.baseBranches];
2330
+ if (review.autoReview.autoPauseAfterReviewedCommits !== null) {
2331
+ autoReview.auto_pause_after_reviewed_commits = review.autoReview.autoPauseAfterReviewedCommits;
2332
+ }
2333
+ out.auto_review = autoReview;
2334
+ }
2335
+ if (review.preMergeChecks.length > 0) {
2336
+ out.pre_merge_checks = review.preMergeChecks.map((check) => {
2337
+ const entry = { name: check.name };
2338
+ if (check.whenPaths.length > 0)
2339
+ entry.when_paths = [...check.whenPaths];
2340
+ if (check.titleContains !== null)
2341
+ entry.title_contains = check.titleContains;
2342
+ if (check.descriptionContains !== null)
2343
+ entry.description_contains = check.descriptionContains;
2344
+ if (check.requireLabel !== null)
2345
+ entry.require_label = check.requireLabel;
2346
+ if (check.enforce)
2347
+ entry.enforce = true;
2348
+ return entry;
2349
+ });
2350
+ }
2351
+ if (Object.keys(review.fields).length > 0)
2352
+ out.fields = { ...review.fields };
2353
+ if (Object.keys(review.enrichmentAnalyzers).length > 0)
2354
+ out.enrichment = { ...review.enrichmentAnalyzers };
2355
+ if (selfHostAiModelPresent(review.aiModel)) {
2356
+ const aiModel = {};
2357
+ if (review.aiModel.claudeModel !== null)
2358
+ aiModel.claude_model = review.aiModel.claudeModel;
2359
+ if (review.aiModel.claudeEffort !== null)
2360
+ aiModel.claude_effort = review.aiModel.claudeEffort;
2361
+ if (review.aiModel.codexModel !== null)
2362
+ aiModel.codex_model = review.aiModel.codexModel;
2363
+ if (review.aiModel.codexEffort !== null)
2364
+ aiModel.codex_effort = review.aiModel.codexEffort;
2365
+ if (review.aiModel.ollamaModel !== null)
2366
+ aiModel.ollama_model = review.aiModel.ollamaModel;
2367
+ if (review.aiModel.openaiModel !== null)
2368
+ aiModel.openai_model = review.aiModel.openaiModel;
2369
+ if (review.aiModel.openaiCompatibleModel !== null)
2370
+ aiModel.openai_compatible_model = review.aiModel.openaiCompatibleModel;
2371
+ if (review.aiModel.anthropicModel !== null)
2372
+ aiModel.anthropic_model = review.aiModel.anthropicModel;
2373
+ out.ai_model = aiModel;
2374
+ }
2375
+ if (visualConfigPresent(review.visual)) {
2376
+ const visual = {};
2377
+ if (review.visual.productionUrl !== null)
2378
+ visual.production_url = review.visual.productionUrl;
2379
+ if (review.visual.preview.urlTemplate !== null)
2380
+ visual.preview = { url_template: review.visual.preview.urlTemplate };
2381
+ if (review.visual.routes.paths.length > 0 || review.visual.routes.maxRoutes !== null) {
2382
+ const routes = {};
2383
+ if (review.visual.routes.paths.length > 0)
2384
+ routes.paths = [...review.visual.routes.paths];
2385
+ if (review.visual.routes.maxRoutes !== null)
2386
+ routes.max_routes = review.visual.routes.maxRoutes;
2387
+ visual.routes = routes;
2388
+ }
2389
+ if (review.visual.themes.length > 0)
2390
+ visual.themes = [...review.visual.themes];
2391
+ if (review.visual.gif)
2392
+ visual.gif = true;
2393
+ if (review.visual.enabled !== null)
2394
+ visual.enabled = review.visual.enabled;
2395
+ if (review.visual.themeStorageKey !== null)
2396
+ visual.theme_storage_key = review.visual.themeStorageKey;
2397
+ if (review.visual.actionsFallback)
2398
+ visual.actions_fallback = true;
2399
+ out.visual = visual;
2400
+ }
2401
+ if (review.linkedIssueSatisfaction !== null)
2402
+ out.linkedIssueSatisfaction = review.linkedIssueSatisfaction;
2403
+ return out;
2404
+ }
2405
+ /**
2406
+ * Resolve the `review.path_instructions` that APPLY to a PR — those whose glob matches at least one changed path
2407
+ * — into a single prompt section for the AI reviewer, or "" when none match (so the prompt stays byte-identical).
2408
+ * Pure; uses the same manifest path-glob semantics (`matchesManifestPath`) as the rest of the manifest. Capped to
2409
+ * keep the prompt bounded. (#review-path-instructions)
2410
+ */
2411
+ export function parseFocusManifest(raw, source) {
2412
+ if (raw === undefined || raw === null)
2413
+ return emptyManifest(source ?? "none");
2414
+ if (typeof raw !== "object" || Array.isArray(raw)) {
2415
+ return emptyManifest(source ?? "api_record", ["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]);
2416
+ }
2417
+ const record = raw;
2418
+ const warnings = [];
2419
+ const resolvedSource = normalizeSource(source, record.source, warnings);
2420
+ const manifest = {
2421
+ present: true,
2422
+ source: resolvedSource,
2423
+ wantedPaths: normalizeStringList(record.wantedPaths, "wantedPaths", warnings),
2424
+ preferredLabels: normalizeStringList(record.preferredLabels, "preferredLabels", warnings),
2425
+ linkedIssuePolicy: normalizeEnum(record.linkedIssuePolicy, "linkedIssuePolicy", ["required", "preferred", "optional"], "optional", warnings),
2426
+ testExpectations: normalizeStringList(record.testExpectations, "testExpectations", warnings),
2427
+ issueDiscoveryPolicy: normalizeEnum(record.issueDiscoveryPolicy, "issueDiscoveryPolicy", ["encouraged", "neutral", "discouraged"], "neutral", warnings),
2428
+ maintainerNotes: normalizeStringList(record.maintainerNotes, "maintainerNotes", warnings),
2429
+ publicNotes: normalizeStringList(record.publicNotes, "publicNotes", warnings).filter(isFocusManifestPublicSafe),
2430
+ gate: parseGateConfig(record.gate, warnings),
2431
+ settings: parseSettingsOverride(record.settings, warnings, resolvedSource),
2432
+ review: parseReviewConfig(record.review, warnings),
2433
+ features: parseFeaturesConfig(record.features, warnings),
2434
+ experimental: parseExperimentalConfig(record.experimental, warnings),
2435
+ contentLane: parseContentLaneConfig(record.contentLane, warnings),
2436
+ repoDocGeneration: parseRepoDocGenerationConfig(record.repoDocGeneration, warnings),
2437
+ reviewRecap: parseReviewRecapConfig(record.reviewRecap, warnings),
2438
+ maintainerRecap: parseMaintainerRecapConfig(record.maintainerRecap, warnings),
2439
+ warnings,
2440
+ };
2441
+ if (manifest.wantedPaths.length === 0 &&
2442
+ manifest.preferredLabels.length === 0 &&
2443
+ manifest.testExpectations.length === 0 &&
2444
+ manifest.maintainerNotes.length === 0 &&
2445
+ manifest.publicNotes.length === 0 &&
2446
+ manifest.linkedIssuePolicy === "optional" &&
2447
+ manifest.issueDiscoveryPolicy === "neutral" &&
2448
+ !manifest.gate.present &&
2449
+ Object.keys(manifest.settings).length === 0 &&
2450
+ !manifest.review.present &&
2451
+ !manifest.features.present &&
2452
+ !manifest.experimental.present &&
2453
+ !manifest.contentLane.present &&
2454
+ !manifest.repoDocGeneration.present &&
2455
+ !manifest.reviewRecap.present &&
2456
+ !manifest.maintainerRecap.present) {
2457
+ warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals.");
2458
+ manifest.present = false;
2459
+ }
2460
+ return manifest;
2461
+ }
2462
+ /**
2463
+ * Parse raw manifest file/record content (JSON or YAML). Malformed content degrades to an empty
2464
+ * manifest with a warning rather than throwing, so a broken `.gittensory` config never breaks analysis.
2465
+ */
2466
+ export function parseFocusManifestContent(content, source = "repo_file") {
2467
+ if (content === undefined || content === null || content.trim() === "")
2468
+ return emptyManifest(source);
2469
+ if (content.length > MAX_FOCUS_MANIFEST_BYTES || new TextEncoder().encode(content).byteLength > MAX_FOCUS_MANIFEST_BYTES) {
2470
+ return emptyManifest(source, [`Manifest content exceeded ${MAX_FOCUS_MANIFEST_BYTES} bytes; ignoring it and falling back to deterministic signals.`]);
2471
+ }
2472
+ const trimmed = content.trim();
2473
+ const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("[");
2474
+ let parsed;
2475
+ try {
2476
+ parsed = looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed);
2477
+ }
2478
+ catch {
2479
+ return emptyManifest(source, [
2480
+ looksLikeJson
2481
+ ? "Manifest content was not valid JSON; ignoring it and falling back to deterministic signals."
2482
+ : "Manifest content was not valid YAML; ignoring it and falling back to deterministic signals.",
2483
+ ]);
2484
+ }
2485
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2486
+ return emptyManifest(source, ["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]);
2487
+ }
2488
+ return parseFocusManifest(parsed, source);
2489
+ }
2490
+ /**
2491
+ * Format a manifest's parse `warnings[]` into one grouped, deduped, order-preserving notice for the review
2492
+ * surface — an acceptance criterion of #1670: an invalid/malformed `.gittensory.yml` value should fail
2493
+ * clearly instead of silently falling back to a default. Empty/no warnings ⇒ `null` (byte-identical, no
2494
+ * notice). Pure; reuses the warnings every parser already accumulates rather than a parallel schema. (#2056)
2495
+ */
2496
+ export function formatManifestValidationNotice(warnings) {
2497
+ const seen = new Set();
2498
+ const deduped = [];
2499
+ for (const warning of warnings) {
2500
+ const trimmed = warning.trim();
2501
+ if (!trimmed || seen.has(trimmed))
2502
+ continue;
2503
+ seen.add(trimmed);
2504
+ deduped.push(trimmed);
2505
+ }
2506
+ if (deduped.length === 0)
2507
+ return null;
2508
+ return deduped.map((warning) => `- ${warning}`).join("\n");
2509
+ }
2510
+ function normalizePathForMatch(path) {
2511
+ return String(path).replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").toLowerCase();
2512
+ }
2513
+ /**
2514
+ * LINEAR-TIME wildcard matcher for a `*`-glob pattern over an already-normalized path. `*` (and a collapsed
2515
+ * run of `*`) matches any run of characters INCLUDING `/` (gittensory globs cross slashes). Implemented as a
2516
+ * prefix + suffix + ordered-substring (indexOf) scan rather than a `.*`-per-star regex: the old regex
2517
+ * (`^.*a.*a...$`) backtracks catastrophically on a near-miss path and could hang the gate for an entire repo
2518
+ * (a manifest glob with many non-adjacent `*`). This algorithm is O(path × parts) with NO backtracking.
2519
+ */
2520
+ function linearGlobMatcher(pattern) {
2521
+ // The caller only compiles this for a pattern that contains a wildcard, so split always yields >= 2 parts.
2522
+ const parts = pattern.split(/\*+/); // literal segments between (collapsed) wildcard runs
2523
+ const first = parts[0];
2524
+ const last = parts[parts.length - 1];
2525
+ const middles = parts.slice(1, -1).filter((part) => part.length > 0);
2526
+ return (path) => {
2527
+ if (!path.startsWith(first) || !path.endsWith(last))
2528
+ return false;
2529
+ let idx = first.length;
2530
+ for (const part of middles) {
2531
+ const found = path.indexOf(part, idx);
2532
+ if (found === -1)
2533
+ return false;
2534
+ idx = found + part.length;
2535
+ }
2536
+ return path.length - last.length >= idx; // the suffix must not overlap the consumed prefix/middles
2537
+ };
2538
+ }
2539
+ /**
2540
+ * Compile a manifest path pattern into a predicate over an ALREADY-normalized path. Supports exact paths,
2541
+ * directory prefixes (`src/` or `src`), and `*` wildcards (`*` and a double-star both match any run of chars
2542
+ * across `/`). A double-star-then-separator prefix means "zero or more path segments", so the mandatory slash
2543
+ * is absorbed and a double-star glob also matches a ROOT-level (zero-depth) file, not only nested ones.
2544
+ * Compiling once lets a caller test many paths against one pattern without recompiling per path.
2545
+ * An empty/blank pattern never matches.
2546
+ */
2547
+ function expandGlobstarSlash(pattern) {
2548
+ const alternatives = [""];
2549
+ for (let idx = 0; idx < pattern.length;) {
2550
+ if (pattern.startsWith("**/", idx)) {
2551
+ const count = alternatives.length;
2552
+ const canKeepRootAlternatives = count * 2 <= MAX_GLOBSTAR_SLASH_ALTERNATIVES;
2553
+ for (let altIdx = count - 1; altIdx >= 0; altIdx -= 1) {
2554
+ const prefix = alternatives[altIdx];
2555
+ alternatives[altIdx] = `${prefix}*/`;
2556
+ if (canKeepRootAlternatives)
2557
+ alternatives.push(prefix);
2558
+ }
2559
+ idx += 3;
2560
+ continue;
2561
+ }
2562
+ for (let altIdx = 0; altIdx < alternatives.length; altIdx += 1)
2563
+ alternatives[altIdx] += pattern[idx];
2564
+ idx += 1;
2565
+ }
2566
+ return alternatives;
2567
+ }
2568
+ function compileManifestPathMatcher(pattern) {
2569
+ const normalizedPattern = normalizePathForMatch(pattern);
2570
+ if (!normalizedPattern)
2571
+ return () => false;
2572
+ if (normalizedPattern.includes("*")) {
2573
+ // `**/` means zero or more whole path segments. Keep the slash in the non-root alternative so
2574
+ // basename globs (e.g. `**/safe.ts`) do not degrade into suffix globs that match `unsafe.ts`.
2575
+ const matchers = expandGlobstarSlash(normalizedPattern).map((globbed) => globbed.includes("*") ? linearGlobMatcher(globbed) : (normalizedPath) => normalizedPath === globbed);
2576
+ return (normalizedPath) => matchers.some((matcher) => matcher(normalizedPath));
2577
+ }
2578
+ const dirPattern = normalizedPattern.endsWith("/") ? normalizedPattern : `${normalizedPattern}/`;
2579
+ return (normalizedPath) => normalizedPath === normalizedPattern || normalizedPath.startsWith(dirPattern);
2580
+ }
2581
+ /**
2582
+ * Match a changed path against a manifest path pattern. Supports exact paths, directory
2583
+ * prefixes (`src/` or `src`), and `*` wildcards (`**` collapses to `*`).
2584
+ */
2585
+ export function matchesManifestPath(path, pattern) {
2586
+ const normalizedPath = normalizePathForMatch(path);
2587
+ if (!normalizedPath)
2588
+ return false;
2589
+ return compileManifestPathMatcher(pattern)(normalizedPath);
2590
+ }
2591
+ export function compileFocusManifestPolicy(repoFullNameOrManifest, manifestOrOptions, options = {}) {
2592
+ let repoFullName;
2593
+ let manifest;
2594
+ if (typeof repoFullNameOrManifest === "string") {
2595
+ repoFullName = repoFullNameOrManifest;
2596
+ manifest = manifestOrOptions;
2597
+ }
2598
+ else {
2599
+ repoFullName = "";
2600
+ manifest = repoFullNameOrManifest;
2601
+ options = manifestOrOptions ?? {};
2602
+ }
2603
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
2604
+ const safePublicNotes = manifest.publicNotes.filter(isFocusManifestPublicSafe);
2605
+ const contributionLanes = buildPolicyContributionLanes(manifest);
2606
+ const readinessWarnings = buildPolicyReadinessWarnings(manifest);
2607
+ const entryGuidance = buildPolicyEntryGuidance(manifest);
2608
+ const summary = buildPolicySummary(manifest);
2609
+ return {
2610
+ repoFullName,
2611
+ generatedAt,
2612
+ source: manifest.source,
2613
+ present: manifest.present,
2614
+ publicSafe: {
2615
+ contributionLanes,
2616
+ labelPolicy: {
2617
+ preferredLabels: manifest.preferredLabels.filter(isFocusManifestPublicSafe),
2618
+ required: manifest.linkedIssuePolicy !== "optional",
2619
+ },
2620
+ validation: {
2621
+ expectations: manifest.testExpectations.filter(isFocusManifestPublicSafe),
2622
+ linkedIssuePolicy: manifest.linkedIssuePolicy,
2623
+ },
2624
+ issueDiscoveryPolicy: manifest.issueDiscoveryPolicy,
2625
+ publicNotes: safePublicNotes,
2626
+ readinessWarnings,
2627
+ entryGuidance,
2628
+ summary,
2629
+ },
2630
+ authenticated: {
2631
+ manifestSource: manifest.source,
2632
+ privateNoteCount: manifest.maintainerNotes.length,
2633
+ manifestWarningCount: manifest.warnings.length,
2634
+ parseWarnings: manifest.warnings,
2635
+ readinessWarnings,
2636
+ maintainerContext: manifest.maintainerNotes,
2637
+ },
2638
+ };
2639
+ }
2640
+ function buildPolicyEntryGuidance(manifest) {
2641
+ const guidance = [];
2642
+ // Build the sentence from the public-safe subset (as preferredLabels and publicNotes below already do, and
2643
+ // as the sibling buildPolicyContributionLanes does for preferredPaths). Joining the raw wantedPaths means a
2644
+ // single reserved-word path (e.g. `src/ranking/`) fails the all-or-nothing public-safety filter at the end
2645
+ // and silently drops the entire focus-areas guidance line instead of surfacing the safe paths.
2646
+ const safeWantedPaths = manifest.wantedPaths.filter(isFocusManifestPublicSafe);
2647
+ if (safeWantedPaths.length > 0) {
2648
+ guidance.push(`Focus changes on maintainer-wanted areas: ${safeWantedPaths.slice(0, 5).join(", ")}.`);
2649
+ }
2650
+ if (manifest.linkedIssuePolicy === "required")
2651
+ guidance.push("Link a tracked issue before opening a pull request.");
2652
+ else if (manifest.linkedIssuePolicy === "preferred")
2653
+ guidance.push("Linking a tracked issue is preferred before opening a pull request.");
2654
+ if (manifest.preferredLabels.length > 0) {
2655
+ const safeLabels = manifest.preferredLabels.filter(isFocusManifestPublicSafe);
2656
+ if (safeLabels.length > 0)
2657
+ guidance.push(`Apply a maintainer-preferred label: ${safeLabels.slice(0, 3).join(", ")}.`);
2658
+ }
2659
+ guidance.push(...manifest.publicNotes.filter(isFocusManifestPublicSafe));
2660
+ return [...new Set(guidance)].filter(isFocusManifestPublicSafe);
2661
+ }
2662
+ function buildPolicySummary(manifest) {
2663
+ if (!manifest.present)
2664
+ return "No maintainer focus manifest; contribution guidance is not constrained.";
2665
+ if (manifest.issueDiscoveryPolicy === "encouraged")
2666
+ return "Issue-discovery is the preferred contribution mode for this repo.";
2667
+ if (manifest.issueDiscoveryPolicy === "discouraged")
2668
+ return "Direct PRs are preferred; issue-discovery submissions are discouraged.";
2669
+ if (manifest.wantedPaths.length > 0)
2670
+ return "Direct PRs on the maintainer-wanted areas are preferred.";
2671
+ return "Contribution guidance is derived from the maintainer focus manifest.";
2672
+ }
2673
+ function buildPolicyContributionLanes(manifest) {
2674
+ if (!manifest.present)
2675
+ return [];
2676
+ const lanes = [];
2677
+ const safeWantedPaths = manifest.wantedPaths.filter(isFocusManifestPublicSafe);
2678
+ const safeTestExpectations = manifest.testExpectations.filter(isFocusManifestPublicSafe);
2679
+ // Derive the public preference only from public-safe signals: use the SAME filtered list that surfaces in
2680
+ // validationExpectations below, not the raw testExpectations. Otherwise a manifest whose only test expectation is
2681
+ // public-unsafe (e.g. a wallet/seed phrase) is redacted from the lane yet still flips the public preference to
2682
+ // "preferred" ("…with required validation evidence"), a self-contradictory verdict with no visible basis.
2683
+ const directPrPreference = manifest.issueDiscoveryPolicy === "encouraged" ? "discouraged"
2684
+ : safeWantedPaths.length > 0 || safeTestExpectations.length > 0 ? "preferred"
2685
+ : "neutral";
2686
+ lanes.push({
2687
+ id: "direct-pr",
2688
+ preference: directPrPreference,
2689
+ title: "Direct pull request lane",
2690
+ summary: directPrPreference === "discouraged"
2691
+ ? "Direct pull requests are discouraged; issue discovery is the preferred entry mode."
2692
+ : directPrPreference === "preferred"
2693
+ ? "Contribute changes in maintainer-wanted areas with required validation evidence."
2694
+ : "Direct pull requests are accepted when they stay inside maintainer-wanted scope.",
2695
+ preferredPaths: safeWantedPaths,
2696
+ discouragedPaths: [],
2697
+ validationExpectations: safeTestExpectations,
2698
+ publicNotes: manifest.publicNotes.filter(isFocusManifestPublicSafe),
2699
+ });
2700
+ const issueDiscoveryPreference = manifest.issueDiscoveryPolicy === "encouraged" ? "preferred"
2701
+ : manifest.issueDiscoveryPolicy === "discouraged" ? "discouraged"
2702
+ : "neutral";
2703
+ lanes.push({
2704
+ id: "issue-discovery",
2705
+ preference: issueDiscoveryPreference,
2706
+ title: "Issue discovery lane",
2707
+ summary: issueDiscoveryPreference === "preferred"
2708
+ ? "File well-scoped issue reports that the maintainer has indicated are welcome."
2709
+ : issueDiscoveryPreference === "discouraged"
2710
+ ? "The maintainer has indicated this repo prefers direct fixes over new issue reports."
2711
+ : "Issue discovery is optional; confirm maintainer scope before filing new issues.",
2712
+ preferredPaths: [],
2713
+ discouragedPaths: [],
2714
+ validationExpectations: [],
2715
+ publicNotes: [],
2716
+ });
2717
+ return lanes;
2718
+ }
2719
+ function buildPolicyReadinessWarnings(manifest) {
2720
+ if (!manifest.present)
2721
+ return [];
2722
+ const warnings = [];
2723
+ if (manifest.wantedPaths.length === 0 && manifest.preferredLabels.length === 0) {
2724
+ warnings.push("Focus manifest does not define wanted paths or preferred labels; contribution scope may be unclear to contributors.");
2725
+ }
2726
+ if (manifest.testExpectations.length === 0) {
2727
+ warnings.push("Focus manifest does not define validation expectations; contributors may not know what tests to run.");
2728
+ }
2729
+ return warnings.filter(isFocusManifestPublicSafe);
2730
+ }