diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index c247e748..d98ca793 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -63,7 +63,8 @@ x-astron-compliance: # 可选,平台私有合规元数据 ``` > 合规元数据先按 SkillHub/Astron 私有扩展实现,字段名采用 `x-astron-compliance`。 -> 当前第一阶段支持发布校验和版本级 `complianceSnapshot` 固化;详情展示、审核 diff、搜索 facet +> 当前第一阶段支持发布校验和版本级 `complianceSnapshot` 固化;这些信息表示“技能作者声明的合规映射”, +> SkillHub 校验证据引用的格式和可访问性,但不等同于第三方认证或平台背书。详情展示、审核 diff、搜索投影 > 和 Runtime trace 集成按后续阶段推进。设计边界、分阶段实现和 Runtime 职责划分见 > [24-compliance-metadata-design.md](24-compliance-metadata-design.md)。 diff --git a/docs/24-compliance-metadata-design.md b/docs/24-compliance-metadata-design.md index 1d060b9b..a7ea9c05 100644 --- a/docs/24-compliance-metadata-design.md +++ b/docs/24-compliance-metadata-design.md @@ -20,6 +20,10 @@ Issue #556 提出的方向是让 SkillHub 支持“可标准映射、可审计 > SkillHub 负责“这个技能版本声明了什么合规能力”;Agent Runtime 负责“这次执行实际用了哪个技能版本”。两者通过 `skillVersionId + complianceSnapshotDigest` 关联。 +这里的 compliance 是作者随技能包提交的声明型元数据。SkillHub 第一阶段只验证字段结构、取值格式、 +包内证据文件是否存在、外部证据 URL 是否是合法 HTTP(S) URL,并生成不可变快照摘要;它不验证外部标准内容是否真实适用, +也不代表第三方审计、认证通过或平台背书。 + ## 2. 职责边界 ### 2.1 SkillHub 职责 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/compliance-snapshot-panel.test.tsx b/web/src/features/skill/compliance-snapshot-panel.test.tsx index 5070ebb5..9bf957c3 100644 --- a/web/src/features/skill/compliance-snapshot-panel.test.tsx +++ b/web/src/features/skill/compliance-snapshot-panel.test.tsx @@ -1,5 +1,8 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ComplianceSnapshotPanel } from './compliance-snapshot-panel' vi.mock('react-i18next', async () => { @@ -8,14 +11,32 @@ vi.mock('react-i18next', async () => { ...actual, useTranslation: () => ({ t: (key: string, values?: Record) => - key === 'compliance.mappingCount' ? `${values?.count} mappings` : key, + key === 'compliance.mappingCount' + ? `${values?.count} mappings` + : key === 'common.expand' + ? '展开详情' + : key === 'common.collapse' + ? '收起详情' + : key === 'compliance.title' + ? '合规声明' + : key, }), } }) describe('ComplianceSnapshotPanel', () => { - it('renders compliance mappings and evidence', () => { - const html = renderToStaticMarkup( + afterEach(() => { + cleanup() + }) + + it('renders nothing when there are no compliance mappings', () => { + const html = renderToStaticMarkup() + + expect(html).toBe('') + }) + + it('renders a compact summary by default and expands on demand', () => { + render( { title: 'Command and Scripting Interpreter', evidence: [{ type: 'packaged-file', path: 'references/standards.md', sha256: 'abc' }], }, + { + standard: 'nist-csf', + version: '2.0', + controlId: 'PR.DS-01', + title: 'Data-at-rest protection', + evidence: [], + }, + { + standard: 'soc2', + version: '2023', + controlId: 'CC6.1', + title: 'Logical Access Security', + evidence: [], + }, ], }} />, ) - expect(html).toContain('compliance.title') - expect(html).toContain('1 mappings') - expect(html).toContain('mitre-attack') - expect(html).toContain('T1059') - expect(html).toContain('references/standards.md') - }) + const toggle = screen.getByRole('button', { name: '展开详情' }) + expect(toggle).toBeTruthy() + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('mitre-attack · T1059')).toBeTruthy() + expect(screen.getByText('+1')).toBeTruthy() + expect(screen.queryByText('references/standards.md')).toBeNull() - it('renders nothing when there are no compliance mappings', () => { - const html = renderToStaticMarkup() + fireEvent.click(toggle) - expect(html).toBe('') + const expandedToggle = screen.getByRole('button', { name: '收起详情' }) + expect(expandedToggle.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('references/standards.md')).toBeTruthy() + expect(screen.getByText('soc2')).toBeTruthy() }) }) diff --git a/web/src/features/skill/compliance-snapshot-panel.tsx b/web/src/features/skill/compliance-snapshot-panel.tsx index b35460c1..a08d50cd 100644 --- a/web/src/features/skill/compliance-snapshot-panel.tsx +++ b/web/src/features/skill/compliance-snapshot-panel.tsx @@ -1,4 +1,5 @@ -import { ExternalLink, ShieldCheck } from 'lucide-react' +import { useState } from 'react' +import { ChevronDown, ChevronUp, ExternalLink, ShieldCheck } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { ComplianceSnapshot } from '@/api/types' import { cn } from '@/shared/lib/utils' @@ -6,6 +7,7 @@ import { cn } from '@/shared/lib/utils' interface ComplianceSnapshotPanelProps { snapshot?: ComplianceSnapshot | null className?: string + defaultExpanded?: boolean } function shortDigest(digest?: string) { @@ -18,61 +20,98 @@ function shortDigest(digest?: string) { return `${digest.slice(0, 17)}…` } -export function ComplianceSnapshotPanel({ snapshot, className }: ComplianceSnapshotPanelProps) { +export function ComplianceSnapshotPanel({ snapshot, className, defaultExpanded = false }: ComplianceSnapshotPanelProps) { const { t } = useTranslation() const items = snapshot?.items?.filter((item) => item.standard || item.controlId) ?? [] + const [isExpanded, setIsExpanded] = useState(defaultExpanded) if (items.length === 0) { return null } return ( -
+
-
+
{t('compliance.title')} {t('compliance.mappingCount', { count: items.length })} + {snapshot?.schemaVersion ? ( + + {snapshot.schemaVersion} + + ) : null}
-
- {shortDigest(snapshot?.digest)} +
+
+ {shortDigest(snapshot?.digest)} +
+
-
- {items.map((item, index) => ( -
-
- - {item.standard ?? t('compliance.unknownStandard')} - - {item.version ? ( - {item.version} - ) : null} - {item.controlId ?? '—'} -
- {item.title ? ( -
{item.title}
- ) : null} - {item.evidence && item.evidence.length > 0 ? ( -
- {item.evidence.map((evidence, evidenceIndex) => ( - - {evidence.url ? : null} - {evidence.path ?? evidence.url ?? evidence.type ?? t('compliance.evidence')} - - ))} -
- ) : null} -
+
+ {items.slice(0, isExpanded ? items.length : 2).map((item, index) => ( + + + {[item.standard, item.controlId].filter(Boolean).join(' · ')} + ))} + {!isExpanded && items.length > 2 ? ( + + +{items.length - 2} + + ) : null}
+ + {isExpanded ? ( +
+ {items.map((item, index) => ( +
+
+ + {item.standard ?? t('compliance.unknownStandard')} + + {item.version ? ( + {item.version} + ) : null} + {item.controlId ?? '—'} +
+ {item.title ? ( +
{item.title}
+ ) : null} + {item.evidence && item.evidence.length > 0 ? ( +
+ {item.evidence.map((evidence, evidenceIndex) => ( + + {evidence.url ? : null} + {evidence.path ?? evidence.url ?? evidence.type ?? t('compliance.evidence')} + + ))} +
+ ) : null} +
+ ))} +
+ ) : null}
) } 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..65a1e265 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,10 +1,14 @@ { "compliance": { - "title": "Compliance mappings", + "title": "Compliance claims", "mappingCount": "{{count}} items", "unknownStandard": "Unknown standard", "evidence": "Evidence" }, + "common": { + "expand": "Expand details", + "collapse": "Collapse details" + }, "nav": { "landing": "Home", "home": "Skill Center", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b4201a1d..cf1b92b8 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1,10 +1,14 @@ { "compliance": { - "title": "合规映射", + "title": "合规声明", "mappingCount": "{{count}} 项", "unknownStandard": "未知标准", "evidence": "证据" }, + "common": { + "expand": "展开详情", + "collapse": "收起详情" + }, "nav": { "landing": "首页", "home": "技能中心",