Skip to content

Commit f37e669

Browse files
martyndaviesclaude
andcommitted
Group repeated issues by rule code with expandable occurrences
DetailedScoreSection now collapses identical-rule occurrences into one header row per rule code with an "× N" count badge, expandable to show the underlying per-occurrence rows. Reduces noise on specs that emit the same rule across many operations (e.g. owasp:api3:2019-define- error-responses-401 firing once per endpoint). Behaviour: - One row per unique rule code. Groups sorted by severity asc, then occurrence count desc, then message. - Occurrences <= 3 auto-expand; larger groups start collapsed. User can toggle either way. - Single-occurrence groups have no count badge and behave like the old flat row — clicking opens the IssueModal directly. - Each occurrence row shows a friendly operation label ("GET /users/{id}") when the path includes "paths" + an HTTP method, with the source line number as a chip; falls back to the joined path otherwise. Clicking an occurrence opens the IssueModal with that specific issue, so the existing AI-fix flow is preserved. - Pagination now pages over unique groups, not raw issues. Footer shows "X of Y unique issues (Z total occurrences)" for clarity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 750a7e1 commit f37e669

1 file changed

Lines changed: 194 additions & 34 deletions

File tree

  • apps/web/src/components/DetailedScoreSection

apps/web/src/components/DetailedScoreSection/index.tsx

Lines changed: 194 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import getScoreTextColor from "@/utils/get-score-test-color";
44
import {
5+
CaretDown,
6+
CaretRight,
57
FileMagnifyingGlass,
68
Info,
79
Lightning,
@@ -10,7 +12,7 @@ import {
1012
type Icon,
1113
} from "@phosphor-icons/react";
1214
import classNames from "classnames";
13-
import { useState } from "react";
15+
import { useMemo, useState } from "react";
1416
import { useModal } from "react-modal-hook";
1517
import IssueModal from "../IssueModal";
1618

@@ -25,8 +27,16 @@ export type Issue = {
2527
};
2628
};
2729

28-
const PAGE_LENGTH = 5;
29-
const INITIAL_LENGTH = 5;
30+
type GroupedIssue = {
31+
code: string;
32+
severity: number;
33+
message: string;
34+
occurrences: Issue[];
35+
};
36+
37+
const INITIAL_GROUPS = 5;
38+
const PAGE_GROUPS = 5;
39+
const AUTO_EXPAND_THRESHOLD = 3;
3040

3141
type SeverityKey = "error" | "warning" | "info" | "hint";
3242

@@ -69,6 +79,63 @@ const SeverityTag = ({ severity }: { severity: number }) => {
6979
);
7080
};
7181

82+
// Build a friendly operation label from a Spectral issue path.
83+
// e.g. ["paths", "/users/{id}", "get", "responses", "401"] -> "GET /users/{id}"
84+
// Falls back to the joined path when no HTTP verb is found.
85+
const HTTP_METHODS = new Set([
86+
"get",
87+
"post",
88+
"put",
89+
"patch",
90+
"delete",
91+
"options",
92+
"head",
93+
"trace",
94+
]);
95+
96+
function describeOccurrence(issue: Issue): string {
97+
const parts = issue.path.map(String);
98+
const pathsIdx = parts.indexOf("paths");
99+
if (pathsIdx >= 0 && pathsIdx + 2 < parts.length) {
100+
const route = parts[pathsIdx + 1];
101+
const method = parts[pathsIdx + 2];
102+
if (HTTP_METHODS.has(method.toLowerCase())) {
103+
return `${method.toUpperCase()} ${route}`;
104+
}
105+
}
106+
return parts.join(".") || "(root)";
107+
}
108+
109+
function groupIssues(issues: Issue[]): GroupedIssue[] {
110+
const map = new Map<string, GroupedIssue>();
111+
for (const issue of issues) {
112+
const code = String(issue.code);
113+
const existing = map.get(code);
114+
if (existing) {
115+
existing.occurrences.push(issue);
116+
// Keep the most-severe representative for the group header (lower = worse).
117+
if (issue.severity < existing.severity) {
118+
existing.severity = issue.severity;
119+
existing.message = issue.message;
120+
}
121+
} else {
122+
map.set(code, {
123+
code,
124+
severity: issue.severity,
125+
message: issue.message,
126+
occurrences: [issue],
127+
});
128+
}
129+
}
130+
return Array.from(map.values()).sort((a, b) => {
131+
if (a.severity !== b.severity) return a.severity - b.severity;
132+
if (a.occurrences.length !== b.occurrences.length) {
133+
return b.occurrences.length - a.occurrences.length;
134+
}
135+
return a.message.localeCompare(b.message);
136+
});
137+
}
138+
72139
const DetailedScoreSection = ({
73140
title,
74141
score,
@@ -84,16 +151,36 @@ const DetailedScoreSection = ({
84151
openapi: string;
85152
fileExtension: "json" | "yaml";
86153
}) => {
87-
const [page, setPage] = useState(0);
88154
const scoreTextColor = getScoreTextColor(score);
89155
const titleSlug = title.toLowerCase().replace(" ", "-");
156+
157+
const groups = useMemo(() => groupIssues(issues), [issues]);
158+
const groupCount = groups.length;
90159
const issueCount = issues.length;
160+
161+
const [page, setPage] = useState(0);
91162
const totalPages = Math.max(
92163
0,
93-
Math.ceil((issueCount - INITIAL_LENGTH) / PAGE_LENGTH),
164+
Math.ceil((groupCount - INITIAL_GROUPS) / PAGE_GROUPS),
94165
);
95-
const [issueToView, setIssueToView] = useState<Issue | undefined>();
166+
const visibleCount = page
167+
? PAGE_GROUPS * page + INITIAL_GROUPS
168+
: INITIAL_GROUPS;
169+
const visibleGroups = groups.slice(0, visibleCount);
96170

171+
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
172+
const isExpanded = (code: string, occurrenceCount: number) => {
173+
if (code in expanded) return expanded[code];
174+
return occurrenceCount <= AUTO_EXPAND_THRESHOLD;
175+
};
176+
const toggle = (code: string, occurrenceCount: number) => {
177+
setExpanded((prev) => ({
178+
...prev,
179+
[code]: !isExpanded(code, occurrenceCount),
180+
}));
181+
};
182+
183+
const [issueToView, setIssueToView] = useState<Issue | undefined>();
97184
const handleViewClick = (issue: Issue) => {
98185
setIssueToView(issue);
99186
showModal();
@@ -111,11 +198,6 @@ const DetailedScoreSection = ({
111198
);
112199
}, [issueToView]);
113200

114-
const visibleIssues = issues.slice(
115-
0,
116-
page ? PAGE_LENGTH * page + INITIAL_LENGTH : INITIAL_LENGTH,
117-
);
118-
119201
return (
120202
<section className="card mb-6 overflow-hidden p-0">
121203
<header className="border-border flex flex-col items-start justify-between gap-3 border-b p-5 md:flex-row md:items-center md:p-6">
@@ -164,37 +246,66 @@ const DetailedScoreSection = ({
164246
<th className="text-fg-faint px-5 py-2.5 text-[11px] font-semibold tracking-[0.05em] uppercase">
165247
Issue
166248
</th>
249+
<th className="text-fg-faint w-[100px] px-5 py-2.5 text-right text-[11px] font-semibold tracking-[0.05em] uppercase">
250+
Count
251+
</th>
167252
<th className="text-fg-faint w-[60px] px-5 py-2.5 text-[11px] font-semibold tracking-[0.05em] uppercase" />
168253
</tr>
169254
</thead>
170255
<tbody>
171-
{visibleIssues.map((issue, index: number) => {
172-
const onActivate = () => handleViewClick(issue);
173-
return (
256+
{visibleGroups.map((group, groupIndex) => {
257+
const expandedNow = isExpanded(
258+
group.code,
259+
group.occurrences.length,
260+
);
261+
const single = group.occurrences.length === 1;
262+
const Caret = expandedNow ? CaretDown : CaretRight;
263+
const handleHeaderActivate = () => {
264+
if (single) {
265+
handleViewClick(group.occurrences[0]);
266+
} else {
267+
toggle(group.code, group.occurrences.length);
268+
}
269+
};
270+
return [
174271
<tr
175-
key={`${titleSlug}-table-row-${index}`}
272+
key={`${titleSlug}-group-${groupIndex}`}
176273
role="button"
177274
tabIndex={0}
178-
aria-label={`View issue: ${issue.message}`}
179-
onClick={onActivate}
275+
aria-expanded={single ? undefined : expandedNow}
276+
aria-label={
277+
single
278+
? `View issue: ${group.message}`
279+
: `${expandedNow ? "Collapse" : "Expand"} ${
280+
group.occurrences.length
281+
} occurrences of ${group.message}`
282+
}
283+
onClick={handleHeaderActivate}
180284
onKeyDown={(event) => {
181285
if (event.key === "Enter" || event.key === " ") {
182286
event.preventDefault();
183-
onActivate();
287+
handleHeaderActivate();
184288
}
185289
}}
186-
className="border-bg-muted text-fg-secondary hover:bg-bg-subtle focus-visible:bg-bg-subtle focus-visible:outline-accent cursor-pointer border-b transition-colors last:border-b-0 focus-visible:outline-2 focus-visible:-outline-offset-2"
290+
className="border-bg-muted text-fg-secondary hover:bg-bg-subtle focus-visible:bg-bg-subtle focus-visible:outline-accent cursor-pointer border-b transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2"
187291
>
188292
<td className="px-5 py-3 align-top">
189-
<SeverityTag severity={issue.severity} />
293+
<SeverityTag severity={group.severity} />
190294
</td>
191295
<td className="px-5 py-3 align-top">
192296
<span className="text-fg block break-words">
193-
{issue.message}
297+
{group.message}
194298
</span>
195-
{typeof issue.code !== "undefined" && (
196-
<span className="text-fg-muted mt-1 block font-mono text-xs">
197-
{issue.code}
299+
<span className="text-fg-muted mt-1 block font-mono text-xs">
300+
{group.code}
301+
</span>
302+
</td>
303+
<td className="px-5 py-3 text-right align-top">
304+
{single ? (
305+
<span className="text-fg-faint text-xs"></span>
306+
) : (
307+
<span className="badge-numeric badge-neutral">
308+
× {group.occurrences.length}
198309
</span>
199310
)}
200311
</td>
@@ -203,35 +314,84 @@ const DetailedScoreSection = ({
203314
aria-hidden="true"
204315
className="btn btn-ghost btn-icon"
205316
>
206-
<FileMagnifyingGlass size={16} weight="regular" />
317+
{single ? (
318+
<FileMagnifyingGlass size={16} weight="regular" />
319+
) : (
320+
<Caret size={16} weight="regular" />
321+
)}
207322
</span>
208323
</td>
209-
</tr>
210-
);
324+
</tr>,
325+
!single &&
326+
expandedNow &&
327+
group.occurrences.map((occ, occIndex) => {
328+
const line = occ.range.start.line + 1;
329+
const where = describeOccurrence(occ);
330+
const onActivate = () => handleViewClick(occ);
331+
return (
332+
<tr
333+
key={`${titleSlug}-group-${groupIndex}-occ-${occIndex}`}
334+
role="button"
335+
tabIndex={0}
336+
aria-label={`View occurrence at ${where}, line ${line}`}
337+
onClick={onActivate}
338+
onKeyDown={(event) => {
339+
if (event.key === "Enter" || event.key === " ") {
340+
event.preventDefault();
341+
onActivate();
342+
}
343+
}}
344+
className="border-bg-muted text-fg-muted hover:bg-bg-subtle focus-visible:bg-bg-subtle focus-visible:outline-accent bg-bg-subtle/40 cursor-pointer border-b transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2"
345+
>
346+
<td className="px-5 py-2 align-top" />
347+
<td className="px-5 py-2 align-top">
348+
<div className="flex flex-col gap-1 pl-4">
349+
<span className="text-fg-secondary font-mono text-xs">
350+
{where}
351+
</span>
352+
</div>
353+
</td>
354+
<td className="px-5 py-2 text-right align-top">
355+
<span className="badge-numeric badge-neutral">
356+
L{line}
357+
</span>
358+
</td>
359+
<td className="px-3 py-2 text-right align-top">
360+
<span
361+
aria-hidden="true"
362+
className="btn btn-ghost btn-icon"
363+
>
364+
<FileMagnifyingGlass size={14} weight="regular" />
365+
</span>
366+
</td>
367+
</tr>
368+
);
369+
}),
370+
];
211371
})}
212372
</tbody>
213373
</table>
214374
</div>
215375
)}
216376

217-
{issueCount > INITIAL_LENGTH && (
377+
{groupCount > INITIAL_GROUPS && (
218378
<div className="border-border bg-bg-subtle flex flex-col items-start gap-2 border-t px-5 py-4 md:flex-row md:items-center md:justify-between">
219379
<span className="text-fg-muted text-xs">
220380
Showing{" "}
221381
<span className="text-fg font-semibold">
222-
{visibleIssues.length}
382+
{visibleGroups.length}
223383
</span>{" "}
224-
of <span className="text-fg font-semibold">{issueCount}</span>{" "}
225-
issues
384+
of <span className="text-fg font-semibold">{groupCount}</span>{" "}
385+
unique issues ({issueCount} total occurrences)
226386
</span>
227387
<div className="flex flex-wrap gap-2">
228-
{page < totalPages && issueCount > PAGE_LENGTH + INITIAL_LENGTH && (
388+
{page < totalPages && groupCount > PAGE_GROUPS + INITIAL_GROUPS && (
229389
<button
230390
type="button"
231391
onClick={() => setPage(page + 1)}
232392
className="btn btn-ghost btn-sm"
233393
>
234-
Show {PAGE_LENGTH} more
394+
Show {PAGE_GROUPS} more
235395
</button>
236396
)}
237397
{page < totalPages && (
@@ -240,7 +400,7 @@ const DetailedScoreSection = ({
240400
onClick={() => setPage(totalPages)}
241401
className="btn btn-outlined btn-sm"
242402
>
243-
Show all {issueCount}
403+
Show all {groupCount}
244404
</button>
245405
)}
246406
{page >= totalPages && (

0 commit comments

Comments
 (0)