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
42 changes: 41 additions & 1 deletion src/signals/slop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export type IssueSlopAssessmentInput = {
export const ISSUE_SLOP_WEIGHTS = {
unfilledTemplate: 50,
emptyBody: 40,
titleRestatement: 35,
} as const;

export const ISSUE_SLOP_RUBRIC_MARKDOWN = [
Expand All @@ -366,6 +367,7 @@ export const ISSUE_SLOP_RUBRIC_MARKDOWN = [
"Advisory-only (issues never block). Current deterministic signals:",
"- empty issue body",
"- issue template opened but left unfilled",
"- issue body only restates the title (no added detail)",
].join("\n");

export function buildIssueSlopAssessment(input: IssueSlopAssessmentInput): SlopAssessment {
Expand All @@ -374,11 +376,17 @@ export function buildIssueSlopAssessment(input: IssueSlopAssessmentInput): SlopA
// An empty body and an unfilled template are mutually exclusive (the latter needs a non-empty body), so
// only probe for the template when there IS a body to inspect.
const unfilledTemplateFinding = emptyBodyFinding ? null : buildUnfilledIssueTemplateFinding(input);
// The title-restatement signal needs a body with REAL prose (so it survives the unfilled-template strip),
// so it can only fire once the two emptier signals are ruled out — the three are mutually exclusive.
const titleRestatementFinding = emptyBodyFinding || unfilledTemplateFinding ? null : buildTitleRestatementIssueFinding(input);
if (unfilledTemplateFinding) findings.push(unfilledTemplateFinding);
if (emptyBodyFinding) findings.push(emptyBodyFinding);
if (titleRestatementFinding) findings.push(titleRestatementFinding);

const slopRisk = clamp(
(emptyBodyFinding ? ISSUE_SLOP_WEIGHTS.emptyBody : 0) + (unfilledTemplateFinding ? ISSUE_SLOP_WEIGHTS.unfilledTemplate : 0),
(emptyBodyFinding ? ISSUE_SLOP_WEIGHTS.emptyBody : 0) +
(unfilledTemplateFinding ? ISSUE_SLOP_WEIGHTS.unfilledTemplate : 0) +
(titleRestatementFinding ? ISSUE_SLOP_WEIGHTS.titleRestatement : 0),
0,
100,
);
Expand Down Expand Up @@ -425,6 +433,38 @@ export function buildUnfilledIssueTemplateFinding(input: IssueSlopAssessmentInpu
};
}

// Normalize for restatement comparison: lowercase, then collapse every run of non-alphanumeric characters
// (punctuation, markdown, whitespace, emoji) to a single space. This makes "Login is BROKEN!" and
// "login is broken" compare equal, so reformatting/punctuation alone cannot dodge the signal.
function normalizeIssueText(text: string): string {
return text
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}

// Fires when a non-empty body adds NOTHING beyond the title — it normalizes to exactly the title (a verbatim
// restatement or the title pasted back as the "description"). High-precision and conservative: the body must
// reduce to the title with zero extra words, so any genuine added detail (steps, location, expected vs actual)
// clears it. Distinct from the unfilled-template signal, whose body has no real word at all. (#533)
export function buildTitleRestatementIssueFinding(input: IssueSlopAssessmentInput): SignalFinding | null {
const title = normalizeIssueText(input.title ?? "");
const body = normalizeIssueText(input.body ?? "");
// Need both a real title and a real body to compare; an empty side is another signal's concern.
if (title.length === 0 || body.length === 0) return null;
if (body !== title) return null;
// Static, public-safe text (no interpolation) — no sanitizer guard needed.
const detail = "The issue body only restates the title and adds no further detail.";
return {
code: "title_only_restatement",
title: "Issue body only restates the title",
severity: "warning",
detail,
action: "Add detail beyond the title: what is wrong, where it happens, and why it matters.",
publicText: detail,
};
}

function stripHtmlComments(input: string): string {
let output = "";
let cursor = 0;
Expand Down
34 changes: 34 additions & 0 deletions test/unit/slop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
buildNoLinkedIssueRationaleFinding,
buildNonSubstantivePaddingFinding,
buildSlopAssessment,
buildTitleRestatementIssueFinding,
buildTrivialWhitespaceChurnFinding,
buildUnfilledIssueTemplateFinding,
type SlopAssessmentInput,
Expand Down Expand Up @@ -498,6 +499,39 @@ describe("buildIssueSlopAssessment (#533 issue-side triage)", () => {
// A genuine (even terse) description survives — a real 3+ letter word is present.
expect(buildUnfilledIssueTemplateFinding({ body: "### Description\nThe build fails on save.\n" })).toBeNull();
});

it("flags a body that only restates the title, ignoring case and punctuation (#533)", () => {
const result = buildIssueSlopAssessment({ title: "Login is broken", body: "login is broken!!!" });
expect(result.findings.map((f) => f.code)).toEqual(["title_only_restatement"]);
expect(result.slopRisk).toBe(ISSUE_SLOP_WEIGHTS.titleRestatement);
expect(result.band).toBe("elevated");
expect(JSON.stringify(result)).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
});

it("does NOT flag a body that adds any detail beyond the title", () => {
// The body repeats the title but adds real detail → not a bare restatement.
expect(buildIssueSlopAssessment({ title: "Login is broken", body: "Login is broken when the session token has expired." }).findings).toEqual([]);
});

it("title-restatement is mutually exclusive with the emptier signals", () => {
// Empty body → empty_issue_body only (never restatement, which needs a real body).
expect(buildIssueSlopAssessment({ title: "Login is broken", body: "" }).findings.map((f) => f.code)).toEqual(["empty_issue_body"]);
// Unfilled template → unfilled_issue_template only (no real word to match the title).
expect(buildIssueSlopAssessment({ title: "Login is broken", body: "<!-- nothing -->" }).findings.map((f) => f.code)).toEqual([
"unfilled_issue_template",
]);
});

it("title-restatement builder guards a missing title or body when called directly", () => {
// Both sides must normalize to real text; an empty/omitted title or body yields no finding.
expect(buildTitleRestatementIssueFinding({ title: "", body: "anything" })).toBeNull();
expect(buildTitleRestatementIssueFinding({ body: "no title here" })).toBeNull();
expect(buildTitleRestatementIssueFinding({ title: "Only a title" })).toBeNull();
// A title that is pure punctuation normalizes to empty → no finding even against a matching body.
expect(buildTitleRestatementIssueFinding({ title: "!!!", body: "???" })).toBeNull();
// A genuine restatement fires.
expect(buildTitleRestatementIssueFinding({ title: "Build fails", body: "Build fails." })).toMatchObject({ code: "title_only_restatement" });
});
});

describe("buildNonSubstantivePaddingFinding (#561 path-matcher signal)", () => {
Expand Down
Loading