@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,14 @@
1
+ export declare const PREFLIGHT_LIMITS: {
2
+ readonly repoFullNameChars: 200;
3
+ readonly contributorLoginChars: 100;
4
+ readonly titleChars: 300;
5
+ readonly bodyChars: 20000;
6
+ readonly labelChars: 100;
7
+ readonly changedFileChars: 300;
8
+ readonly testChars: 300;
9
+ readonly authorAssociationChars: 100;
10
+ readonly labels: 50;
11
+ readonly changedFiles: 200;
12
+ readonly linkedIssues: 100;
13
+ readonly tests: 50;
14
+ };
@@ -0,0 +1,14 @@
1
+ export const PREFLIGHT_LIMITS = {
2
+ repoFullNameChars: 200,
3
+ contributorLoginChars: 100,
4
+ titleChars: 300,
5
+ bodyChars: 20_000,
6
+ labelChars: 100,
7
+ changedFileChars: 300,
8
+ testChars: 300,
9
+ authorAssociationChars: 100,
10
+ labels: 50,
11
+ changedFiles: 200,
12
+ linkedIssues: 100,
13
+ tests: 50,
14
+ };
@@ -0,0 +1,56 @@
1
+ import type { AdvisoryFinding } from "../types/predicted-gate-types.js";
2
+ export declare const GENERIC_COMMIT_PATTERN: RegExp;
3
+ export declare function hasClearNoIssueRationale(pr: {
4
+ title: string;
5
+ body?: string | null | undefined;
6
+ }): boolean;
7
+ export type SlopBand = "clean" | "low" | "elevated" | "high";
8
+ export type SlopChangedFile = {
9
+ path: string;
10
+ additions?: number | undefined;
11
+ deletions?: number | undefined;
12
+ };
13
+ export type SlopAssessmentInput = {
14
+ changedFiles?: SlopChangedFile[] | undefined;
15
+ tests?: string[] | undefined;
16
+ testFiles?: string[] | undefined;
17
+ /** PR/branch description. An empty/whitespace description on a code change is a weak-effort signal. */
18
+ description?: string | null | undefined;
19
+ /** The PR's commit subject line(s). A generic/empty primary subject (wip / fix / update / ".") is a weak-effort signal. */
20
+ commitMessages?: string[] | undefined;
21
+ /** True when this PR sits in a high-risk duplicate cluster (2+ open PRs) — the caller computes it from
22
+ * the collision report. Undefined on surfaces without repo context. */
23
+ inDuplicateCluster?: boolean | undefined;
24
+ /** Whether this PR links at least one issue (caller computes from `linkedIssues.length > 0`). Only an
25
+ * explicit `false` can trip the no-linked-issue-without-rationale signal; undefined means the surface
26
+ * has no issue data. */
27
+ hasLinkedIssue?: boolean | undefined;
28
+ /** True when the contributor/repo is in the issue-discovery lane, where PRs without a linked issue are
29
+ * expected and so the no-linked-issue-without-rationale signal does not apply. */
30
+ issueDiscoveryLane?: boolean | undefined;
31
+ };
32
+ export type SlopAssessment = {
33
+ slopRisk: number;
34
+ band: SlopBand;
35
+ findings: AdvisoryFinding[];
36
+ };
37
+ export declare const SLOP_WEIGHTS: {
38
+ readonly trivialWhitespaceChurn: 30;
39
+ readonly missingTestEvidence: 15;
40
+ readonly nonSubstantivePadding: 30;
41
+ readonly emptyDescription: 15;
42
+ readonly lowQualityCommitMessage: 15;
43
+ readonly duplicateClusterMembership: 15;
44
+ readonly noLinkedIssueWithoutRationale: 15;
45
+ };
46
+ export declare const SLOP_RUBRIC_MARKDOWN: string;
47
+ export declare function buildSlopAssessment(input: SlopAssessmentInput): SlopAssessment;
48
+ export declare function buildNonSubstantivePaddingFinding(input: SlopAssessmentInput): AdvisoryFinding | null;
49
+ export declare function buildEmptyDescriptionFinding(input: SlopAssessmentInput): AdvisoryFinding | null;
50
+ export declare function buildLowQualityCommitMessageFinding(input: SlopAssessmentInput): AdvisoryFinding | null;
51
+ export declare function buildDuplicateClusterFinding(input: SlopAssessmentInput): AdvisoryFinding | null;
52
+ export declare function buildNoLinkedIssueRationaleFinding(input: SlopAssessmentInput): AdvisoryFinding | null;
53
+ export declare function buildMissingTestEvidenceFinding(input: SlopAssessmentInput): AdvisoryFinding | null;
54
+ export declare function buildTrivialWhitespaceChurnFinding(input: SlopAssessmentInput): AdvisoryFinding | null;
55
+ export declare function slopBandFor(slopRisk: number): SlopBand;
56
+ export declare function clamp(value: number, min: number, max: number): number;
@@ -0,0 +1,324 @@
1
+ import { isCodeFile, isTestFile, classifyChangedFile } from "./path-matchers.js";
2
+ import { hasLocalTestEvidence, isTestPath } from "./test-evidence.js";
3
+ import { isFocusManifestPublicSafe } from "../focus-manifest.js";
4
+ // PR-side slop-assessment scorer (#5133, Wave 3.5 follow-up to #2333/#2334), extracted from
5
+ // `src/signals/slop.ts` so the miner CLI's iterate-loop (packages/gittensory-miner) can run the SAME
6
+ // deterministic self-review scorer the live gate uses, instead of no real implementation at all
7
+ // (`attempt-runner.js`'s `deps.runSlopAssessment` had no production binding before this). This file is
8
+ // the canonical implementation; `src/signals/slop.ts`'s PR-side exports become a thin re-export shim over
9
+ // it (imported via relative source path, matching this repo's existing engine-consumption convention —
10
+ // see e.g. `src/signals/path-matchers.ts`). Only the PR-side scorer is extracted here — the issue-side
11
+ // triage (`buildIssueSlopAssessment` and friends) stays in `src/`, since it is not needed by the miner's
12
+ // self-review path and was never part of this issue's scope.
13
+ //
14
+ // GENERIC_COMMIT_PATTERN / hasClearNoIssueRationale below are a HAND-KEPT MIRROR of the same-named
15
+ // exports in `src/signals/engine.ts` — that file is not extracted (it is 5700+ lines, deeply
16
+ // interconnected with the live app's DB/Workers/scoring subsystems, and used by many call sites beyond
17
+ // slop.ts), so these two small, fully self-contained pieces are duplicated here instead, matching the
18
+ // established discipline already used by `packages/gittensory-engine/src/miner/self-review-adapter.ts`'s
19
+ // own header comment ("hand-kept structural mirror... keep in sync by hand"). Keep both copies in sync by
20
+ // hand if either changes.
21
+ export const GENERIC_COMMIT_PATTERN = /^(?:(?:wip|fix(?:es|ed|ing)?|updat(?:e|es|ed|ing)|change[sd]?|edit[sd]?|patch|minor|tweak[sd]?|misc|cleanup|chore|stuff|temp|tmp|test|final|done|commit|asdf+)\b|\.+)[\s.!]*$/i;
22
+ export function hasClearNoIssueRationale(pr) {
23
+ return /\b(?:no issue\s*(?:because\b|:)|no linked issue\s*(?:because\b|:)|no ticket\s*(?:because\b|:)|(?:maintenance|docs?[\s-]+only|tests?[\s-]+only|ci[\s-]+only|refactor[\s-]+only|typo|chore|cleanup)\b)/i.test([pr.title, pr.body ?? ""].join(" "));
24
+ }
25
+ // Deterministic, high-precision signals only — this score is the ONLY thing allowed to gate (block), so it
26
+ // must be false-positive-averse. The "strong" signals (trivialWhitespaceChurn, nonSubstantivePadding) are
27
+ // weighted 30 so the `high` band (>=60) is reachable from any two of them. missingTestEvidence is a
28
+ // weak/corroborating 15: missing-test alone never blocks, and even paired with one strong-30 signal it only
29
+ // reaches 45 (elevated, not blockable at the default block threshold) — it takes two strong signals (or one
30
+ // strong + two weak) to block. `clamp(.,0,100)` keeps the stacked score bounded.
31
+ export const SLOP_WEIGHTS = {
32
+ trivialWhitespaceChurn: 30,
33
+ missingTestEvidence: 15,
34
+ nonSubstantivePadding: 30,
35
+ emptyDescription: 15,
36
+ lowQualityCommitMessage: 15,
37
+ duplicateClusterMembership: 15,
38
+ noLinkedIssueWithoutRationale: 15,
39
+ };
40
+ export const SLOP_RUBRIC_MARKDOWN = [
41
+ "# Gittensory slop assessment rubric",
42
+ "",
43
+ "- `clean`: 0",
44
+ "- `low`: 1-30",
45
+ "- `elevated`: 31-59",
46
+ "- `high`: 60-100",
47
+ "",
48
+ "Current deterministic signals:",
49
+ "- trivial / whitespace-only churn",
50
+ "- missing test evidence",
51
+ "- non-substantive padding (generated / vendored / minified output as source)",
52
+ "- empty pull request description on a code change",
53
+ "- generic or empty commit message",
54
+ "- duplicate / overlapping pull request (high-risk collision cluster)",
55
+ "- no linked issue and no rationale (outside the issue-discovery lane)",
56
+ ].join("\n");
57
+ const MIN_CHURN_LINES = 40;
58
+ const MAX_SOURCE_LINE_SHARE = 0.15;
59
+ // Minimum added lines for a changed test file to count as real test evidence. A genuine test needs at
60
+ // least a describe/it/assert; an empty or stub file (0-2 added lines) does not.
61
+ const MIN_SUBSTANTIVE_TEST_ADDITIONS = 3;
62
+ // A padded diff is one whose churn is dominated by non-substantive output. Set at half the diff so a PR
63
+ // with any meaningful share of real, hand-authored files cannot trip it.
64
+ const PADDING_DOMINANCE_SHARE = 0.5;
65
+ export function buildSlopAssessment(input) {
66
+ const findings = [];
67
+ const trivialChurnFinding = buildTrivialWhitespaceChurnFinding(input);
68
+ const missingTestEvidenceFinding = buildMissingTestEvidenceFinding(input);
69
+ const nonSubstantivePaddingFinding = buildNonSubstantivePaddingFinding(input);
70
+ const emptyDescriptionFinding = buildEmptyDescriptionFinding(input);
71
+ const lowQualityCommitMessageFinding = buildLowQualityCommitMessageFinding(input);
72
+ const duplicateClusterFinding = buildDuplicateClusterFinding(input);
73
+ const noLinkedIssueRationaleFinding = buildNoLinkedIssueRationaleFinding(input);
74
+ if (trivialChurnFinding)
75
+ findings.push(trivialChurnFinding);
76
+ if (missingTestEvidenceFinding)
77
+ findings.push(missingTestEvidenceFinding);
78
+ if (nonSubstantivePaddingFinding)
79
+ findings.push(nonSubstantivePaddingFinding);
80
+ if (emptyDescriptionFinding)
81
+ findings.push(emptyDescriptionFinding);
82
+ if (lowQualityCommitMessageFinding)
83
+ findings.push(lowQualityCommitMessageFinding);
84
+ if (duplicateClusterFinding)
85
+ findings.push(duplicateClusterFinding);
86
+ if (noLinkedIssueRationaleFinding)
87
+ findings.push(noLinkedIssueRationaleFinding);
88
+ const slopRisk = clamp((trivialChurnFinding ? SLOP_WEIGHTS.trivialWhitespaceChurn : 0) +
89
+ (missingTestEvidenceFinding ? SLOP_WEIGHTS.missingTestEvidence : 0) +
90
+ (nonSubstantivePaddingFinding ? SLOP_WEIGHTS.nonSubstantivePadding : 0) +
91
+ (emptyDescriptionFinding ? SLOP_WEIGHTS.emptyDescription : 0) +
92
+ (lowQualityCommitMessageFinding ? SLOP_WEIGHTS.lowQualityCommitMessage : 0) +
93
+ (duplicateClusterFinding ? SLOP_WEIGHTS.duplicateClusterMembership : 0) +
94
+ (noLinkedIssueRationaleFinding ? SLOP_WEIGHTS.noLinkedIssueWithoutRationale : 0), 0, 100);
95
+ return {
96
+ slopRisk,
97
+ band: slopBandFor(slopRisk),
98
+ findings,
99
+ };
100
+ }
101
+ // Fires when a high-churn diff is dominated by generated/vendored/minified output (files that carry code
102
+ // extensions and so slip past the source-share check in `trivialWhitespaceChurn`) while genuine source and
103
+ // test effort is negligible — i.e. the diff is padded to look substantive. Lockfiles, dependency manifests,
104
+ // and docs are legitimate change categories and never count toward the padding share, so dependency bumps
105
+ // and docs PRs cannot trip this.
106
+ export function buildNonSubstantivePaddingFinding(input) {
107
+ const totals = summarizePaddingLines(input.changedFiles ?? []);
108
+ if (totals.changedLineCount < MIN_CHURN_LINES)
109
+ return null;
110
+ if (totals.paddingLineCount === 0)
111
+ return null;
112
+ if (totals.paddingLineCount / totals.changedLineCount < PADDING_DOMINANCE_SHARE)
113
+ return null;
114
+ if (totals.substantiveLineCount / totals.changedLineCount > MAX_SOURCE_LINE_SHARE)
115
+ return null;
116
+ return buildPaddingFinding(totals.changedLineCount, totals.paddingLineCount);
117
+ }
118
+ function summarizePaddingLines(changedFiles) {
119
+ let changedLineCount = 0;
120
+ let paddingLineCount = 0;
121
+ let substantiveLineCount = 0;
122
+ for (const file of changedFiles) {
123
+ const lines = nonNegative(file.additions) + nonNegative(file.deletions);
124
+ if (lines === 0)
125
+ continue;
126
+ changedLineCount += lines;
127
+ const category = classifyChangedFile(file.path);
128
+ if (category === "minified" || category === "generated" || category === "vendored") {
129
+ paddingLineCount += lines;
130
+ }
131
+ else if (category === "source" || category === "test") {
132
+ substantiveLineCount += lines;
133
+ }
134
+ }
135
+ return { changedLineCount, paddingLineCount, substantiveLineCount };
136
+ }
137
+ function buildPaddingFinding(changedLineCount, paddingLineCount) {
138
+ // Only integer counts are interpolated, so the text is public-safe by construction.
139
+ const detail = `${paddingLineCount} of ${changedLineCount} changed line(s) are in generated, vendored, or minified files with little substantive source.`;
140
+ return {
141
+ code: "non_substantive_padding",
142
+ title: "Diff is mostly generated, vendored, or minified output",
143
+ severity: "warning",
144
+ detail,
145
+ action: "Exclude generated, vendored, and minified output and keep the diff focused on substantive changes.",
146
+ publicText: detail,
147
+ };
148
+ }
149
+ // Fires only when a real code change ships with an empty / whitespace-only description — a high-precision
150
+ // weak-effort signal. A non-empty description (even a terse one) never trips it, to avoid false positives.
151
+ export function buildEmptyDescriptionFinding(input) {
152
+ // Single pass over changedFiles instead of map().filter(Boolean).filter(isCodeFile) building three
153
+ // intermediate arrays: count changed code-file paths directly.
154
+ let codeFileCount = 0;
155
+ for (const file of input.changedFiles ?? []) {
156
+ if (file.path && isCodeFile(file.path))
157
+ codeFileCount += 1;
158
+ }
159
+ if (codeFileCount === 0)
160
+ return null;
161
+ if ((input.description ?? "").trim().length > 0)
162
+ return null;
163
+ const detail = ensurePublicSafeText(`${codeFileCount} code file(s) changed with an empty pull request description.`, "Code changed with an empty pull request description.");
164
+ return {
165
+ code: "empty_pr_description",
166
+ title: "Code change has no description",
167
+ severity: "warning",
168
+ detail,
169
+ action: "Describe what changed and why so reviewers can evaluate it.",
170
+ publicText: detail,
171
+ };
172
+ }
173
+ // Fires when commit-message data is supplied and the primary subject is empty/whitespace, or is entirely a
174
+ // generic low-effort word (wip / fix / update / "." …). High-precision: a specific subject — even one that
175
+ // isn't a Conventional Commit — never trips this blocking signal; only a bare generic word that IS the
176
+ // whole subject does.
177
+ export function buildLowQualityCommitMessageFinding(input) {
178
+ if (input.commitMessages === undefined || input.commitMessages.length === 0)
179
+ return null;
180
+ const messages = input.commitMessages.map((message) => message.trim()).filter((message) => message.length > 0);
181
+ const primary = messages[0];
182
+ if (primary !== undefined && !GENERIC_COMMIT_PATTERN.test(primary))
183
+ return null;
184
+ const detail = primary === undefined ? "The commit message is empty." : "The commit message is generic (e.g. wip / fix / update) with no specific detail.";
185
+ return {
186
+ code: "low_quality_commit_message",
187
+ title: "Commit message is generic or empty",
188
+ severity: "warning",
189
+ detail,
190
+ action: "Write a specific commit subject that names what changed and why (a Conventional Commit like 'feat(api): add cursor pagination' works well).",
191
+ publicText: detail,
192
+ };
193
+ }
194
+ // Fires when the PR sits in a HIGH-risk collision cluster that holds 2+ open pull requests — genuine
195
+ // overlapping/duplicate work.
196
+ export function buildDuplicateClusterFinding(input) {
197
+ if (input.inDuplicateCluster !== true)
198
+ return null;
199
+ const detail = "This pull request overlaps a high-risk cluster of other open pull requests doing similar work.";
200
+ return {
201
+ code: "duplicate_cluster_membership",
202
+ title: "Pull request duplicates other open work",
203
+ severity: "warning",
204
+ detail,
205
+ action: "Check for an existing pull request or issue covering this change and coordinate or consolidate before continuing.",
206
+ publicText: detail,
207
+ };
208
+ }
209
+ // Fires when the caller reports NO linked issue, the PR body carries no clear no-issue rationale, and the
210
+ // repo is not in the issue-discovery lane (where unlinked PRs are expected). High-precision: only an
211
+ // explicit `hasLinkedIssue: false` trips it — absent data (undefined) is not a signal — and any clear
212
+ // rationale (maintenance / docs-only / "no issue: …") clears it.
213
+ export function buildNoLinkedIssueRationaleFinding(input) {
214
+ if (input.hasLinkedIssue !== false)
215
+ return null;
216
+ if (input.issueDiscoveryLane === true)
217
+ return null;
218
+ if (hasClearNoIssueRationale({ title: "", body: input.description ?? "" }))
219
+ return null;
220
+ const detail = "This pull request links no issue and gives no rationale for working without one.";
221
+ return {
222
+ code: "no_linked_issue_without_rationale",
223
+ title: "No linked issue and no rationale",
224
+ severity: "warning",
225
+ detail,
226
+ action: "Link the issue this addresses, or explain in the description why no issue applies (e.g. a typo, docs-only, or maintenance change).",
227
+ publicText: detail,
228
+ };
229
+ }
230
+ export function buildMissingTestEvidenceFinding(input) {
231
+ const changedFiles = input.changedFiles ?? [];
232
+ const changedPaths = changedFiles.map((file) => file.path).filter(Boolean);
233
+ const codePaths = changedPaths.filter(isCodeFile);
234
+ if (codePaths.length === 0)
235
+ return null;
236
+ // A changed test FILE only counts as real test evidence when it carries substantive content. An empty or
237
+ // no-op test (e.g. a committed `tests/noop.test.ts`) would otherwise clear this finding by path alone. When
238
+ // per-file line counts are unavailable we trust the path (can't prove emptiness); when known, require a few
239
+ // added lines so a stub can't fake coverage. (#audit-3.1)
240
+ const hasSubstantiveTestFile = changedFiles.some((file) => {
241
+ if (!(isTestFile(file.path) || isTestPath(file.path)))
242
+ return false;
243
+ return file.additions === undefined || nonNegative(file.additions) >= MIN_SUBSTANTIVE_TEST_ADDITIONS;
244
+ });
245
+ const hasChangedTestPaths = hasSubstantiveTestFile || hasLocalTestEvidence({ tests: input.tests, testFiles: input.testFiles });
246
+ if (hasChangedTestPaths)
247
+ return null;
248
+ const detail = ensurePublicSafeText(`Changed paths include ${codePaths.length} code file(s) without accompanying test evidence.`, "Code changes were detected without accompanying test evidence.");
249
+ const action = ensurePublicSafeText("Add focused regression tests or explain why existing coverage is sufficient.", "Add focused tests or explain why existing coverage is sufficient.");
250
+ return {
251
+ code: "missing_test_evidence",
252
+ title: "Code changes lack test evidence",
253
+ severity: "warning",
254
+ detail,
255
+ action,
256
+ publicText: detail,
257
+ };
258
+ }
259
+ export function buildTrivialWhitespaceChurnFinding(input) {
260
+ const changedFiles = input.changedFiles ?? [];
261
+ const lineTotals = summarizeChangedLines(changedFiles);
262
+ if (lineTotals.changedLineCount < MIN_CHURN_LINES)
263
+ return null;
264
+ const substantiveLineCount = lineTotals.sourceLineCount + lineTotals.testLineCount;
265
+ if (substantiveLineCount === 0) {
266
+ return buildTrivialChurnFinding(lineTotals.changedLineCount, lineTotals.nonCodeLineCount);
267
+ }
268
+ const substantiveShare = substantiveLineCount / lineTotals.changedLineCount;
269
+ if (substantiveShare > MAX_SOURCE_LINE_SHARE)
270
+ return null;
271
+ return buildTrivialChurnFinding(lineTotals.changedLineCount, lineTotals.nonCodeLineCount);
272
+ }
273
+ function summarizeChangedLines(changedFiles) {
274
+ const changedLineCount = changedFiles.reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0);
275
+ const sourceLineCount = changedFiles
276
+ .filter((file) => isCodeFile(file.path))
277
+ .reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0);
278
+ const testLineCount = changedFiles
279
+ .filter((file) => isTestFile(file.path))
280
+ .reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0);
281
+ const nonCodeLineCount = Math.max(0, changedLineCount - sourceLineCount - testLineCount);
282
+ return { changedLineCount, sourceLineCount, testLineCount, nonCodeLineCount };
283
+ }
284
+ function buildTrivialChurnFinding(changedLineCount, nonCodeLineCount) {
285
+ const detail = ensurePublicSafeText(`The diff churns ${changedLineCount} line(s) with only ${Math.max(0, changedLineCount - nonCodeLineCount)} substantive source line(s) touched.`, "The diff shows high churn with minimal substantive source changes.");
286
+ const action = ensurePublicSafeText("Reduce whitespace-only or formatting-only churn and keep the diff focused on substantive changes.", "Reduce formatting-only churn and keep the diff focused on substantive changes.");
287
+ return {
288
+ code: "trivial_whitespace_churn",
289
+ title: "Diff looks like trivial or whitespace-only churn",
290
+ severity: "warning",
291
+ detail,
292
+ action,
293
+ publicText: detail,
294
+ };
295
+ }
296
+ function nonNegative(value) {
297
+ // `Number.isFinite` already excludes undefined/NaN/Infinity, so by the time the right operand of `&&`
298
+ // evaluates, `value` is guaranteed to be a real finite number — no `?? 0` fallback is reachable here.
299
+ return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
300
+ }
301
+ function ensurePublicSafeText(text, fallback) {
302
+ // Defense-in-depth: every current call site interpolates only integer counts (see e.g.
303
+ // buildPaddingFinding's own comment), which can never fail isFocusManifestPublicSafe — the fallback
304
+ // branch guards against a FUTURE call site interpolating unsafe text, not a reachable case today.
305
+ /* v8 ignore next */
306
+ return isFocusManifestPublicSafe(text) ? text : fallback;
307
+ }
308
+ // Documented thresholds (#565, recalibrated by #3939): the deterministic slopRisk (0-100) maps to fixed
309
+ // bands — clean = 0, low = 1-30, elevated = 31-59, high = 60-100.
310
+ // Exported (unlike the other internal helpers above) because `src/signals/slop.ts`'s own issue-side
311
+ // triage (not extracted here — see this file's header) reuses the identical band math and imports it
312
+ // back rather than keeping a duplicate copy.
313
+ export function slopBandFor(slopRisk) {
314
+ if (slopRisk <= 0)
315
+ return "clean";
316
+ if (slopRisk < 31)
317
+ return "low";
318
+ if (slopRisk < 60)
319
+ return "elevated";
320
+ return "high";
321
+ }
322
+ export function clamp(value, min, max) {
323
+ return Math.min(max, Math.max(min, value));
324
+ }
@@ -0,0 +1,25 @@
1
+ export declare function isTestPath(file: string): boolean;
2
+ /** True iff `file` is a hand-authored program-source file: a recognized source extension that is not itself a
3
+ * test file. The single source of truth for every `isCodeFile` in the signals layer, so the source/test
4
+ * classifiers can never drift (the same way isCodeFile's `isTestFile` wrappers all delegate to isTestPath). */
5
+ export declare function isSourcePath(file: string): boolean;
6
+ /** cs/swift/groovy/kts plus php, C/C++/Objective-C, vue/svelte/astro, and dart — see isSourcePath for the
7
+ * canonical JVM/.NET/Swift/Groovy/Kotlin-script matcher kept symmetric with isTestPath. Generated Dart part
8
+ * files (.g.dart/.freezed.dart/.gr.dart) stay non-code (#3724). The single source of truth both the Worker
9
+ * (src/signals/path-matchers.ts) and the published @loopover/mcp/gittensory-miner CLIs delegate
10
+ * to, so the three previously-independent hand-ports can't silently drift from each other again. */
11
+ export declare function isCodeFile(file: string): boolean;
12
+ export declare function hasLocalTestEvidence(input: {
13
+ tests?: string[] | undefined;
14
+ testFiles?: string[] | undefined;
15
+ }): boolean;
16
+ export declare function hasValidationNote(value: string): boolean;
17
+ /**
18
+ * Coarse classification of how much test coverage accompanies a set of changed paths.
19
+ * Used by slop signals to weight diffs that touch source but include no tests differently
20
+ * from those with proportionally strong test changes.
21
+ */
22
+ export type TestCoverageClassification = "strong" | "adequate" | "weak" | "absent";
23
+ export declare function classifyTestCoverage(changedPaths: string[]): TestCoverageClassification;
24
+ export declare const TEST_FRAMEWORKS: readonly ["vitest", "jest", "pytest", "go-test", "rspec", "cargo-test"];
25
+ export type TestFramework = (typeof TEST_FRAMEWORKS)[number];
@@ -0,0 +1,108 @@
1
+ export function isTestPath(file) {
2
+ return (/(^|\/)(test|tests|spec|__tests__)\//i.test(file) ||
3
+ /(^|\/)src\/test\//i.test(file) ||
4
+ /(^|\/)[^/]+_test\.(go|py|rb|dart)$/i.test(file) || // Dart/Flutter `foo_test.dart` co-located with source
5
+ /(^|\/)test_[^/]*\.py$/i.test(file) || // pytest's default `test_*.py` prefix convention (the suffix rule above only catches `*_test.py`)
6
+ /(^|\/)[^/]+_spec\.rb$/i.test(file) ||
7
+ /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs)$/i.test(file) ||
8
+ /(^|\/)[^/]+\.(cy|e2e)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/i.test(file) ||
9
+ // JVM / C# / Swift / PHP `SomethingTest(s)`/`SomethingSpec` class-suffix convention
10
+ // (JUnit, Kotlin/ScalaTest, Spock, xUnit/NUnit, XCTest, PHPUnit/PHPSpec). Case-sensitive on the
11
+ // PascalCase suffix so it can't false-positive on words that merely end in
12
+ // "test"/"spec" (Latest.java, Contest.cs, manifest.scala, Latest.php).
13
+ /(^|\/)\w*(Tests?|Spec)\.(java|kt|kts|scala|cs|swift|groovy|php)$/.test(file) ||
14
+ /(^|\/)__snapshots__\//i.test(file));
15
+ }
16
+ // Canonical hand-authored-source extensions — the SOURCE-side sibling of isTestPath's class-suffix rule.
17
+ // The two matchers MUST stay symmetric: isTestPath recognizes java/kt/kts/scala/cs/swift/groovy test files,
18
+ // so this set lists those same languages. Otherwise a C#/Swift/Groovy/Kotlin-script SOURCE change is classified
19
+ // as neither code nor test and silently escapes both the missing-tests gate signals and token scoring.
20
+ const SOURCE_FILE_EXTENSION = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|rs|kt|kts|scala|java|cs|swift|groovy|go|sql)$/i;
21
+ /** True iff `file` is a hand-authored program-source file: a recognized source extension that is not itself a
22
+ * test file. The single source of truth for every `isCodeFile` in the signals layer, so the source/test
23
+ * classifiers can never drift (the same way isCodeFile's `isTestFile` wrappers all delegate to isTestPath). */
24
+ export function isSourcePath(file) {
25
+ return SOURCE_FILE_EXTENSION.test(file) && !isTestPath(file);
26
+ }
27
+ // Extensions recognized as code outside isSourcePath's core set (php, native, front-end frameworks, Dart).
28
+ // isSourcePath owns the JVM/.NET/Swift/Groovy/Kotlin-script set symmetric with isTestPath.
29
+ const EXTENDED_SOURCE_EXTENSION = /\.(php|cpp|cc|c|h|hpp|m|vue|svelte|astro|dart)$/i;
30
+ /** cs/swift/groovy/kts plus php, C/C++/Objective-C, vue/svelte/astro, and dart — see isSourcePath for the
31
+ * canonical JVM/.NET/Swift/Groovy/Kotlin-script matcher kept symmetric with isTestPath. Generated Dart part
32
+ * files (.g.dart/.freezed.dart/.gr.dart) stay non-code (#3724). The single source of truth both the Worker
33
+ * (src/signals/path-matchers.ts) and the published @loopover/mcp/gittensory-miner CLIs delegate
34
+ * to, so the three previously-independent hand-ports can't silently drift from each other again. */
35
+ export function isCodeFile(file) {
36
+ if (isSourcePath(file))
37
+ return true;
38
+ return EXTENDED_SOURCE_EXTENSION.test(file) && !isTestPath(file) && !/\.(g|freezed|gr)\.dart$/i.test(file);
39
+ }
40
+ export function hasLocalTestEvidence(input) {
41
+ return (input.tests ?? []).length > 0 || (input.testFiles ?? []).some((file) => isTestPath(file));
42
+ }
43
+ // A body can mention testing without having actually done it ("No tests run", "Tests not run", "Not
44
+ // tested locally", "did not run any tests") -- the affirmative keyword match below would otherwise treat
45
+ // that as passing evidence and let a configured manifest test expectation silently disappear. Rather than
46
+ // enumerate ever more literal phrase templates (which a previous version of this function tried, and which
47
+ // still missed "Not tested" because its test-noun list didn't include the verb form "tested"), detect
48
+ // negation by PROXIMITY: a negation word within a few words of a test/validation stem, in either order,
49
+ // with a shared stem definition so the "is this a test/validation mention at all" question is answered
50
+ // exactly once. The filler between the negation word and the stem may not cross a clause/sentence boundary
51
+ // (a comma, period, exclamation mark, or question mark), so an unrelated "not" earlier in the body (e.g.
52
+ // "This is not a breaking change. Tested with npm run test:ci.") cannot suppress a later, unrelated
53
+ // affirmative note. A colon, semicolon, or dash is deliberately NOT a hard boundary here -- see
54
+ // LABEL_SEPARATOR_GAP below.
55
+ const TEST_STEM = "(?:test(?:ed|s|ing)?|validat(?:ion|ed)|verif(?:y|ied|ying)|manual check|smoke(?:\\s+tests?)?)";
56
+ const NEGATION_WORD = "(?:no|not|never|without|skip(?:ped)?|didn't|doesn't|isn't|wasn't|weren't|haven't|hasn't)";
57
+ const NEGATION_CONTINUATION = "(?:not|never|failed|failing|skipped|incomplete)";
58
+ const SAME_SENTENCE_FILLER_WORD = "[^\\s.,!?;]+";
59
+ // A label-style status line often glues its separator directly onto the negation word or stem with no
60
+ // surrounding whitespace ("Tests: not run.", "Validation; skipped.", "Tests - not run."). The plain
61
+ // `\s+` gap below would never match across that punctuation, so the negation went undetected and the
62
+ // bare "Tests"/"Validation" keyword fell through to the affirmative check instead (#3304, round 4).
63
+ // Allow ONE label separator (colon, semicolon, or a hyphen/en-dash/em-dash) with any trailing
64
+ // whitespace to stand in for the mandatory whitespace, but only at the junction touching the negation
65
+ // word or stem itself -- every other gap between filler words stays pure whitespace, so a label
66
+ // separator elsewhere in the sentence still cannot let a negation reach across unrelated content (the
67
+ // filler-word bound below already exists for exactly this reason).
68
+ const LABEL_SEPARATOR_GAP = "(?:\\s+|[:;\\-\\u2013\\u2014]\\s*)";
69
+ const NEGATES_BEFORE_TEST_STEM = new RegExp(`\\b${NEGATION_WORD}\\b${LABEL_SEPARATOR_GAP}(?:${SAME_SENTENCE_FILLER_WORD}\\s+){0,3}${TEST_STEM}\\b`, "i");
70
+ const NEGATES_AFTER_TEST_STEM = new RegExp(`\\b${TEST_STEM}\\b${LABEL_SEPARATOR_GAP}(?:${SAME_SENTENCE_FILLER_WORD}\\s+){0,2}${NEGATION_CONTINUATION}\\b`, "i");
71
+ // A compound negated adjective with no separating whitespace at all ("untested", "unvalidated", "unverified").
72
+ const NEGATES_TEST_STEM_PREFIX = /\bun(?:tested|validated|verified)\b/i;
73
+ const AFFIRMATIVE_TEST_MENTION = /\b(test(?:ed|s|ing)?|validation|validated|verified|manual check|smoke|pytest|vitest|npm test|pnpm test|cargo test|go test)\b/i;
74
+ // A body can contain BOTH a genuine negated clause ("No tests run locally.") and a separate, later clause
75
+ // with real affirmative evidence ("Validated with npm run test:ci.") -- evaluating the negation checks
76
+ // against the WHOLE body would let the first clause veto the second, discarding real evidence the manifest
77
+ // gate is specifically trying to detect (#3304, round 3). Split on the same clause-boundary punctuation the
78
+ // proximity checks already treat as a hard stop -- colon/semicolon/dash are excluded here on purpose
79
+ // (#3304, round 4): they are typically a label separator glued directly onto the word on either side
80
+ // ("Tests: not run."), and splitting on them would sever the stem from its own negation before the
81
+ // proximity checks ever run, the same way the round-3 bug worked one level up. Require at least one
82
+ // clause to be an affirmative, non-negated mention -- so an earlier honest "no tests" disclosure can no
83
+ // longer suppress later evidence.
84
+ export function hasValidationNote(value) {
85
+ return value
86
+ .split(/[.,!?]+/)
87
+ .some((clause) => !NEGATES_TEST_STEM_PREFIX.test(clause) &&
88
+ !NEGATES_BEFORE_TEST_STEM.test(clause) &&
89
+ !NEGATES_AFTER_TEST_STEM.test(clause) &&
90
+ AFFIRMATIVE_TEST_MENTION.test(clause));
91
+ }
92
+ export function classifyTestCoverage(changedPaths) {
93
+ if (changedPaths.length === 0)
94
+ return "absent";
95
+ const testCount = changedPaths.filter(isTestPath).length;
96
+ if (testCount === 0)
97
+ return "absent";
98
+ const ratio = testCount / changedPaths.length;
99
+ if (ratio >= 0.4)
100
+ return "strong";
101
+ if (ratio >= 0.2)
102
+ return "adequate";
103
+ return "weak";
104
+ }
105
+ // #2187 (foundational slice of #1972 — boundary-safe test generation): a small, precise framework list.
106
+ // Consumed by the MCP test-gen tool's enum (src/mcp/server.ts testGenShape) so a caller cannot request a
107
+ // spec for a framework this engine doesn't recognize.
108
+ export const TEST_FRAMEWORKS = ["vitest", "jest", "pytest", "go-test", "rspec", "cargo-test"];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The standard env-var allowlist for a locally-authenticated CLI subprocess: home + proxy + TLS-cert + locale +
3
+ * XDG config paths, so the CLI keeps its own auth/proxy/cert settings, but nothing else (no runtime secrets) leaks
4
+ * in. A caller that needs a different/larger set (e.g. a coding-agent driver) passes its own list to
5
+ * {@link buildAllowlistedEnv} rather than editing this one.
6
+ */
7
+ export declare const SUBPROCESS_CLI_ENV_ALLOWLIST: readonly ["HOME", "HTTPS_PROXY", "HTTP_PROXY", "LANG", "LC_ALL", "NODE_EXTRA_CA_CERTS", "NO_PROXY", "PATH", "SSL_CERT_DIR", "SSL_CERT_FILE", "TERM", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "https_proxy", "http_proxy", "no_proxy"];
8
+ /**
9
+ * Build a child-process env by copying ONLY `allowlist` keys from `parent`, then overlaying `extra`. Parameterized
10
+ * (the allowlist is a caller argument, not hardcoded) so different subprocess kinds can use different allowlists.
11
+ * `undefined` values are dropped from both sources; `extra` wins over an allowlisted parent value for the same key.
12
+ * Pure — never reads the ambient process env itself.
13
+ */
14
+ export declare function buildAllowlistedEnv(parent: Record<string, string | undefined>, allowlist: readonly string[], extra?: Record<string, string | undefined>): Record<string, string | undefined>;
15
+ /** Well-known secret token shapes to strip from untrusted subprocess output. Ported verbatim from
16
+ * src/selfhost/ai.ts (`SECRET_PATTERNS`) — keep the two in sync (or shim ai.ts onto this) rather than weakening. */
17
+ export declare const SECRET_PATTERNS: readonly RegExp[];
18
+ /**
19
+ * Redact secrets from untrusted subprocess output before it flows to logs/Sentry: strip each caller-supplied known
20
+ * secret value exactly (length-guarded so a short/empty token can't blank unrelated text), then well-known token
21
+ * shapes ({@link SECRET_PATTERNS}). Ported from src/selfhost/ai.ts's `redactSecrets`. Pure.
22
+ */
23
+ export declare function redactSecrets(text: string, knownSecrets?: readonly string[]): string;
@@ -0,0 +1,74 @@
1
+ // Shared subprocess env-allowlist + secret-redaction helpers (#4284). Any driver that spawns a locally-authenticated
2
+ // CLI (the review `claude`/`codex` subprocess in src/selfhost/ai.ts, and the coding-agent drivers coming in
3
+ // gittensory-miner) needs the SAME two safety primitives: hand the child a STRICT allowlisted env (never the full
4
+ // worker/host env, which can carry runtime credentials into a prompt-injectable subprocess), and redact well-known
5
+ // secret shapes out of the child's untrusted stderr before it reaches logs. This module is the single engine-hosted
6
+ // source of truth for both, so those callers depend on one implementation instead of copy-pasting the pattern.
7
+ /**
8
+ * The standard env-var allowlist for a locally-authenticated CLI subprocess: home + proxy + TLS-cert + locale +
9
+ * XDG config paths, so the CLI keeps its own auth/proxy/cert settings, but nothing else (no runtime secrets) leaks
10
+ * in. A caller that needs a different/larger set (e.g. a coding-agent driver) passes its own list to
11
+ * {@link buildAllowlistedEnv} rather than editing this one.
12
+ */
13
+ export const SUBPROCESS_CLI_ENV_ALLOWLIST = [
14
+ "HOME",
15
+ "HTTPS_PROXY",
16
+ "HTTP_PROXY",
17
+ "LANG",
18
+ "LC_ALL",
19
+ "NODE_EXTRA_CA_CERTS",
20
+ "NO_PROXY",
21
+ "PATH",
22
+ "SSL_CERT_DIR",
23
+ "SSL_CERT_FILE",
24
+ "TERM",
25
+ "XDG_CONFIG_HOME",
26
+ "XDG_DATA_HOME",
27
+ "XDG_STATE_HOME",
28
+ "https_proxy",
29
+ "http_proxy",
30
+ "no_proxy",
31
+ ];
32
+ /**
33
+ * Build a child-process env by copying ONLY `allowlist` keys from `parent`, then overlaying `extra`. Parameterized
34
+ * (the allowlist is a caller argument, not hardcoded) so different subprocess kinds can use different allowlists.
35
+ * `undefined` values are dropped from both sources; `extra` wins over an allowlisted parent value for the same key.
36
+ * Pure — never reads the ambient process env itself.
37
+ */
38
+ export function buildAllowlistedEnv(parent, allowlist, extra = {}) {
39
+ const child = {};
40
+ for (const key of allowlist) {
41
+ const value = parent[key];
42
+ if (value !== undefined)
43
+ child[key] = value;
44
+ }
45
+ for (const [key, value] of Object.entries(extra)) {
46
+ if (value !== undefined)
47
+ child[key] = value;
48
+ }
49
+ return child;
50
+ }
51
+ /** Well-known secret token shapes to strip from untrusted subprocess output. Ported verbatim from
52
+ * src/selfhost/ai.ts (`SECRET_PATTERNS`) — keep the two in sync (or shim ai.ts onto this) rather than weakening. */
53
+ export const SECRET_PATTERNS = [
54
+ /\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI / Anthropic keys (sk-..., sk-ant-..., sk-proj-...)
55
+ /\bgh[oprsu]_[A-Za-z0-9]{20,}/g, // GitHub PAT / OAuth / server / refresh tokens
56
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT
57
+ /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT (header.payload.signature)
58
+ /\bAKIA[0-9A-Z]{16}/g, // AWS access key id
59
+ ];
60
+ /**
61
+ * Redact secrets from untrusted subprocess output before it flows to logs/Sentry: strip each caller-supplied known
62
+ * secret value exactly (length-guarded so a short/empty token can't blank unrelated text), then well-known token
63
+ * shapes ({@link SECRET_PATTERNS}). Ported from src/selfhost/ai.ts's `redactSecrets`. Pure.
64
+ */
65
+ export function redactSecrets(text, knownSecrets = []) {
66
+ let out = text;
67
+ for (const secret of knownSecrets) {
68
+ if (secret.length >= 8)
69
+ out = out.split(secret).join("[redacted]");
70
+ }
71
+ for (const pattern of SECRET_PATTERNS)
72
+ out = out.replace(pattern, "[redacted]");
73
+ return out;
74
+ }