From bce12f527775b4293d59afa4ddcbce498083b773 Mon Sep 17 00:00:00 2001
From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:37:40 +0200
Subject: [PATCH] feat(studio): group duplicate validity diagnostics
---
.../adapters/validitySuggestions.test.ts | 99 +++++++++++++++++++
.../__tests__/tabs/ValidityTab.test.tsx | 27 +++++
src/studio/adapters/validitySuggestions.ts | 54 +++++++++-
src/studio/tabs/ValidityTab.tsx | 8 ++
4 files changed, 186 insertions(+), 2 deletions(-)
diff --git a/src/studio/__tests__/adapters/validitySuggestions.test.ts b/src/studio/__tests__/adapters/validitySuggestions.test.ts
index 23fa8c4c..d72b8902 100644
--- a/src/studio/__tests__/adapters/validitySuggestions.test.ts
+++ b/src/studio/__tests__/adapters/validitySuggestions.test.ts
@@ -262,6 +262,105 @@ describe('buildValiditySuggestions', () => {
]);
});
+ it('groups duplicate diagnostics with the same code and target into one suggestion', () => {
+ const suggestions = buildValiditySuggestions({
+ validity: makeValidity('error', [
+ {
+ code: 'assembly.part.floating',
+ severity: 'warning',
+ message: 'link floats',
+ hint: 'anchor link',
+ partName: 'link',
+ },
+ {
+ code: 'assembly.part.floating',
+ severity: 'error',
+ message: 'link still has no grounded mate',
+ hint: 'add a grounded mate to link',
+ partName: 'link',
+ },
+ ]),
+ mechanismBanner: null,
+ });
+
+ expect(suggestions).toHaveLength(1);
+ expect(suggestions[0]).toMatchObject({
+ id: 'diagnostic:assembly.part.floating:link:0',
+ severity: 'error',
+ targetLabel: 'link',
+ targetId: 'link',
+ diagnosticCount: 2,
+ evidence: 'link floats',
+ action: 'anchor link',
+ });
+ });
+
+ it('keeps diagnostics with the same code but different targets as separate suggestions', () => {
+ const suggestions = buildValiditySuggestions({
+ validity: makeValidity('error', [
+ {
+ code: 'assembly.part.floating',
+ severity: 'error',
+ message: 'link floats',
+ hint: 'anchor link',
+ partName: 'link',
+ },
+ {
+ code: 'assembly.part.floating',
+ severity: 'error',
+ message: 'cover floats',
+ hint: 'anchor cover',
+ partName: 'cover',
+ },
+ ]),
+ mechanismBanner: null,
+ });
+
+ expect(suggestions.map((suggestion) => suggestion.targetId)).toEqual(['link', 'cover']);
+ expect(suggestions.map((suggestion) => suggestion.diagnosticCount)).toEqual([1, 1]);
+ });
+
+ it('applies the visible card limit after grouping duplicate diagnostics', () => {
+ const suggestions = buildValiditySuggestions({
+ validity: makeValidity('error', [
+ {
+ code: 'assembly.part.floating',
+ severity: 'error',
+ message: 'link floats',
+ hint: 'anchor link',
+ partName: 'link',
+ },
+ {
+ code: 'assembly.part.floating',
+ severity: 'error',
+ message: 'link still floats',
+ hint: 'anchor link again',
+ partName: 'link',
+ },
+ {
+ code: 'assembly.part.floating',
+ severity: 'error',
+ message: 'cover floats',
+ hint: 'anchor cover',
+ partName: 'cover',
+ },
+ {
+ code: 'assembly.loop.unclosed',
+ severity: 'warning',
+ message: 'loop is open',
+ hint: 'close loop',
+ },
+ ]),
+ mechanismBanner: null,
+ limit: 2,
+ });
+
+ expect(suggestions.map((suggestion) => suggestion.id)).toEqual([
+ 'diagnostic:assembly.part.floating:link:0',
+ 'diagnostic:assembly.part.floating:cover:1',
+ ]);
+ });
+
it('maps target precedence for partName, mateName, partA, and no target', () => {
const suggestions = buildValiditySuggestions({
validity: makeValidity('error', [
diff --git a/src/studio/__tests__/tabs/ValidityTab.test.tsx b/src/studio/__tests__/tabs/ValidityTab.test.tsx
index 1b8c5d33..e13b1b57 100644
--- a/src/studio/__tests__/tabs/ValidityTab.test.tsx
+++ b/src/studio/__tests__/tabs/ValidityTab.test.tsx
@@ -203,6 +203,33 @@ describe('ValidityTab', () => {
expect(card.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
+ it('groups duplicate diagnostic suggestions while keeping every raw diagnostic row', () => {
+ const first: ValidatorDiagnostic = {
+ code: 'assembly.part.floating',
+ severity: 'error',
+ message: 'output-horn floats',
+ hint: 'add a mate to output-horn',
+ partName: 'output-horn',
+ };
+ const duplicate: ValidatorDiagnostic = {
+ code: 'assembly.part.floating',
+ severity: 'warning',
+ message: 'output-horn still has no grounded mate',
+ hint: 'ground output-horn',
+ partName: 'output-horn',
+ };
+
+ mockUseRecomputeResult.mockReturnValue(
+ withValidity(makeValidity('error', [first, duplicate], 1, 0)),
+ );
+
+ render();
+
+ expect(screen.getAllByTestId('validity-suggestion-card')).toHaveLength(1);
+ expect(screen.getByTestId('validity-suggestion-count').textContent).toBe('2 findings');
+ expect(screen.getAllByTestId('diagnostic-row')).toHaveLength(2);
+ });
+
it('clicking a suggestion card Jump button calls selectFeature with the target id', () => {
const diag: ValidatorDiagnostic = {
code: 'assembly.part.floating',
diff --git a/src/studio/adapters/validitySuggestions.ts b/src/studio/adapters/validitySuggestions.ts
index 18466712..5ae08c40 100644
--- a/src/studio/adapters/validitySuggestions.ts
+++ b/src/studio/adapters/validitySuggestions.ts
@@ -13,6 +13,7 @@ export interface ValiditySuggestionCard {
readonly targetLabel: string | null;
readonly targetId: string | null;
readonly targetIds: readonly string[];
+ readonly diagnosticCount: number;
readonly code: string;
readonly promptText: string;
readonly promptSource: 'review' | 'fallback';
@@ -50,6 +51,7 @@ export function buildValiditySuggestions(input: {
targetLabel: null,
targetId: null,
targetIds: [],
+ diagnosticCount: 1,
code: entry.code,
promptText: backendPrompt ?? fallbackPrompt(entry.code, evidence, action),
promptSource,
@@ -57,7 +59,8 @@ export function buildValiditySuggestions(input: {
};
}) ?? [];
const diagnosticCards =
- input.validity?.diagnostics.map((diag, index): ValiditySuggestionCard => {
+ groupDiagnostics(input.validity?.diagnostics ?? []).map((group, index): ValiditySuggestionCard => {
+ const diag = group.diagnostics[0]!;
const targetLabel = diagnosticTargetLabel(diag);
const targetId = diagnosticTargetId(diag);
const targetIds = diagnosticTargetIds(diag);
@@ -66,13 +69,14 @@ export function buildValiditySuggestions(input: {
return {
id: `diagnostic:${diag.code}:${targetId ?? 'global'}:${index}`,
kind: 'diagnostic',
- severity: diag.severity,
+ severity: group.severity,
title: targetLabel != null ? `Fix ${targetLabel}` : `Resolve ${diag.code}`,
evidence,
action,
targetLabel,
targetId,
targetIds,
+ diagnosticCount: group.diagnostics.length,
code: diag.code,
promptText: backendPrompt ?? fallbackPrompt(diag.code, evidence, action),
promptSource,
@@ -83,6 +87,52 @@ export function buildValiditySuggestions(input: {
return [...mechanismCards, ...diagnosticCards].slice(0, limit);
}
+function groupDiagnostics(
+ diagnostics: ReadonlyArray,
+): Array<{ diagnostics: ValidatorDiagnostic[]; severity: ValidatorDiagnostic['severity'] }> {
+ const groups = new Map();
+ for (const diagnostic of diagnostics) {
+ const key = diagnosticGroupKey(diagnostic);
+ const group = groups.get(key);
+ if (group == null) {
+ groups.set(key, [diagnostic]);
+ } else {
+ group.push(diagnostic);
+ }
+ }
+ return [...groups.values()].map((group) => ({
+ diagnostics: group,
+ severity: highestSeverity(group),
+ }));
+}
+
+function diagnosticGroupKey(d: ValidatorDiagnostic): string {
+ const targetId = diagnosticTargetId(d) ?? 'global';
+ const targetIds = diagnosticTargetIds(d).join('|');
+ return `${d.code}:${targetId}:${targetIds}`;
+}
+
+function highestSeverity(diagnostics: ReadonlyArray): ValidatorDiagnostic['severity'] {
+ return diagnostics.reduce(
+ (highest, diagnostic) =>
+ severityRank(diagnostic.severity) > severityRank(highest)
+ ? diagnostic.severity
+ : highest,
+ 'info',
+ );
+}
+
+function severityRank(severity: ValidatorDiagnostic['severity']): number {
+ switch (severity) {
+ case 'error':
+ return 3;
+ case 'warning':
+ return 2;
+ case 'info':
+ return 1;
+ }
+}
+
function diagnosticTargetLabel(d: ValidatorDiagnostic): string | null {
if (d.partName) return d.partName;
if (d.mateName) return d.mateName;
diff --git a/src/studio/tabs/ValidityTab.tsx b/src/studio/tabs/ValidityTab.tsx
index b5c8c0d1..f38b8a69 100644
--- a/src/studio/tabs/ValidityTab.tsx
+++ b/src/studio/tabs/ValidityTab.tsx
@@ -181,6 +181,14 @@ function SuggestionCard({
/>
{card.title}
{card.code}
+ {card.diagnosticCount > 1 && (
+
+ {card.diagnosticCount} findings
+
+ )}
{card.targetLabel != null && (
{card.targetLabel}