Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ export {
buildSlopAssessment,
buildTrivialWhitespaceChurnFinding,
hasClearNoIssueRationale,
isTestableCodePath,
type SlopAssessment,
type SlopAssessmentInput,
type SlopBand,
Expand Down
12 changes: 11 additions & 1 deletion packages/loopover-engine/src/signals/slop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ const PADDING_DOMINANCE_SHARE = 0.5;
// (where it exempts codegen-only diffs from the missing-test-evidence signal).
const PADDING_CATEGORIES = new Set(["minified", "generated", "vendored"]);

/** The canonical "this path is code that ought to carry tests" predicate: a source file that is NOT
* mechanically-produced padding (generated/vendored/minified output carries source extensions — e.g.
* protoc's `.pb.go` stubs — but nobody hand-writes tests for it). Exported as the single source of truth so
* every test-evidence consumer (buildMissingTestEvidenceFinding here, plus the improvement and
* contributor-open-pr-monitor signals in src/) applies the SAME rule instead of a hand-copied `isCodeFile`
* that disagrees at exactly the codegen boundary (#9696). */
export function isTestableCodePath(path: string): boolean {
return isCodeFile(path) && !PADDING_CATEGORIES.has(classifyChangedFile(path));
}

export function buildSlopAssessment(input: SlopAssessmentInput): SlopAssessment {
const findings: AdvisoryFinding[] = [];
const trivialChurnFinding = buildTrivialWhitespaceChurnFinding(input);
Expand Down Expand Up @@ -282,7 +292,7 @@ export function buildMissingTestEvidenceFinding(input: SlopAssessmentInput): Adv
// so passes the plain isCodeFile check, but nobody hand-writes tests for mechanically regenerated code.
// Mirror buildNonSubstantivePaddingFinding's classifyChangedFile-based exemption so this signal can't
// fire on a codegen-only diff.
const codePaths = changedPaths.filter((path) => isCodeFile(path) && !PADDING_CATEGORIES.has(classifyChangedFile(path)));
const codePaths = changedPaths.filter(isTestableCodePath);
if (codePaths.length === 0) return null;

// A changed test FILE only counts as real test evidence when it carries substantive content. An empty or
Expand Down
28 changes: 28 additions & 0 deletions packages/loopover-engine/test/slop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { buildMissingTestEvidenceFinding, isTestableCodePath } from "../dist/index.js";

// #9696: isTestableCodePath is the single source-of-truth "code that ought to carry tests" predicate — a
// source file that is NOT mechanically-produced padding — and buildMissingTestEvidenceFinding filters on it,
// so a codegen-only diff never trips the missing-test-evidence signal.

test("isTestableCodePath is true for hand-authored source and false for padding or non-code", () => {
assert.equal(isTestableCodePath("src/app/service.ts"), true);
// Generated/vendored/minified output carries source extensions but is not hand-authored, so it is exempt.
assert.equal(isTestableCodePath("api/service.pb.go"), false); // generated
assert.equal(isTestableCodePath("vendor/dep/util.go"), false); // vendored
assert.equal(isTestableCodePath("dist/app.min.js"), false); // minified
assert.equal(isTestableCodePath("README.md"), false); // not a code file at all
});

test("buildMissingTestEvidenceFinding exempts a codegen-only diff but still fires on real code without tests", () => {
// A diff of only generated output has no testable code → the missing-test-evidence signal must not fire.
assert.equal(
buildMissingTestEvidenceFinding({ changedFiles: [{ path: "api/service.pb.go", additions: 500, deletions: 0 }], tests: [], testFiles: [] }),
null,
);
// Real hand-authored code with zero tests → the finding fires.
const finding = buildMissingTestEvidenceFinding({ changedFiles: [{ path: "src/app/service.ts", additions: 120, deletions: 0 }], tests: [], testFiles: [] });
assert.ok(finding && finding.code === "missing_test_evidence", "expected a missing-test-evidence finding for uncovered hand-authored code");
});
12 changes: 7 additions & 5 deletions src/signals/contributor-open-pr-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { CheckSummaryRecord, PullRequestFileRecord, PullRequestRecord, Pull
import { nowIso } from "../utils/json";
import { buildRoleContext } from "./engine";
import { isFailingCheckSummary } from "./check-summary";
import { isCodeFile } from "./local-branch";
import { isTestableCodePath } from "./slop";
import { isTestPath } from "./test-evidence";

export type OpenPrWorkClassification =
Expand Down Expand Up @@ -263,10 +263,12 @@ function normalizeTitle(title: string): string {

function missingTestsFromFiles(files: PullRequestFileRecord[]): boolean {
if (files.length === 0) return false;
// "Code" is genuine source (isCodeFile), not merely "anything that isn't a test": a docs-, lockfile-, or
// config-only PR has no code to cover and must not be flagged missing_tests. Mirrors the isCodeFile code-side
// used by slop.ts's buildMissingTestEvidenceFinding and the local-branch/local-scorer source predicates.
const codeFiles = files.filter((file) => file.path && isCodeFile(file.path));
// "Code" is genuine source that ought to carry tests, not merely "anything that isn't a test": a docs-,
// lockfile-, or config-only PR — or one of only generated/vendored/minified output — has no code to cover
// and must not be flagged missing_tests. Uses slop.ts's shared isTestableCodePath so this signal applies the
// SAME codegen exemption buildMissingTestEvidenceFinding does, instead of a bare isCodeFile that would flag a
// vendored-only diff the gate-side deliberately exempts (#9696).
const codeFiles = files.filter((file) => file.path && isTestableCodePath(file.path));
const testFiles = files.filter((file) => file.path && isTestPath(file.path));
return codeFiles.length > 0 && testFiles.length === 0;
}
Expand Down
8 changes: 5 additions & 3 deletions src/signals/improvement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@
// live source for complexityDeltas, duplicationDeltas, or patchCoverageDeltaPercent — all three are honest
// gaps, not yet wired by design (a later sub-issue's job), and this module must degrade cleanly when they're
// absent (see "insufficient signal" below) rather than fabricate a neutral score.
import { buildMissingTestEvidenceFinding, clamp, type SlopChangedFile } from "./slop";
import { isCodeFile } from "./path-matchers";
import { buildMissingTestEvidenceFinding, clamp, isTestableCodePath, type SlopChangedFile } from "./slop";
import type { SignalFinding } from "./engine";

export type ImprovementBand = "insufficient-signal" | "none" | "minor" | "moderate" | "significant";
Expand Down Expand Up @@ -161,7 +160,10 @@ function finitePatchCoverageDelta(value: number | undefined): number | undefined
}

function hasCodeFileToEvaluate(changedFiles: SlopChangedFile[] | undefined): boolean {
return (changedFiles ?? []).some((file) => Boolean(file.path) && isCodeFile(file.path));
// isTestableCodePath, not bare isCodeFile: a diff of only generated/vendored/minified output (e.g.
// `api/service.pb.go`) is NOT code that needs tests, so it must not disambiguate the null from
// buildMissingTestEvidenceFinding into a positive "test evidence present" finding (#9696).
return (changedFiles ?? []).some((file) => Boolean(file.path) && isTestableCodePath(file.path));
}

// Fires when at least one function's complexity genuinely dropped (a negative delta) after this PR. Mixed
Expand Down
1 change: 1 addition & 0 deletions src/signals/slop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
buildTrivialWhitespaceChurnFinding,
clamp,
hasClearNoIssueRationale,
isTestableCodePath,
slopBandFor,
type SlopAssessment,
type SlopAssessmentInput,
Expand Down
19 changes: 18 additions & 1 deletion test/unit/contributor-open-pr-monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
mapPendingClassToWorkClassification,
} from "../../src/signals/contributor-open-pr-monitor";
import { classifyOpenPullRequest } from "../../src/scoring/pending-pr-scenarios";
import type { PullRequestRecord, PullRequestReviewRecord } from "../../src/types";
import type { PullRequestFileRecord, PullRequestRecord, PullRequestReviewRecord } from "../../src/types";
import type { RoleContext } from "../../src/signals/engine";
import { createTestEnv } from "../helpers/d1";

Expand Down Expand Up @@ -63,6 +63,23 @@ function approvedReview(pullNumber: number): PullRequestReviewRecord {
}

describe("contributor open PR monitor", () => {
it("regression (#9696): missingTestsFromFiles exempts a generated/vendored-only diff, matching the gate-side isTestableCodePath", () => {
const { missingTestsFromFiles } = __contributorOpenPrMonitorInternals;
// A vendored-only diff with no tests must NOT be flagged missing_tests — the gate-side signal deliberately
// exempts exactly this case, so telling the contributor to add tests for vendored code was a false negative.
expect(missingTestsFromFiles([{ path: "vendor/dep/util.go", additions: 200, deletions: 0 }] as PullRequestFileRecord[])).toBe(false);
expect(missingTestsFromFiles([{ path: "api/service.pb.go", additions: 500, deletions: 0 }] as PullRequestFileRecord[])).toBe(false);
// Real hand-authored code with no test file is still flagged.
expect(missingTestsFromFiles([{ path: "src/app/service.ts", additions: 50, deletions: 0 }] as PullRequestFileRecord[])).toBe(true);
// Real code WITH a substantive test file is not flagged.
expect(
missingTestsFromFiles([
{ path: "src/app/service.ts", additions: 50, deletions: 0 },
{ path: "test/unit/service.test.ts", additions: 30, deletions: 0 },
] as PullRequestFileRecord[]),
).toBe(false);
});

it("maps issue #36 classifications from cached review/check metadata", () => {
const approved = classifyOpenPullRequest({
pr: pr({ number: 1 }),
Expand Down
11 changes: 11 additions & 0 deletions test/unit/improvement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ describe("buildStructuralImprovementAssessment", () => {
expect(result.findings).toEqual([expect.objectContaining({ code: "added_test_evidence", severity: "info" })]);
});

it("regression (#9696): a codegen-only diff (no tests) does NOT emit a false added_test_evidence signal", () => {
// api/service.pb.go passes bare isCodeFile (a `.go` extension) but is generated output nobody hand-tests.
// Before the fix, hasCodeFileToEvaluate used bare isCodeFile, so buildMissingTestEvidenceFinding's null
// (no testable code) was read as "test evidence present" and the band became `minor` for a zero-test PR.
const result = buildStructuralImprovementAssessment({
changedFiles: [{ path: "api/service.pb.go", additions: 500, deletions: 0 }],
});
expect(result.findings.map((finding) => finding.code)).not.toContain("added_test_evidence");
expect(result.band).toBe("insufficient-signal");
});

it("stacks both corroborating signals (30) to a band that still sits below a single structural signal (35)", () => {
const result = buildStructuralImprovementAssessment({
patchCoverageDeltaPercent: 5,
Expand Down