From 04884ed8495883357c0fbfe9fe5ccecfe2ea3863 Mon Sep 17 00:00:00 2001 From: jaso0n0818 Date: Fri, 26 Jun 2026 09:31:40 +0000 Subject: [PATCH] feat(signals): flag issues whose body only restates the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third deterministic issue-slop signal (#533) alongside the empty-body and unfilled-template findings: `title_only_restatement` fires when a non-empty issue body normalizes to exactly the title, i.e. the submitter pasted the title back as the description and added nothing. Conservative and high-precision, matching the issue-triage rubric: the body must reduce to the title with zero extra words after case- and punctuation-insensitive normalization, so any genuine added detail (steps, location, expected vs actual) clears it. The three issue signals stay mutually exclusive — restatement only evaluates once empty-body and unfilled-template are ruled out, since it requires a body with real prose. --- src/signals/slop.ts | 42 +++++++++++++++++++++++++++++++++++++++++- test/unit/slop.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/signals/slop.ts b/src/signals/slop.ts index 8fe10aa39f..467b4e7d07 100644 --- a/src/signals/slop.ts +++ b/src/signals/slop.ts @@ -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 = [ @@ -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 { @@ -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, ); @@ -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; diff --git a/test/unit/slop.test.ts b/test/unit/slop.test.ts index cf7000529e..1839f63696 100644 --- a/test/unit/slop.test.ts +++ b/test/unit/slop.test.ts @@ -10,6 +10,7 @@ import { buildNoLinkedIssueRationaleFinding, buildNonSubstantivePaddingFinding, buildSlopAssessment, + buildTitleRestatementIssueFinding, buildTrivialWhitespaceChurnFinding, buildUnfilledIssueTemplateFinding, type SlopAssessmentInput, @@ -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: "" }).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)", () => {