feat(search): surface compliance mappings in discovery

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-08-07 16:37:07 +08:00
parent 460304eed8
commit 00f55c2db3
17 changed files with 247 additions and 34 deletions

View file

@ -20,5 +20,6 @@ public record SkillSummaryResponse(
SkillLifecycleVersionResponse headlineVersion,
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode
String resolutionMode,
ComplianceSnapshotResponse complianceSnapshot
) {}

View file

@ -79,7 +79,8 @@ public class JpaMySkillQueryRepository implements MySkillQueryRepository {
toLifecycleVersion(headlineVersion),
toLifecycleVersion(publishedVersion),
toLifecycleVersion(ownerPreviewVersion),
projection.resolutionMode().name()
projection.resolutionMode().name(),
null
);
}

View file

@ -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<Long, String> 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
);
}

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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

View file

@ -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()
);
}
}

View file

@ -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;

View file

@ -173,6 +173,7 @@ export interface SkillSummary {
publishedVersion?: SkillLifecycleVersion
ownerPreviewVersion?: SkillLifecycleVersion
resolutionMode?: string
complianceSnapshot?: ComplianceSnapshot
}
export type LabelItem = Omit<components['schemas']['SkillLabelDto'], 'slug' | 'type' | 'displayName'> & {

View file

@ -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<number>(),
}),
}))
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')
})
})

View file

@ -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 (
<Card
@ -60,6 +61,26 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
</p>
)}
{complianceItems.length > 0 ? (
<div className="mb-4 flex flex-wrap gap-1.5">
{complianceItems.slice(0, 2).map((item, index) => (
<span
key={`${item.standard ?? 'standard'}-${item.controlId ?? index}`}
className="inline-flex items-center gap-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300"
title={item.title}
>
<ShieldCheck className="h-3 w-3" />
{[item.standard, item.controlId].filter(Boolean).join(' · ')}
</span>
))}
{complianceItems.length > 2 ? (
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{complianceItems.length - 2}
</span>
) : null}
</div>
) : null}
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
{headlineVersion && (
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">

View file

@ -199,7 +199,8 @@
"enterKeyword": "Please enter a search keyword",
"results": "{{count}} skills found",
"resultCount": "Found <1>{{count}}</1> results",
"loadingMore": "Updating search results..."
"loadingMore": "Updating search results...",
"complianceSuggestions": "Compliance:"
},
"searchBar": {
"placeholder": "Search skills...",

View file

@ -199,7 +199,8 @@
"enterKeyword": "请输入搜索关键词",
"results": "找到 {{count}} 个技能",
"resultCount": "找到 <1>{{count}}</1> 个结果",
"loadingMore": "正在更新搜索结果..."
"loadingMore": "正在更新搜索结果...",
"complianceSuggestions": "合规检索:"
},
"searchBar": {
"placeholder": "搜索技能...",

View file

@ -200,6 +200,25 @@ describe('SearchPage', () => {
})
})
it('offers compliance search suggestions that update the query', () => {
renderToStaticMarkup(<SearchPage />)
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(<SearchPage />)

View file

@ -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() {
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="shrink-0 text-sm font-medium text-muted-foreground">{t('search.complianceSuggestions')}</span>
{COMPLIANCE_SEARCH_SUGGESTIONS.map((suggestion) => (
<Button
key={suggestion}
variant={q.toLowerCase() === suggestion.toLowerCase() ? 'default' : 'outline'}
size="sm"
onClick={() => handleComplianceSuggestion(suggestion)}
>
{suggestion}
</Button>
))}
</div>
</div>
{/* Results */}