From 00f55c2db3f20c41a86b5e4c6dbb28ac81cb841c Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:37:07 +0800 Subject: [PATCH] feat(search): surface compliance mappings in discovery Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skillhub/dto/SkillSummaryResponse.java | 3 +- .../repository/JpaMySkillQueryRepository.java | 3 +- .../service/SkillSearchAppService.java | 35 ++++++++++- .../compat/ClawHubCompatControllerTest.java | 3 +- .../compat/ClawHubRegistryFacadeTest.java | 3 +- .../skillhub/controller/MeControllerTest.java | 3 +- .../service/SkillSearchAppServiceTest.java | 43 +++++++++++++ .../service/cli/CliSkillAppServiceTest.java | 4 +- .../SkillLifecycleProjectionService.java | 16 ++++- web/src/api/generated/schema.d.ts | 37 +++++------ web/src/api/types.ts | 1 + web/src/features/skill/skill-card.test.ts | 61 ++++++++++++++++++- web/src/features/skill/skill-card.tsx | 23 ++++++- web/src/i18n/locales/en.json | 3 +- web/src/i18n/locales/zh.json | 3 +- web/src/pages/search.test.tsx | 19 ++++++ web/src/pages/search.tsx | 21 +++++++ 17 files changed, 247 insertions(+), 34 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 0948756e..8011a12c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -20,5 +20,6 @@ public record SkillSummaryResponse( SkillLifecycleVersionResponse headlineVersion, SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, - String resolutionMode + String resolutionMode, + ComplianceSnapshotResponse complianceSnapshot ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java index d9c97041..83c33b4e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaMySkillQueryRepository.java @@ -79,7 +79,8 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository { toLifecycleVersion(headlineVersion), toLifecycleVersion(publishedVersion), toLifecycleVersion(ownerPreviewVersion), - projection.resolutionMode().name() + projection.resolutionMode().name(), + null ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 63410678..16e63ec7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -18,6 +18,7 @@ import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** @@ -36,6 +37,7 @@ public class SkillSearchAppService { private final NamespaceRepository namespaceRepository; private final NamespaceService namespaceService; private final SkillLifecycleProjectionService skillLifecycleProjectionService; + private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService; private final RbacService rbacService; public SkillSearchAppService( @@ -45,11 +47,32 @@ public class SkillSearchAppService { NamespaceService namespaceService, SkillLifecycleProjectionService skillLifecycleProjectionService, RbacService rbacService) { + this( + searchQueryService, + skillRepository, + namespaceRepository, + namespaceService, + skillLifecycleProjectionService, + new ComplianceSnapshotProjectionService(new com.fasterxml.jackson.databind.ObjectMapper()), + rbacService + ); + } + + @Autowired + public SkillSearchAppService( + SearchQueryService searchQueryService, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + NamespaceService namespaceService, + SkillLifecycleProjectionService skillLifecycleProjectionService, + ComplianceSnapshotProjectionService complianceSnapshotProjectionService, + RbacService rbacService) { this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.namespaceService = namespaceService; this.skillLifecycleProjectionService = skillLifecycleProjectionService; + this.complianceSnapshotProjectionService = complianceSnapshotProjectionService; this.rbacService = rbacService; } @@ -198,7 +221,11 @@ public class SkillSearchAppService { return skillIds.stream() .map(skillsById::get) .filter(java.util.Objects::nonNull) - .map(skill -> toSummaryResponse(skill, namespaceSlugsById, projectionsBySkillId.get(skill.getId()))) + .map(skill -> toSummaryResponse( + skill, + namespaceSlugsById, + projectionsBySkillId.get(skill.getId()) + )) .toList(); } @@ -207,6 +234,7 @@ public class SkillSearchAppService { Map namespaceSlugsById, SkillLifecycleProjectionService.Projection projection) { String namespaceSlug = namespaceSlugsById.get(skill.getNamespaceId()); + SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion(); return new SkillSummaryResponse( skill.getId(), @@ -225,7 +253,10 @@ public class SkillSearchAppService { toLifecycleVersion(projection.headlineVersion()), toLifecycleVersion(projection.publishedVersion()), toLifecycleVersion(projection.ownerPreviewVersion()), - projection.resolutionMode().name() + projection.resolutionMode().name(), + headlineVersion != null + ? complianceSnapshotProjectionService.fromParsedMetadataJson(headlineVersion.parsedMetadataJson()) + : null ); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index cb4c0fc8..b07914a4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -114,7 +114,8 @@ class ClawHubCompatControllerTest { new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"), new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"), null, - "PUBLISHED")), + "PUBLISHED", + null)), 1, 0, 20 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java index 27d825dc..5485c8d9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java @@ -55,7 +55,8 @@ class ClawHubRegistryFacadeTest { new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), null, - "PUBLISHED" + "PUBLISHED", + null )), 1, 0, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index e4e7b80b..a2ee75fe 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -75,7 +75,8 @@ class MeControllerTest { new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), null, - "PUBLISHED" + "PUBLISHED", + null )), 9, 1, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 3cd408e4..8fb38b47 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -187,6 +187,49 @@ class SkillSearchAppServiceTest { .findBySkillIdInAndStatus(List.of(10L, 11L), com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED); } + @Test + void search_shouldProjectComplianceSnapshotFromHeadlineVersion() { + Skill skill = new Skill(1L, "compliance-skill", "owner-1", SkillVisibility.PUBLIC); + setField(skill, "id", 10L); + skill.setLatestVersionId(101L); + + SkillVersion version = publishedVersion(10L, 101L, "1.0.0"); + version.setParsedMetadataJson(""" + { + "complianceSnapshot": { + "schemaVersion": "1.0", + "items": [ + { + "standard": "mitre-attack", + "version": "v19.1", + "controlId": "T1059", + "title": "Command and Scripting Interpreter", + "evidence": [] + } + ], + "digest": "sha256:demo" + } + } + """); + + Namespace namespace = new Namespace("global", "Global", "owner-1"); + setField(namespace, "id", 1L); + namespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace)); + when(skillVersionRepository.findByIdIn(List.of(101L))).thenReturn(List.of(version)); + + SkillSearchAppService.SearchResponse response = service.search("T1059", null, "relevance", 0, 20, null, null); + + assertEquals(1, response.items().size()); + assertEquals("mitre-attack", response.items().getFirst().complianceSnapshot().items().getFirst().standard()); + assertEquals("T1059", response.items().getFirst().complianceSnapshot().items().getFirst().controlId()); + assertEquals("sha256:demo", response.items().getFirst().complianceSnapshot().digest()); + } + @Test void search_shouldNotFallbackToOlderPublishedVersionWhenLatestIsMissing() { Skill skill = new Skill(1L, "missing-latest", "owner-1", SkillVisibility.PUBLIC); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index 0c75ca34..8e4ae51e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -75,7 +75,7 @@ class CliSkillAppServiceTest { "global", Instant.now(), false, new SkillLifecycleVersionResponse(1L, "1.2.0", "PUBLISHED"), new SkillLifecycleVersionResponse(1L, "1.2.0", "PUBLISHED"), - null, "PUBLISHED" + null, "PUBLISHED", null )), 1L, 0, 20 ); @@ -103,7 +103,7 @@ class CliSkillAppServiceTest { "global", Instant.now(), false, new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), new SkillLifecycleVersionResponse(2L, "1.0.0", "PUBLISHED"), - null, "PUBLISHED" + null, "PUBLISHED", null ) ), 1L, 0, 20 diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 31ff9a6b..4bb3bfa5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -30,8 +30,13 @@ public class SkillLifecycleProjectionService { public record VersionProjection( Long id, String version, - String status - ) {} + String status, + String parsedMetadataJson + ) { + public VersionProjection(Long id, String version, String status) { + this(id, version, status, null); + } + } public record Projection( VersionProjection headlineVersion, @@ -153,6 +158,11 @@ public class SkillLifecycleProjectionService { if (version == null) { return null; } - return new VersionProjection(version.getId(), version.getVersion(), version.getStatus().name()); + return new VersionProjection( + version.getId(), + version.getVersion(), + version.getStatus().name(), + version.getParsedMetadataJson() + ); } } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 07dea7da..62216527 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4058,6 +4058,24 @@ export interface components { timestamp?: string; requestId?: string; }; + ComplianceEvidenceResponse: { + type?: string; + path?: string; + url?: string; + sha256?: string; + }; + ComplianceMappingResponse: { + standard?: string; + version?: string; + controlId?: string; + title?: string; + evidence?: components["schemas"]["ComplianceEvidenceResponse"][]; + }; + ComplianceSnapshotResponse: { + schemaVersion?: string; + items?: components["schemas"]["ComplianceMappingResponse"][]; + digest?: string; + }; SearchResponse: { items?: components["schemas"]["SkillSummaryResponse"][]; /** Format: int64 */ @@ -4096,6 +4114,7 @@ export interface components { publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; resolutionMode?: string; + complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"]; }; ApiResponseBoolean: { /** Format: int32 */ @@ -4147,24 +4166,6 @@ export interface components { timestamp?: string; requestId?: string; }; - ComplianceEvidenceResponse: { - type?: string; - path?: string; - url?: string; - sha256?: string; - }; - ComplianceMappingResponse: { - standard?: string; - version?: string; - controlId?: string; - title?: string; - evidence?: components["schemas"]["ComplianceEvidenceResponse"][]; - }; - ComplianceSnapshotResponse: { - schemaVersion?: string; - items?: components["schemas"]["ComplianceMappingResponse"][]; - digest?: string; - }; SkillVersionDetailResponse: { /** Format: int64 */ id?: number; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c09d4b62..41867244 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -173,6 +173,7 @@ export interface SkillSummary { publishedVersion?: SkillLifecycleVersion ownerPreviewVersion?: SkillLifecycleVersion resolutionMode?: string + complianceSnapshot?: ComplianceSnapshot } export type LabelItem = Omit & { diff --git a/web/src/features/skill/skill-card.test.ts b/web/src/features/skill/skill-card.test.ts index d94d42cf..2e7b9cdc 100644 --- a/web/src/features/skill/skill-card.test.ts +++ b/web/src/features/skill/skill-card.test.ts @@ -1,5 +1,30 @@ -import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { createElement, type ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' import * as mod from './skill-card' +import { SkillCard } from './skill-card' + +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: () => ({ + isAuthenticated: false, + }), +})) + +vi.mock('@/features/social/use-star', () => ({ + useStarredIdSet: () => ({ + starredIds: new Set(), + }), +})) + +vi.mock('@/shared/ui/card', () => ({ + Card: ({ children, className }: { children?: ReactNode; className?: string }) => ( + createElement('div', { className }, children) + ), +})) + +vi.mock('@/shared/components/namespace-badge', () => ({ + NamespaceBadge: ({ name }: { name: string }) => createElement('span', null, name), +})) /** * skill-card.tsx exports a single React component (SkillCard). @@ -14,4 +39,38 @@ describe('skill-card module exports', () => { expect(mod.SkillCard).toBeDefined() expect(typeof mod.SkillCard).toBe('function') }) + + it('renders compliance badges from the skill summary snapshot', () => { + const html = renderToStaticMarkup( + createElement(SkillCard, { + skill: { + id: 1, + slug: 'audit-runner', + displayName: 'Audit Runner', + summary: 'Runs controls', + downloadCount: 10, + starCount: 2, + ratingCount: 0, + namespace: 'global', + updatedAt: '2026-08-07T00:00:00Z', + canSubmitPromotion: false, + headlineVersion: { id: 11, version: '1.0.0', status: 'PUBLISHED' }, + complianceSnapshot: { + schemaVersion: '1.0', + digest: 'sha256:demo', + items: [ + { standard: 'mitre-attack', controlId: 'T1059', title: 'Command and Scripting Interpreter' }, + { standard: 'nist-csf', controlId: 'PR.AA-01' }, + { standard: 'soc2', controlId: 'CC6.1' }, + ], + }, + }, + }) + ) + + expect(html).toContain('mitre-attack') + expect(html).toContain('T1059') + expect(html).toContain('nist-csf') + expect(html).toContain('+1') + }) }) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index 51dae46f..cf6d7214 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -5,7 +5,7 @@ import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' -import { Bookmark } from 'lucide-react' +import { Bookmark, ShieldCheck } from 'lucide-react' interface SkillCardProps { skill: SkillSummary @@ -23,6 +23,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard const showStarredHighlight = highlightStarred && isAuthenticated && starredIds.has(skill.id) const headlineVersion = getHeadlineVersion(skill) const isInteractive = typeof onClick === 'function' + const complianceItems = skill.complianceSnapshot?.items?.filter((item) => item.standard || item.controlId) ?? [] return ( )} + {complianceItems.length > 0 ? ( +
+ {complianceItems.slice(0, 2).map((item, index) => ( + + + {[item.standard, item.controlId].filter(Boolean).join(' · ')} + + ))} + {complianceItems.length > 2 ? ( + + +{complianceItems.length - 2} + + ) : null} +
+ ) : null} +
{headlineVersion && ( diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 59b128d2..06868fe6 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -199,7 +199,8 @@ "enterKeyword": "Please enter a search keyword", "results": "{{count}} skills found", "resultCount": "Found <1>{{count}} results", - "loadingMore": "Updating search results..." + "loadingMore": "Updating search results...", + "complianceSuggestions": "Compliance:" }, "searchBar": { "placeholder": "Search skills...", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b4201a1d..348b9dcd 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -199,7 +199,8 @@ "enterKeyword": "请输入搜索关键词", "results": "找到 {{count}} 个技能", "resultCount": "找到 <1>{{count}} 个结果", - "loadingMore": "正在更新搜索结果..." + "loadingMore": "正在更新搜索结果...", + "complianceSuggestions": "合规检索:" }, "searchBar": { "placeholder": "搜索技能...", diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index 4db0210c..3c83bef8 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -200,6 +200,25 @@ describe('SearchPage', () => { }) }) + it('offers compliance search suggestions that update the query', () => { + renderToStaticMarkup() + + findButton('MITRE T1059').onClick?.() + + expect(navigateMock).toHaveBeenCalledWith({ + to: '/search', + search: { + q: 'MITRE T1059', + namespace: 'team-ai', + label: 'code-generation', + sort: 'downloads', + page: 0, + starredOnly: false, + }, + replace: true, + }) + }) + it('preserves the active label when paging and when toggling starred-only', () => { renderToStaticMarkup() diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index a9426b55..dbfec8ce 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -18,6 +18,7 @@ import { Button } from '@/shared/ui/button' import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' const PAGE_SIZE = 12 +const COMPLIANCE_SEARCH_SUGGESTIONS = ['MITRE T1059', 'NIST CSF', 'SOC2', 'GDPR'] function blurActiveElement() { if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') { @@ -165,6 +166,13 @@ export function SearchPage() { }) } + const handleComplianceSuggestion = (query: string) => { + setQueryInput(formatNamespaceSearchInput(namespace, query)) + startTransition(() => { + navigate({ to: '/search', search: { q: query, namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true }) + }) + } + const handleSortChange = (newSort: string) => { navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } }) } @@ -305,6 +313,19 @@ export function SearchPage() { ) : null}
+
+ {t('search.complianceSuggestions')} + {COMPLIANCE_SEARCH_SUGGESTIONS.map((suggestion) => ( + + ))} +
{/* Results */}