Skip to content
70 changes: 69 additions & 1 deletion src/signals/slop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SignalFinding } from "./engine";

Check warning on line 1 in src/signals/slop.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #560.

Check notice on line 1 in src/signals/slop.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #560.

Check notice on line 1 in src/signals/slop.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in src/signals/slop.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
import { isCodeFile, isTestFile } from "./local-branch";
import { hasLocalTestEvidence, isTestPath } from "./test-evidence";
import { isFocusManifestPublicSafe } from "./focus-manifest";
Expand All @@ -25,6 +25,7 @@

export const SLOP_WEIGHTS = {
Comment thread
kiannidev marked this conversation as resolved.
missingTestEvidence: 30,
Comment thread
kiannidev marked this conversation as resolved.
trivialWhitespaceChurn: 25,
Comment thread
kiannidev marked this conversation as resolved.
} as const;
Comment thread
kiannidev marked this conversation as resolved.

export const SLOP_RUBRIC_MARKDOWN = [
Expand All @@ -37,14 +38,25 @@
"",
"Current deterministic signals:",
Comment thread
kiannidev marked this conversation as resolved.
"- missing test evidence",
"- trivial / whitespace-only churn",
].join("\n");
Comment thread
kiannidev marked this conversation as resolved.
Comment thread
kiannidev marked this conversation as resolved.

Comment thread
kiannidev marked this conversation as resolved.
const MIN_CHURN_LINES = 40;
const MAX_SOURCE_LINE_SHARE = 0.15;

export function buildSlopAssessment(input: SlopAssessmentInput): SlopAssessment {
const findings: SignalFinding[] = [];
const missingTestEvidenceFinding = buildMissingTestEvidenceFinding(input);
const trivialChurnFinding = buildTrivialWhitespaceChurnFinding(input);
if (missingTestEvidenceFinding) findings.push(missingTestEvidenceFinding);
if (trivialChurnFinding) findings.push(trivialChurnFinding);

const slopRisk = clamp(missingTestEvidenceFinding ? SLOP_WEIGHTS.missingTestEvidence : 0, 0, 100);
const slopRisk = clamp(
(missingTestEvidenceFinding ? SLOP_WEIGHTS.missingTestEvidence : 0) +
(trivialChurnFinding ? SLOP_WEIGHTS.trivialWhitespaceChurn : 0),
0,
100,
);

return {
slopRisk,
Expand Down Expand Up @@ -83,6 +95,62 @@
};
}

Comment thread
kiannidev marked this conversation as resolved.
export function buildTrivialWhitespaceChurnFinding(input: SlopAssessmentInput): SignalFinding | null {
Comment thread
kiannidev marked this conversation as resolved.
Comment thread
kiannidev marked this conversation as resolved.
const changedFiles = input.changedFiles ?? [];
const lineTotals = summarizeChangedLines(changedFiles);
if (lineTotals.changedLineCount < MIN_CHURN_LINES) return null;
Comment thread
kiannidev marked this conversation as resolved.
if (lineTotals.sourceLineCount === 0) {
return buildTrivialChurnFinding(lineTotals.changedLineCount, lineTotals.nonCodeLineCount);
}
const sourceShare = lineTotals.sourceLineCount / lineTotals.changedLineCount;
if (sourceShare > MAX_SOURCE_LINE_SHARE) return null;
return buildTrivialChurnFinding(lineTotals.changedLineCount, lineTotals.nonCodeLineCount);
}

function summarizeChangedLines(changedFiles: SlopChangedFile[]): {
changedLineCount: number;
sourceLineCount: number;
testLineCount: number;
nonCodeLineCount: number;
} {
const changedLineCount = changedFiles.reduce(
(sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions),
0,
);
const sourceLineCount = changedFiles
.filter((file) => isCodeFile(file.path))
.reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0);
const testLineCount = changedFiles
.filter((file) => isTestFile(file.path))
.reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0);
const nonCodeLineCount = Math.max(0, changedLineCount - sourceLineCount - testLineCount);
return { changedLineCount, sourceLineCount, testLineCount, nonCodeLineCount };
}

function buildTrivialChurnFinding(changedLineCount: number, nonCodeLineCount: number): SignalFinding {
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.",
);
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.",
);

return {
code: "trivial_whitespace_churn",
title: "Diff looks like trivial or whitespace-only churn",
severity: "warning",
detail,
action,
publicText: detail,
};
}

function nonNegative(value: number | undefined): number {
return Number.isFinite(value) && (value ?? 0) > 0 ? Math.trunc(value as number) : 0;
}

function ensurePublicSafeText(text: string, fallback: string): string {
return isFocusManifestPublicSafe(text) ? text : fallback;
}
Expand Down
74 changes: 72 additions & 2 deletions test/unit/slop.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";

Check warning on line 1 in test/unit/slop.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #560.

Check notice on line 1 in test/unit/slop.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #560.

Check notice on line 1 in test/unit/slop.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in test/unit/slop.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
import {
buildMissingTestEvidenceFinding,
buildSlopAssessment,
buildTrivialWhitespaceChurnFinding,
SLOP_RUBRIC_MARKDOWN,
SLOP_WEIGHTS,
} from "../../src/signals/slop";
Expand All @@ -13,6 +14,7 @@
it("exports rubric bands and a deterministic assessment shell", () => {
expect(SLOP_RUBRIC_MARKDOWN).toContain("clean");
expect(SLOP_RUBRIC_MARKDOWN).toContain("missing test evidence");
expect(SLOP_RUBRIC_MARKDOWN).toContain("trivial / whitespace-only churn");

const clean = buildSlopAssessment({});
expect(clean).toEqual({ slopRisk: 0, band: "clean", findings: [] });
Expand All @@ -35,6 +37,27 @@
expect(JSON.stringify(result)).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
});

it("raises trivial-churn slop for high-churn diffs with minimal source lines", () => {
const result = buildSlopAssessment({
changedFiles: [
{ path: "README.md", additions: 30, deletions: 20 },
{ path: "docs/guide.md", additions: 25, deletions: 15 },
{ path: "src/widget.ts", additions: 2, deletions: 1 },
{ path: "test/unit/widget.test.ts", additions: 4, deletions: 0 },
],
});

expect(result.slopRisk).toBe(SLOP_WEIGHTS.trivialWhitespaceChurn);
expect(result.band).toBe("elevated");
expect(result.findings).toEqual([
expect.objectContaining({
code: "trivial_whitespace_churn",
severity: "warning",
}),
]);
expect(JSON.stringify(result)).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
});

it("does not raise missing-test-evidence when changed test files are present", () => {
expect(
buildSlopAssessment({
Expand All @@ -55,13 +78,43 @@
).toEqual({ slopRisk: 0, band: "clean", findings: [] });
});

it("ignores docs-only diffs without code files", () => {
it("does not raise trivial-churn when substantive source edits dominate", () => {
expect(
buildSlopAssessment({
changedFiles: [
{ path: "src/registry/sync.ts", additions: 80, deletions: 20 },
{ path: "test/unit/registry-sync.test.ts", additions: 40, deletions: 5 },
],
}),
).toEqual({ slopRisk: 0, band: "clean", findings: [] });
});

it("does not raise trivial-churn for small diffs below the churn threshold", () => {
expect(
buildSlopAssessment({
changedFiles: [{ path: "README.md", additions: 10, deletions: 8 }],
}),
).toEqual({ slopRisk: 0, band: "clean", findings: [] });
});

it("ignores docs-only diffs without code files for missing-test-evidence", () => {
expect(
buildSlopAssessment({
changedFiles: [{ path: "README.md", additions: 40, deletions: 0 }],
changedFiles: [{ path: "README.md", additions: 10, deletions: 0 }],
}),
).toEqual({ slopRisk: 0, band: "clean", findings: [] });
});

it("raises trivial-churn for non-code-only high-churn diffs", () => {
expect(
buildSlopAssessment({
changedFiles: [
{ path: "README.md", additions: 25, deletions: 20 },
{ path: "docs/guide.md", additions: 20, deletions: 15 },
],
}).findings.map((finding) => finding.code),
).toEqual(["trivial_whitespace_churn"]);
});
});

describe("buildMissingTestEvidenceFinding", () => {
Expand All @@ -77,3 +130,20 @@
expect(JSON.stringify(finding)).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
});
});

describe("buildTrivialWhitespaceChurnFinding", () => {
it("keeps public reason strings sanitized", () => {
const finding = buildTrivialWhitespaceChurnFinding({
changedFiles: [
{ path: "README.md", additions: 30, deletions: 20 },
{ path: "docs/guide.md", additions: 25, deletions: 15 },
],
});

expect(finding).toMatchObject({
code: "trivial_whitespace_churn",
publicText: expect.any(String),
});
expect(JSON.stringify(finding)).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
});
});
Loading