Skip to content

Commit 5ae2ba9

Browse files
Merge branch 'main' into feat/guardrail-mode
2 parents 59b7643 + 84553fc commit 5ae2ba9

55 files changed

Lines changed: 1646 additions & 92 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/loopover-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ export {
465465
buildSlopAssessment,
466466
buildTrivialWhitespaceChurnFinding,
467467
hasClearNoIssueRationale,
468+
isTestableCodePath,
468469
type SlopAssessment,
469470
type SlopAssessmentInput,
470471
type SlopBand,

packages/loopover-engine/src/signals/slop.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ const PADDING_DOMINANCE_SHARE = 0.5;
110110
// (where it exempts codegen-only diffs from the missing-test-evidence signal).
111111
const PADDING_CATEGORIES = new Set(["minified", "generated", "vendored"]);
112112

113+
/** The canonical "this path is code that ought to carry tests" predicate: a source file that is NOT
114+
* mechanically-produced padding (generated/vendored/minified output carries source extensions — e.g.
115+
* protoc's `.pb.go` stubs — but nobody hand-writes tests for it). Exported as the single source of truth so
116+
* every test-evidence consumer (buildMissingTestEvidenceFinding here, plus the improvement and
117+
* contributor-open-pr-monitor signals in src/) applies the SAME rule instead of a hand-copied `isCodeFile`
118+
* that disagrees at exactly the codegen boundary (#9696). */
119+
export function isTestableCodePath(path: string): boolean {
120+
return isCodeFile(path) && !PADDING_CATEGORIES.has(classifyChangedFile(path));
121+
}
122+
113123
export function buildSlopAssessment(input: SlopAssessmentInput): SlopAssessment {
114124
const findings: AdvisoryFinding[] = [];
115125
const trivialChurnFinding = buildTrivialWhitespaceChurnFinding(input);
@@ -282,7 +292,7 @@ export function buildMissingTestEvidenceFinding(input: SlopAssessmentInput): Adv
282292
// so passes the plain isCodeFile check, but nobody hand-writes tests for mechanically regenerated code.
283293
// Mirror buildNonSubstantivePaddingFinding's classifyChangedFile-based exemption so this signal can't
284294
// fire on a codegen-only diff.
285-
const codePaths = changedPaths.filter((path) => isCodeFile(path) && !PADDING_CATEGORIES.has(classifyChangedFile(path)));
295+
const codePaths = changedPaths.filter(isTestableCodePath);
286296
if (codePaths.length === 0) return null;
287297

288298
// A changed test FILE only counts as real test evidence when it carries substantive content. An empty or
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import { buildMissingTestEvidenceFinding, isTestableCodePath } from "../dist/index.js";
5+
6+
// #9696: isTestableCodePath is the single source-of-truth "code that ought to carry tests" predicate — a
7+
// source file that is NOT mechanically-produced padding — and buildMissingTestEvidenceFinding filters on it,
8+
// so a codegen-only diff never trips the missing-test-evidence signal.
9+
10+
test("isTestableCodePath is true for hand-authored source and false for padding or non-code", () => {
11+
assert.equal(isTestableCodePath("src/app/service.ts"), true);
12+
// Generated/vendored/minified output carries source extensions but is not hand-authored, so it is exempt.
13+
assert.equal(isTestableCodePath("api/service.pb.go"), false); // generated
14+
assert.equal(isTestableCodePath("vendor/dep/util.go"), false); // vendored
15+
assert.equal(isTestableCodePath("dist/app.min.js"), false); // minified
16+
assert.equal(isTestableCodePath("README.md"), false); // not a code file at all
17+
});
18+
19+
test("buildMissingTestEvidenceFinding exempts a codegen-only diff but still fires on real code without tests", () => {
20+
// A diff of only generated output has no testable code → the missing-test-evidence signal must not fire.
21+
assert.equal(
22+
buildMissingTestEvidenceFinding({ changedFiles: [{ path: "api/service.pb.go", additions: 500, deletions: 0 }], tests: [], testFiles: [] }),
23+
null,
24+
);
25+
// Real hand-authored code with zero tests → the finding fires.
26+
const finding = buildMissingTestEvidenceFinding({ changedFiles: [{ path: "src/app/service.ts", additions: 120, deletions: 0 }], tests: [], testFiles: [] });
27+
assert.ok(finding && finding.code === "missing_test_evidence", "expected a missing-test-evidence finding for uncovered hand-authored code");
28+
});

packages/loopover-miner/lib/attempt-cli.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,8 +1097,16 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {}
10971097
// the worktree to postmortem -- those are the cases that default to `true` (nothing to retain), matching
10981098
// cleanupAttemptWorktree's own retention policy (a failed REAL attempt is what gets retained).
10991099
if (worktreeResult?.ok) {
1100-
const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree;
1101-
await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true);
1100+
// #9677: cleanupWorktree spawns `git worktree remove` -- a locked worktree, a git failure, or a timeout must
1101+
// never abort the rest of this teardown (the claim release, allocator release, and every close() below).
1102+
// Catch-and-capture, mirroring the DB-fork discard block below; an unremoved worktree is a lesser harm than
1103+
// a stranded `active` claim that blocks a sibling miner for the ledger's full expiry window.
1104+
try {
1105+
const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree;
1106+
await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true);
1107+
} catch (error) {
1108+
captureMinerError(error, { kind: "attempt_worktree_cleanup_failed", repoFullName: parsed.repoFullName, attemptId });
1109+
}
11021110
}
11031111
// Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an
11041112
// unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would
@@ -1108,8 +1116,15 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {}
11081116
// the initial claim submission actually ran (claimRecord is only set once claimedIssue is), so a run that
11091117
// never reached the claim point (e.g. blocked_max_concurrent_claims) has nothing to release remotely.
11101118
if (claimedIssue && claimRecord && isDiscoveryPlaneEnabled(env)) {
1111-
const submitClaim = options.submitSoftClaim ?? submitSoftClaim;
1112-
await submitClaim({ ...claimRecord, status: "released" } as Parameters<typeof SubmitSoftClaimFn>[0], { env });
1119+
// #9677: the hosted release is a network POST -- a down discovery plane or a non-2xx response must not
1120+
// strand the local claim release / allocator release / close()s that follow. Catch-and-capture like the
1121+
// DB-fork discard block below rather than letting the finally throw.
1122+
try {
1123+
const submitClaim = options.submitSoftClaim ?? submitSoftClaim;
1124+
await submitClaim({ ...claimRecord, status: "released" } as Parameters<typeof SubmitSoftClaimFn>[0], { env });
1125+
} catch (error) {
1126+
captureMinerError(error, { kind: "attempt_hosted_claim_release_failed", repoFullName: parsed.repoFullName, attemptId });
1127+
}
11131128
}
11141129
if (allocation && allocator) allocator.release(attemptId);
11151130
// #7858: discard the disposable DB fork on every terminal outcome, mirroring the worktree release above.

packages/loopover-miner/lib/attempt-worktree.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,10 @@ export async function prepareAttemptWorktree(
8181
});
8282
// ensureRepoCloned's own EnsureRepoClonedResult declares `error` optional, but every one of its real ok:false
8383
// return sites (repo-clone.ts) sets a real, non-empty error string -- a non-null assertion here rather than a
84-
// fake fallback string, since that fallback would be genuinely unreachable dead code.
85-
if (!cloneResult.ok) return { ok: false, error: cloneResult.error! };
84+
// fake fallback string, since that fallback would be genuinely unreachable dead code. repoPath is always set on
85+
// its failures too, so forward it (matching the worktree-add failure below) -- a clone/checkout failure and a
86+
// worktree-add failure now surface the same {ok:false, repoPath, error} shape to callers.
87+
if (!cloneResult.ok) return { ok: false, repoPath: cloneResult.repoPath, error: cloneResult.error! };
8688

8789
const exec = options.exec ?? createRealWorktreeExec(options.timeoutMs);
8890
const baseBranch = typeof options.baseBranch === "string" && options.baseBranch.trim() ? options.baseBranch.trim() : "main";

packages/loopover-miner/lib/discover-cli.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner
22
* can actually run it. Every piece already exists and is independently tested; this module only composes them. */
3+
import { existsSync } from "node:fs";
34
import { resolveForgeConfig } from "./forge-config.js";
45
import type { ForgeConfig } from "./forge-config.js";
56
import {
@@ -22,6 +23,7 @@ import { initPolicyDocCacheStore } from "./policy-doc-cache.js";
2223
import type { PolicyDocCacheStore } from "./policy-doc-cache.js";
2324
import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js";
2425
import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js";
26+
import { isValidRepoSegment } from "./repo-clone.js";
2527
import { enqueueRankedDiscovery } from "./portfolio-discovery.js";
2628
import { AMS_MIN_RANK_SHIPPED, readMinRankAutotuneEnabled, readMinRankOverride } from "./ams-calibration.js";
2729
import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js";
@@ -185,11 +187,19 @@ async function supplementWithDiscoveryIndex(
185187
if (!isDiscoveryPlaneEnabled(env)) return fanOut;
186188
const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex;
187189
const response = await queryIndex(queryScope, { env });
188-
recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env });
189-
if (response.candidates.length === 0) return fanOut;
190+
// #9680: the hosted index preserves a repo's AI-contribution ban (normalizeDiscoveryIndexCandidate keeps
191+
// aiPolicyAllowed:false), but nothing downstream re-checks it -- the `as RawCandidateIssue` cast below would
192+
// launder a `false` through a type declared as the literal `true`, so an AI-banned repo's issue would be ranked
193+
// and enqueued. Enforce the ban here exactly as the local fetchTargetIssues does (`if (!verdict.allowed) return
194+
// []`): drop it, not down-rank or warn. A candidate that omits the field is kept (`!== false`, matching
195+
// normalizeDiscoveryIndexCandidate's own default).
196+
const aiAllowed = response.candidates.filter((candidate) => candidate.aiPolicyAllowed !== false);
197+
const droppedAiBanned = response.candidates.length - aiAllowed.length;
198+
recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env, droppedAiBanned });
199+
if (aiAllowed.length === 0) return fanOut;
190200

191201
const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber)));
192-
const supplemented = response.candidates
202+
const supplemented = aiAllowed
193203
.filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber)))
194204
// DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; copy the real assignees through when the
195205
// hosted contract carried them (#7442), falling back to [] only when the served response genuinely omitted the
@@ -203,6 +213,7 @@ function parseRepoTarget(value: string): FanoutTarget | null {
203213
const trimmed = value.trim();
204214
const [owner, repo, extra] = trimmed.split("/");
205215
if (!owner || !repo || extra !== undefined) return null;
216+
if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null;
206217
return { owner, repo };
207218
}
208219

@@ -516,8 +527,17 @@ export async function runDiscover(args: string[], options: RunDiscoverOptions =
516527
let overrideLedger = null;
517528
try {
518529
const ledgerEnv = options.env ?? process.env;
519-
overrideLedger = initEventLedger(resolveEventLedgerDbPath(ledgerEnv));
520-
minRankScore = readMinRankOverride(overrideLedger, { enabled: readMinRankAutotuneEnabled(ledgerEnv) }) ?? AMS_MIN_RANK_SHIPPED;
530+
const ledgerDbPath = resolveEventLedgerDbPath(ledgerEnv);
531+
// #9679: --dry-run must make ZERO filesystem writes, but initEventLedger creates + migrates + prunes the
532+
// ledger file. On the dry-run path only read the override when the ledger file ALREADY exists (opening a
533+
// not-yet-existing SQLite file is itself a write, and retention pruning can delete rows) -- a missing file
534+
// falls back to the shipped default, exactly the value an empty/new ledger would yield, so the preview is
535+
// unchanged when it exists. Same "skip a file that doesn't exist yet" discipline as migrate-cli.ts /
536+
// store-maintenance.ts. The real (non-dry-run) run is unchanged: it opens unconditionally.
537+
if (!parsed.dryRun || existsSync(ledgerDbPath)) {
538+
overrideLedger = initEventLedger(ledgerDbPath);
539+
minRankScore = readMinRankOverride(overrideLedger, { enabled: readMinRankAutotuneEnabled(ledgerEnv) }) ?? AMS_MIN_RANK_SHIPPED;
540+
}
521541
} catch {
522542
minRankScore = AMS_MIN_RANK_SHIPPED;
523543
} finally {

packages/loopover-miner/lib/discovery-index-client.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,16 @@ export async function submitSoftClaim(
171171
export function recordDiscoveryTelemetry(
172172
event: string,
173173
outcome: string,
174-
options: { env?: Record<string, string | undefined> } = {},
174+
options: { env?: Record<string, string | undefined>; droppedAiBanned?: number } = {},
175175
): void {
176176
const env = options.env ?? process.env;
177177
if (!isDiscoveryPlaneEnabled(env) || !isDiscoveryTelemetryEnabled(env)) return;
178-
getLogger().info("discovery_plane_telemetry", { event, outcome });
178+
// #9680: low-cardinality count of index candidates dropped for an AI-contribution ban this query, when the
179+
// caller supplies it. Spread conditionally so callers that don't pass it keep the exact { event, outcome }
180+
// shape (and payload) they emitted before.
181+
getLogger().info("discovery_plane_telemetry", {
182+
event,
183+
outcome,
184+
...(options.droppedAiBanned !== undefined ? { droppedAiBanned: options.droppedAiBanned } : {}),
185+
});
179186
}

packages/loopover-miner/lib/event-ledger.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { DatabaseSync, SQLOutputValue } from "node:sqlite";
22
import { isDeepStrictEqual } from "node:util";
33
import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
44
import { applySchemaMigrations } from "./schema-version.js";
5+
import { isValidRepoSegment } from "./repo-clone.js";
56
import {
67
EVENT_LEDGER_PURGE_SPEC,
78
EVENT_LEDGER_RETENTION_SPEC,
@@ -83,6 +84,7 @@ function normalizeOptionalRepoFullName(repoFullName: unknown): string | null {
8384
if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
8485
const [owner, repo, extra] = repoFullName.trim().split("/");
8586
if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name");
87+
if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) throw new Error("invalid_repo_full_name");
8688
return `${owner}/${repo}`;
8789
}
8890

packages/loopover-miner/lib/loop-cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { buildLoopClosureSummary } from "./loop-closure.js";
4949
import { attemptLoopReentry } from "./loop-reentry.js";
5050
import { parsePrNumberFromExecResult } from "./pr-number-parse.js";
5151
import { resolveGitHubToken } from "./github-token-resolution.js";
52+
import { isValidRepoSegment } from "./repo-clone.js";
5253
import { DEFAULT_AMS_POLICY_SPEC } from "@loopover/engine";
5354
import type { GovernorCapUsage } from "@loopover/engine";
5455

@@ -118,6 +119,7 @@ function parseRepoTarget(value: string): string | null {
118119
const trimmed = value.trim();
119120
const [owner, repo, extra] = trimmed.split("/");
120121
if (!owner || !repo || extra !== undefined) return null;
122+
if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null;
121123
return `${owner}/${repo}`;
122124
}
123125

packages/loopover-miner/lib/manage-poll.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { PortfolioQueueStore } from "./portfolio-queue.js";
1111
import { DEFAULT_FORGE_CONFIG } from "./forge-config.js";
1212
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
1313
import { resolveGitHubToken } from "./github-token-resolution.js";
14+
import { isValidRepoSegment } from "./repo-clone.js";
1415

1516
const MANAGE_POLL_USAGE =
1617
"Usage: loopover-miner manage poll <owner/repo> <pr#> [--branch <name>] [--dry-run] [--json]";
@@ -54,6 +55,9 @@ function parseRepoArg(value: string): { repoFullName: string } | { error: string
5455
if (!owner || !repo || extra !== undefined) {
5556
return { error: "Repository must be in owner/repo form." };
5657
}
58+
if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) {
59+
return { error: "Repository must be in owner/repo form." };
60+
}
5761
return { repoFullName: `${owner}/${repo}` };
5862
}
5963

0 commit comments

Comments
 (0)