@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.
- package/CHANGELOG.md +94 -0
- package/LICENSE +661 -0
- package/README.md +676 -0
- package/dist/advisory/gate-advisory.d.ts +151 -0
- package/dist/advisory/gate-advisory.js +512 -0
- package/dist/ai-policy-map.d.ts +97 -0
- package/dist/ai-policy-map.js +377 -0
- package/dist/ams-policy-spec.d.ts +56 -0
- package/dist/ams-policy-spec.js +165 -0
- package/dist/calibration-dashboard.d.ts +23 -0
- package/dist/calibration-dashboard.js +65 -0
- package/dist/calibration-trend.d.ts +40 -0
- package/dist/calibration-trend.js +60 -0
- package/dist/contributor-fit.d.ts +24 -0
- package/dist/contributor-fit.js +50 -0
- package/dist/discovery-index-contract.d.ts +74 -0
- package/dist/discovery-index-contract.js +184 -0
- package/dist/discovery-soft-claim.d.ts +35 -0
- package/dist/discovery-soft-claim.js +50 -0
- package/dist/duplicate-winner.d.ts +57 -0
- package/dist/duplicate-winner.js +98 -0
- package/dist/feasibility.d.ts +27 -0
- package/dist/feasibility.js +86 -0
- package/dist/finding-severity-calibration.d.ts +116 -0
- package/dist/finding-severity-calibration.js +457 -0
- package/dist/fleet-run-manifest.d.ts +41 -0
- package/dist/fleet-run-manifest.js +151 -0
- package/dist/focus-manifest/guidance.d.ts +21 -0
- package/dist/focus-manifest/guidance.js +222 -0
- package/dist/focus-manifest.d.ts +826 -0
- package/dist/focus-manifest.js +2730 -0
- package/dist/gate-verdict-calibration.d.ts +111 -0
- package/dist/gate-verdict-calibration.js +411 -0
- package/dist/github/constants.d.ts +1 -0
- package/dist/github/constants.js +1 -0
- package/dist/github/sanitize-public-comment.d.ts +1 -0
- package/dist/github/sanitize-public-comment.js +26 -0
- package/dist/goal-model.d.ts +7 -0
- package/dist/goal-model.js +87 -0
- package/dist/governor/action-mode.d.ts +55 -0
- package/dist/governor/action-mode.js +74 -0
- package/dist/governor/budget-cap.d.ts +49 -0
- package/dist/governor/budget-cap.js +34 -0
- package/dist/governor/chokepoint.d.ts +58 -0
- package/dist/governor/chokepoint.js +269 -0
- package/dist/governor/kill-switch.d.ts +38 -0
- package/dist/governor/kill-switch.js +62 -0
- package/dist/governor/rate-limit.d.ts +40 -0
- package/dist/governor/rate-limit.js +63 -0
- package/dist/governor/reputation-throttle.d.ts +50 -0
- package/dist/governor/reputation-throttle.js +94 -0
- package/dist/governor/run-halt.d.ts +39 -0
- package/dist/governor/run-halt.js +83 -0
- package/dist/governor/self-plagiarism.d.ts +47 -0
- package/dist/governor/self-plagiarism.js +200 -0
- package/dist/governor/write-rate-limit.d.ts +52 -0
- package/dist/governor/write-rate-limit.js +132 -0
- package/dist/governor-ledger.d.ts +25 -0
- package/dist/governor-ledger.js +91 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +120 -0
- package/dist/issue-plan-decomposition.d.ts +18 -0
- package/dist/issue-plan-decomposition.js +56 -0
- package/dist/issue-rag-query.d.ts +12 -0
- package/dist/issue-rag-query.js +35 -0
- package/dist/local-scorer.d.ts +34 -0
- package/dist/local-scorer.js +36 -0
- package/dist/metadata-best-min-score.d.ts +9 -0
- package/dist/metadata-best-min-score.js +9 -0
- package/dist/metadata-best-pick.d.ts +9 -0
- package/dist/metadata-best-pick.js +9 -0
- package/dist/metadata-min-score.d.ts +9 -0
- package/dist/metadata-min-score.js +13 -0
- package/dist/metadata-top-min-score.d.ts +9 -0
- package/dist/metadata-top-min-score.js +13 -0
- package/dist/metadata-top-pick.d.ts +9 -0
- package/dist/metadata-top-pick.js +13 -0
- package/dist/miner/acceptance-criteria.d.ts +43 -0
- package/dist/miner/acceptance-criteria.js +77 -0
- package/dist/miner/agent-sdk-driver.d.ts +39 -0
- package/dist/miner/agent-sdk-driver.js +162 -0
- package/dist/miner/attempt-log.d.ts +29 -0
- package/dist/miner/attempt-log.js +100 -0
- package/dist/miner/attempt-metering.d.ts +39 -0
- package/dist/miner/attempt-metering.js +67 -0
- package/dist/miner/cli-subprocess-driver.d.ts +68 -0
- package/dist/miner/cli-subprocess-driver.js +272 -0
- package/dist/miner/coding-agent-driver.d.ts +33 -0
- package/dist/miner/coding-agent-driver.js +36 -0
- package/dist/miner/coding-agent-invoke.d.ts +11 -0
- package/dist/miner/coding-agent-invoke.js +80 -0
- package/dist/miner/coding-agent-mode.d.ts +25 -0
- package/dist/miner/coding-agent-mode.js +43 -0
- package/dist/miner/driver-factory.d.ts +82 -0
- package/dist/miner/driver-factory.js +169 -0
- package/dist/miner/harness-submission-trigger.d.ts +37 -0
- package/dist/miner/harness-submission-trigger.js +46 -0
- package/dist/miner/iterate-loop.d.ts +97 -0
- package/dist/miner/iterate-loop.js +278 -0
- package/dist/miner/iterate-policy.d.ts +98 -0
- package/dist/miner/iterate-policy.js +98 -0
- package/dist/miner/lint-guard.d.ts +50 -0
- package/dist/miner/lint-guard.js +68 -0
- package/dist/miner/local-write-tools.d.ts +81 -0
- package/dist/miner/local-write-tools.js +113 -0
- package/dist/miner/loop-reentry-policy.d.ts +36 -0
- package/dist/miner/loop-reentry-policy.js +53 -0
- package/dist/miner/repo-map.d.ts +51 -0
- package/dist/miner/repo-map.js +268 -0
- package/dist/miner/self-review-adapter.d.ts +106 -0
- package/dist/miner/self-review-adapter.js +83 -0
- package/dist/miner/submission-gate.d.ts +40 -0
- package/dist/miner/submission-gate.js +89 -0
- package/dist/miner/worktree-allocator.d.ts +59 -0
- package/dist/miner/worktree-allocator.js +60 -0
- package/dist/miner/worktree-pool.d.ts +55 -0
- package/dist/miner/worktree-pool.js +65 -0
- package/dist/miner-goal-lane-fit.d.ts +18 -0
- package/dist/miner-goal-lane-fit.js +76 -0
- package/dist/miner-goal-spec.d.ts +139 -0
- package/dist/miner-goal-spec.js +281 -0
- package/dist/miner-prediction-metrics.d.ts +16 -0
- package/dist/miner-prediction-metrics.js +61 -0
- package/dist/miner-telemetry.d.ts +32 -0
- package/dist/miner-telemetry.js +87 -0
- package/dist/objective-anchor.d.ts +125 -0
- package/dist/objective-anchor.js +401 -0
- package/dist/opportunity-competition.d.ts +6 -0
- package/dist/opportunity-competition.js +29 -0
- package/dist/opportunity-freshness.d.ts +18 -0
- package/dist/opportunity-freshness.js +55 -0
- package/dist/opportunity-metadata.d.ts +53 -0
- package/dist/opportunity-metadata.js +204 -0
- package/dist/opportunity-ranker.d.ts +46 -0
- package/dist/opportunity-ranker.js +74 -0
- package/dist/pairwise-calibration.d.ts +49 -0
- package/dist/pairwise-calibration.js +106 -0
- package/dist/phase7-calibration-loop.d.ts +136 -0
- package/dist/phase7-calibration-loop.js +437 -0
- package/dist/plan-blocked.d.ts +6 -0
- package/dist/plan-blocked.js +24 -0
- package/dist/plan-completed.d.ts +5 -0
- package/dist/plan-completed.js +6 -0
- package/dist/plan-completion.d.ts +6 -0
- package/dist/plan-completion.js +7 -0
- package/dist/plan-empty.d.ts +5 -0
- package/dist/plan-empty.js +6 -0
- package/dist/plan-export.d.ts +27 -0
- package/dist/plan-export.js +86 -0
- package/dist/plan-failure.d.ts +5 -0
- package/dist/plan-failure.js +6 -0
- package/dist/plan-overall-status.d.ts +6 -0
- package/dist/plan-overall-status.js +25 -0
- package/dist/plan-pending.d.ts +5 -0
- package/dist/plan-pending.js +6 -0
- package/dist/plan-progress-complete.d.ts +7 -0
- package/dist/plan-progress-complete.js +10 -0
- package/dist/plan-ready.d.ts +6 -0
- package/dist/plan-ready.js +12 -0
- package/dist/plan-running.d.ts +5 -0
- package/dist/plan-running.js +6 -0
- package/dist/plan-skipped.d.ts +5 -0
- package/dist/plan-skipped.js +6 -0
- package/dist/plan-step-count.d.ts +5 -0
- package/dist/plan-step-count.js +6 -0
- package/dist/plan-step-stats.d.ts +5 -0
- package/dist/plan-step-stats.js +6 -0
- package/dist/plan-templates.d.ts +25 -0
- package/dist/plan-templates.js +107 -0
- package/dist/plan-terminated.d.ts +6 -0
- package/dist/plan-terminated.js +9 -0
- package/dist/portfolio/non-convergence.d.ts +37 -0
- package/dist/portfolio/non-convergence.js +57 -0
- package/dist/portfolio/queue.d.ts +30 -0
- package/dist/portfolio/queue.js +150 -0
- package/dist/predicted-gate.d.ts +103 -0
- package/dist/predicted-gate.js +218 -0
- package/dist/prompt-packet.d.ts +11 -0
- package/dist/prompt-packet.js +34 -0
- package/dist/ranked-opportunity-best-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-best-min-score.js +9 -0
- package/dist/ranked-opportunity-best-pick.d.ts +8 -0
- package/dist/ranked-opportunity-best-pick.js +9 -0
- package/dist/ranked-opportunity-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-min-score.js +13 -0
- package/dist/ranked-opportunity-top-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-top-min-score.js +13 -0
- package/dist/review/advisory-ai-routing-config.d.ts +8 -0
- package/dist/review/advisory-ai-routing-config.js +40 -0
- package/dist/review/check-names.d.ts +1 -0
- package/dist/review/check-names.js +1 -0
- package/dist/review/cla-check.d.ts +49 -0
- package/dist/review/cla-check.js +71 -0
- package/dist/review/diff-file-priority.d.ts +1 -0
- package/dist/review/diff-file-priority.js +12 -0
- package/dist/review/enrichment-analyzer-names.d.ts +3 -0
- package/dist/review/enrichment-analyzer-names.js +63 -0
- package/dist/review/guardrail-config.d.ts +10 -0
- package/dist/review/guardrail-config.js +62 -0
- package/dist/review/linked-issue-hard-rules-config.d.ts +4 -0
- package/dist/review/linked-issue-hard-rules-config.js +79 -0
- package/dist/review/linked-issue-label-propagation.d.ts +9 -0
- package/dist/review/linked-issue-label-propagation.js +114 -0
- package/dist/review/pre-merge-checks.d.ts +28 -0
- package/dist/review/pre-merge-checks.js +66 -0
- package/dist/review/review-thread-findings.d.ts +1 -0
- package/dist/review/review-thread-findings.js +1 -0
- package/dist/review/safe-url.d.ts +5 -0
- package/dist/review/safe-url.js +137 -0
- package/dist/review/screenshot-table-gate.d.ts +87 -0
- package/dist/review/screenshot-table-gate.js +340 -0
- package/dist/review/unlinked-issue-guardrail-config.d.ts +9 -0
- package/dist/review/unlinked-issue-guardrail-config.js +44 -0
- package/dist/reviewer-consensus-calibration.d.ts +113 -0
- package/dist/reviewer-consensus-calibration.js +493 -0
- package/dist/reward-risk.d.ts +223 -0
- package/dist/reward-risk.js +618 -0
- package/dist/scoring/label-match.d.ts +4 -0
- package/dist/scoring/label-match.js +145 -0
- package/dist/scoring/model.d.ts +31 -0
- package/dist/scoring/model.js +157 -0
- package/dist/scoring/pending-pr-scenarios.d.ts +44 -0
- package/dist/scoring/pending-pr-scenarios.js +145 -0
- package/dist/scoring/preview.d.ts +246 -0
- package/dist/scoring/preview.js +1108 -0
- package/dist/scoring/types.d.ts +131 -0
- package/dist/scoring/types.js +7 -0
- package/dist/settings/auto-close-exempt.d.ts +10 -0
- package/dist/settings/auto-close-exempt.js +58 -0
- package/dist/settings/autonomy.d.ts +33 -0
- package/dist/settings/autonomy.js +80 -0
- package/dist/settings/command-authorization.d.ts +49 -0
- package/dist/settings/command-authorization.js +222 -0
- package/dist/settings/contributor-blacklist.d.ts +17 -0
- package/dist/settings/contributor-blacklist.js +86 -0
- package/dist/settings/moderation-rules.d.ts +51 -0
- package/dist/settings/moderation-rules.js +104 -0
- package/dist/settings/pr-type-label.d.ts +70 -0
- package/dist/settings/pr-type-label.js +149 -0
- package/dist/signals/change-guardrail.d.ts +49 -0
- package/dist/signals/change-guardrail.js +164 -0
- package/dist/signals/check-summary.d.ts +9 -0
- package/dist/signals/check-summary.js +12 -0
- package/dist/signals/contributor-calibration.d.ts +23 -0
- package/dist/signals/contributor-calibration.js +47 -0
- package/dist/signals/duplicate-winner.d.ts +9 -0
- package/dist/signals/duplicate-winner.js +9 -0
- package/dist/signals/path-matchers.d.ts +36 -0
- package/dist/signals/path-matchers.js +371 -0
- package/dist/signals/predicted-gate-engine.d.ts +62 -0
- package/dist/signals/predicted-gate-engine.js +895 -0
- package/dist/signals/preflight-limits.d.ts +14 -0
- package/dist/signals/preflight-limits.js +14 -0
- package/dist/signals/slop.d.ts +56 -0
- package/dist/signals/slop.js +324 -0
- package/dist/signals/test-evidence.d.ts +25 -0
- package/dist/signals/test-evidence.js +108 -0
- package/dist/subprocess-env.d.ts +23 -0
- package/dist/subprocess-env.js +74 -0
- package/dist/track-record-summary.d.ts +92 -0
- package/dist/track-record-summary.js +301 -0
- package/dist/types/manifest-deps-types.d.ts +514 -0
- package/dist/types/manifest-deps-types.js +4 -0
- package/dist/types/predicted-gate-types.d.ts +338 -0
- package/dist/types/predicted-gate-types.js +3 -0
- package/dist/types/reward-risk-types.d.ts +319 -0
- package/dist/types/reward-risk-types.js +11 -0
- package/dist/utils/json.d.ts +1 -0
- package/dist/utils/json.js +3 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- 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
|
+
}
|