@agent-facets/core 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type } from 'arktype'
3
+ import { type Lockfile, LockfileSchema } from '../schemas/lockfile.ts'
4
+
5
+ // --- Valid lockfiles ---
6
+
7
+ describe('LockfileSchema — valid lockfiles', () => {
8
+ test('lockfile with source-mode servers', () => {
9
+ const input = {
10
+ facet: {
11
+ name: 'acme-dev',
12
+ version: '1.0.0',
13
+ integrity: 'sha256:abc123',
14
+ },
15
+ servers: {
16
+ jira: {
17
+ version: '1.5.2',
18
+ integrity: 'sha256:def456',
19
+ api_surface: 'sha256:789abc',
20
+ },
21
+ },
22
+ }
23
+ const result = LockfileSchema(input)
24
+ expect(result).not.toBeInstanceOf(type.errors)
25
+ const data = result as Lockfile
26
+ expect(data.facet.name).toBe('acme-dev')
27
+ expect(data.servers?.jira).toEqual({
28
+ version: '1.5.2',
29
+ integrity: 'sha256:def456',
30
+ api_surface: 'sha256:789abc',
31
+ })
32
+ })
33
+
34
+ test('lockfile with ref-mode servers', () => {
35
+ const input = {
36
+ facet: {
37
+ name: 'acme-dev',
38
+ version: '1.0.0',
39
+ integrity: 'sha256:abc123',
40
+ },
41
+ servers: {
42
+ slack: {
43
+ image: 'ghcr.io/acme/slack-bot:v2',
44
+ digest: 'sha256:e4d909',
45
+ api_surface: 'sha256:567ghi',
46
+ },
47
+ },
48
+ }
49
+ const result = LockfileSchema(input)
50
+ expect(result).not.toBeInstanceOf(type.errors)
51
+ const data = result as Lockfile
52
+ expect(data.servers?.slack).toEqual({
53
+ image: 'ghcr.io/acme/slack-bot:v2',
54
+ digest: 'sha256:e4d909',
55
+ api_surface: 'sha256:567ghi',
56
+ })
57
+ })
58
+
59
+ test('lockfile with mixed server types', () => {
60
+ const input = {
61
+ facet: {
62
+ name: 'acme-dev',
63
+ version: '1.0.0',
64
+ integrity: 'sha256:abc123',
65
+ },
66
+ servers: {
67
+ jira: {
68
+ version: '1.5.2',
69
+ integrity: 'sha256:def456',
70
+ api_surface: 'sha256:789abc',
71
+ },
72
+ slack: {
73
+ image: 'ghcr.io/acme/slack-bot:v2',
74
+ digest: 'sha256:e4d909',
75
+ api_surface: 'sha256:567ghi',
76
+ },
77
+ },
78
+ }
79
+ const result = LockfileSchema(input)
80
+ expect(result).not.toBeInstanceOf(type.errors)
81
+ })
82
+
83
+ test('lockfile without servers is valid', () => {
84
+ const input = {
85
+ facet: {
86
+ name: 'no-servers',
87
+ version: '1.0.0',
88
+ integrity: 'sha256:abc123',
89
+ },
90
+ }
91
+ const result = LockfileSchema(input)
92
+ expect(result).not.toBeInstanceOf(type.errors)
93
+ const data = result as Lockfile
94
+ expect(data.servers).toBeUndefined()
95
+ })
96
+ })
97
+
98
+ // --- Invalid lockfiles ---
99
+
100
+ describe('LockfileSchema — invalid lockfiles', () => {
101
+ test('missing facet integrity', () => {
102
+ const input = {
103
+ facet: {
104
+ name: 'acme-dev',
105
+ version: '1.0.0',
106
+ },
107
+ }
108
+ const result = LockfileSchema(input)
109
+ expect(result).toBeInstanceOf(type.errors)
110
+ })
111
+
112
+ test('incomplete source-mode server entry', () => {
113
+ const input = {
114
+ facet: {
115
+ name: 'acme-dev',
116
+ version: '1.0.0',
117
+ integrity: 'sha256:abc123',
118
+ },
119
+ servers: {
120
+ jira: {
121
+ version: '1.5.2',
122
+ // missing integrity and api_surface
123
+ },
124
+ },
125
+ }
126
+ const result = LockfileSchema(input)
127
+ expect(result).toBeInstanceOf(type.errors)
128
+ })
129
+
130
+ test('ref-mode missing digest', () => {
131
+ const input = {
132
+ facet: {
133
+ name: 'acme-dev',
134
+ version: '1.0.0',
135
+ integrity: 'sha256:abc123',
136
+ },
137
+ servers: {
138
+ slack: {
139
+ image: 'ghcr.io/acme/slack-bot:v2',
140
+ // missing digest and api_surface
141
+ },
142
+ },
143
+ }
144
+ const result = LockfileSchema(input)
145
+ expect(result).toBeInstanceOf(type.errors)
146
+ })
147
+ })
148
+
149
+ // --- Unknown field pass-through ---
150
+
151
+ describe('LockfileSchema — unknown field tolerance', () => {
152
+ test('unknown field in lockfile is preserved', () => {
153
+ const input = {
154
+ facet: {
155
+ name: 'acme-dev',
156
+ version: '1.0.0',
157
+ integrity: 'sha256:abc123',
158
+ },
159
+ generatedAt: '2026-03-08',
160
+ }
161
+ const result = LockfileSchema(input)
162
+ expect(result).not.toBeInstanceOf(type.errors)
163
+ const data = result as Lockfile & { generatedAt: string }
164
+ expect(data.generatedAt).toBe('2026-03-08')
165
+ })
166
+ })
@@ -0,0 +1,99 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { loadServerManifest } from '../loaders/server.ts'
6
+
7
+ let testDir: string
8
+
9
+ beforeAll(async () => {
10
+ testDir = await mkdtemp(join(tmpdir(), 'server-loader-test-'))
11
+ })
12
+
13
+ afterAll(async () => {
14
+ await rm(testDir, { recursive: true, force: true })
15
+ })
16
+
17
+ async function writeFixture(dir: string, filename: string, content: string) {
18
+ const path = join(dir, filename)
19
+ await Bun.write(path, content)
20
+ return path
21
+ }
22
+
23
+ async function createFixtureDir(name: string): Promise<string> {
24
+ const dir = join(testDir, name)
25
+ await Bun.write(join(dir, '.keep'), '')
26
+ return dir
27
+ }
28
+
29
+ describe('loadServerManifest', () => {
30
+ test('successful load', async () => {
31
+ const dir = await createFixtureDir('valid-server')
32
+ await writeFixture(
33
+ dir,
34
+ 'server.json',
35
+ JSON.stringify({
36
+ name: 'jira',
37
+ version: '1.5.0',
38
+ runtime: 'bun',
39
+ entry: 'index.ts',
40
+ description: 'Jira integration',
41
+ author: 'acme-org',
42
+ }),
43
+ )
44
+
45
+ const result = await loadServerManifest(dir)
46
+ expect(result.ok).toBe(true)
47
+ if (result.ok) {
48
+ expect(result.data.name).toBe('jira')
49
+ expect(result.data.version).toBe('1.5.0')
50
+ expect(result.data.runtime).toBe('bun')
51
+ expect(result.data.entry).toBe('index.ts')
52
+ expect(result.data.description).toBe('Jira integration')
53
+ expect(result.data.author).toBe('acme-org')
54
+ }
55
+ })
56
+
57
+ test('file not found', async () => {
58
+ const dir = await createFixtureDir('missing-server')
59
+
60
+ const result = await loadServerManifest(dir)
61
+ expect(result.ok).toBe(false)
62
+ if (!result.ok) {
63
+ expect(result.errors).toHaveLength(1)
64
+ expect(result.errors.at(0)?.message).toContain('File not found')
65
+ }
66
+ })
67
+
68
+ test('malformed JSON', async () => {
69
+ const dir = await createFixtureDir('malformed-server')
70
+ await writeFixture(dir, 'server.json', '{ "name": [unterminated')
71
+
72
+ const result = await loadServerManifest(dir)
73
+ expect(result.ok).toBe(false)
74
+ if (!result.ok) {
75
+ expect(result.errors.at(0)?.message).toContain('JSON syntax error')
76
+ }
77
+ })
78
+
79
+ test('validation errors for missing required fields', async () => {
80
+ const dir = await createFixtureDir('invalid-server')
81
+ await writeFixture(
82
+ dir,
83
+ 'server.json',
84
+ JSON.stringify({
85
+ name: 'jira',
86
+ version: '1.5.0',
87
+ }),
88
+ )
89
+
90
+ const result = await loadServerManifest(dir)
91
+ expect(result.ok).toBe(false)
92
+ if (!result.ok) {
93
+ // Should have errors for missing runtime and entry
94
+ expect(result.errors.length).toBeGreaterThanOrEqual(1)
95
+ const messages = result.errors.map((e) => e.message).join(' ')
96
+ expect(messages).toContain('runtime')
97
+ }
98
+ })
99
+ })
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type } from 'arktype'
3
+ import { type ServerManifest, ServerManifestSchema } from '../schemas/server-manifest.ts'
4
+
5
+ // --- Valid manifests ---
6
+
7
+ describe('ServerManifestSchema — valid manifests', () => {
8
+ test('minimal valid server manifest', () => {
9
+ const input = {
10
+ name: 'jira',
11
+ version: '1.5.0',
12
+ runtime: 'bun',
13
+ entry: 'index.ts',
14
+ }
15
+ const result = ServerManifestSchema(input)
16
+ expect(result).not.toBeInstanceOf(type.errors)
17
+ const data = result as ServerManifest
18
+ expect(data.name).toBe('jira')
19
+ expect(data.version).toBe('1.5.0')
20
+ expect(data.runtime).toBe('bun')
21
+ expect(data.entry).toBe('index.ts')
22
+ })
23
+
24
+ test('server manifest with optional fields', () => {
25
+ const input = {
26
+ name: 'jira',
27
+ version: '1.5.0',
28
+ description: 'Jira integration',
29
+ author: 'acme-org',
30
+ runtime: 'bun',
31
+ entry: 'index.ts',
32
+ }
33
+ const result = ServerManifestSchema(input)
34
+ expect(result).not.toBeInstanceOf(type.errors)
35
+ const data = result as ServerManifest
36
+ expect(data.description).toBe('Jira integration')
37
+ expect(data.author).toBe('acme-org')
38
+ })
39
+ })
40
+
41
+ // --- Invalid manifests ---
42
+
43
+ describe('ServerManifestSchema — invalid manifests', () => {
44
+ test('missing runtime', () => {
45
+ const input = {
46
+ name: 'jira',
47
+ version: '1.5.0',
48
+ entry: 'index.ts',
49
+ }
50
+ const result = ServerManifestSchema(input)
51
+ expect(result).toBeInstanceOf(type.errors)
52
+ })
53
+
54
+ test('missing entry', () => {
55
+ const input = {
56
+ name: 'jira',
57
+ version: '1.5.0',
58
+ runtime: 'bun',
59
+ }
60
+ const result = ServerManifestSchema(input)
61
+ expect(result).toBeInstanceOf(type.errors)
62
+ })
63
+
64
+ test('wrong field type', () => {
65
+ const input = {
66
+ name: 'jira',
67
+ version: '1.5.0',
68
+ runtime: 42,
69
+ entry: 'index.ts',
70
+ }
71
+ const result = ServerManifestSchema(input)
72
+ expect(result).toBeInstanceOf(type.errors)
73
+ })
74
+ })
75
+
76
+ // --- Unknown field pass-through ---
77
+
78
+ describe('ServerManifestSchema — unknown field tolerance', () => {
79
+ test('unknown field is preserved', () => {
80
+ const input = {
81
+ name: 'jira',
82
+ version: '1.5.0',
83
+ runtime: 'bun',
84
+ entry: 'index.ts',
85
+ license: 'MIT',
86
+ }
87
+ const result = ServerManifestSchema(input)
88
+ expect(result).not.toBeInstanceOf(type.errors)
89
+ const data = result as ServerManifest & { license: string }
90
+ expect(data.license).toBe('MIT')
91
+ })
92
+ })
@@ -0,0 +1,102 @@
1
+ import { createTar, type TarFileInput } from 'nanotar'
2
+ import { FACET_MANIFEST_FILE, type ResolvedFacetManifest } from '../loaders/facet.ts'
3
+
4
+ export interface ArchiveEntry {
5
+ path: string
6
+ content: string
7
+ }
8
+
9
+ /**
10
+ * Computes a SHA-256 content hash of the given content.
11
+ * Returns the hash in ADR-004 format: `sha256:<hex>`.
12
+ */
13
+ export function computeContentHash(content: string | Uint8Array): string {
14
+ const hex = Bun.CryptoHasher.hash('sha256', content, 'hex')
15
+ return `sha256:${hex}`
16
+ }
17
+
18
+ /**
19
+ * Collects all files that belong in the archive from a resolved manifest.
20
+ * Returns entries sorted lexicographically by path.
21
+ *
22
+ * The manifest content is read separately because the resolved manifest
23
+ * is a parsed object — we need the original file content for the archive.
24
+ */
25
+ export function collectArchiveEntries(resolved: ResolvedFacetManifest, manifestContent: string): ArchiveEntry[] {
26
+ const entries: ArchiveEntry[] = [{ path: FACET_MANIFEST_FILE, content: manifestContent }]
27
+
28
+ if (resolved.skills) {
29
+ for (const [name, skill] of Object.entries(resolved.skills)) {
30
+ entries.push({ path: `skills/${name}.md`, content: skill.prompt })
31
+ }
32
+ }
33
+
34
+ if (resolved.agents) {
35
+ for (const [name, agent] of Object.entries(resolved.agents)) {
36
+ entries.push({ path: `agents/${name}.md`, content: agent.prompt })
37
+ }
38
+ }
39
+
40
+ if (resolved.commands) {
41
+ for (const [name, command] of Object.entries(resolved.commands)) {
42
+ entries.push({ path: `commands/${name}.md`, content: command.prompt })
43
+ }
44
+ }
45
+
46
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
47
+
48
+ return entries
49
+ }
50
+
51
+ /**
52
+ * Computes SHA-256 content hashes for each archive entry.
53
+ * Returns a map of relative path to `sha256:<hex>`.
54
+ */
55
+ export function computeAssetHashes(entries: ArchiveEntry[]): Record<string, string> {
56
+ const hashes: Record<string, string> = {}
57
+ for (const entry of entries) {
58
+ hashes[entry.path] = computeContentHash(entry.content)
59
+ }
60
+ return hashes
61
+ }
62
+
63
+ /**
64
+ * Assembles a deterministic uncompressed tar archive from archive entries.
65
+ *
66
+ * Determinism is ensured by:
67
+ * - Entries must be pre-sorted by path (caller responsibility via collectArchiveEntries)
68
+ * - All metadata is zeroed: mtime=0, uid=0, gid=0, fixed mode, empty user/group
69
+ *
70
+ * The tar bytes are what gets content-hashed (the integrity value).
71
+ * Compression is a separate delivery concern — see {@link compressArchive}.
72
+ */
73
+ export function assembleTar(entries: ArchiveEntry[]): Uint8Array {
74
+ const files: TarFileInput[] = entries.map((entry) => ({
75
+ name: entry.path,
76
+ data: entry.content,
77
+ }))
78
+
79
+ return createTar(files, {
80
+ attrs: {
81
+ mtime: 0,
82
+ uid: 0,
83
+ gid: 0,
84
+ mode: '644',
85
+ user: '',
86
+ group: '',
87
+ },
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Compresses tar bytes with gzip for the `.facet` delivery format.
93
+ *
94
+ * Compression is a delivery concern — the integrity hash covers the
95
+ * uncompressed tar bytes, not the compressed output. This allows
96
+ * changing compression algorithms without invalidating hashes.
97
+ */
98
+ export function compressArchive(tarBytes: Uint8Array): Uint8Array {
99
+ const buffer = new ArrayBuffer(tarBytes.byteLength)
100
+ new Uint8Array(buffer).set(tarBytes)
101
+ return Bun.gzipSync(buffer)
102
+ }
@@ -0,0 +1,36 @@
1
+ import type { FacetManifest } from '../schemas/facet-manifest.ts'
2
+ import type { ValidationError } from '../types.ts'
3
+
4
+ type AssetType = 'skill' | 'agent' | 'command'
5
+
6
+ /**
7
+ * Detects naming collisions within each asset type.
8
+ * Skills must have unique names within skills, agents within agents,
9
+ * and commands within commands. Cross-type duplicates are allowed —
10
+ * a skill and an agent may share the same name.
11
+ */
12
+ export function detectNamingCollisions(manifest: FacetManifest): ValidationError[] {
13
+ const errors: ValidationError[] = []
14
+
15
+ const checkDuplicates = (names: string[], type: AssetType) => {
16
+ const seen = new Set<string>()
17
+ for (const name of names) {
18
+ if (seen.has(name)) {
19
+ errors.push({
20
+ path: name,
21
+ message: `Naming collision: "${name}" is declared more than once in ${type}s`,
22
+ expected: `unique name within ${type}s`,
23
+ actual: `"${name}" appears multiple times in ${type}s`,
24
+ })
25
+ } else {
26
+ seen.add(name)
27
+ }
28
+ }
29
+ }
30
+
31
+ if (manifest.skills) checkDuplicates(Object.keys(manifest.skills), 'skill')
32
+ if (manifest.agents) checkDuplicates(Object.keys(manifest.agents), 'agent')
33
+ if (manifest.commands) checkDuplicates(Object.keys(manifest.commands), 'command')
34
+
35
+ return errors
36
+ }
@@ -0,0 +1,120 @@
1
+ import { join } from 'node:path'
2
+ import { FACET_MANIFEST_FILE, loadManifest, type ResolvedFacetManifest, resolvePrompts } from '../loaders/facet.ts'
3
+ import type { ValidationError } from '../types.ts'
4
+ import {
5
+ assembleTar,
6
+ collectArchiveEntries,
7
+ compressArchive,
8
+ computeAssetHashes,
9
+ computeContentHash,
10
+ } from './content-hash.ts'
11
+ import { detectNamingCollisions } from './detect-collisions.ts'
12
+ import { validateCompactFacets } from './validate-facets.ts'
13
+ import { validatePlatformConfigs } from './validate-platforms.ts'
14
+
15
+ export interface BuildProgress {
16
+ stage: string
17
+ status: 'running' | 'done' | 'failed'
18
+ }
19
+
20
+ export interface BuildResult {
21
+ ok: true
22
+ data: ResolvedFacetManifest
23
+ warnings: string[]
24
+ archiveBytes: Uint8Array
25
+ integrity: string
26
+ archiveFilename: string
27
+ assetHashes: Record<string, string>
28
+ }
29
+
30
+ export interface BuildFailure {
31
+ ok: false
32
+ errors: ValidationError[]
33
+ warnings: string[]
34
+ }
35
+
36
+ /**
37
+ * Runs the full build pipeline:
38
+ * 1. Load manifest — read the facet manifest, parse JSON, validate schema, check constraints
39
+ * 2. Resolve prompts — read prompt files at conventional paths for skills, agents, commands (also verifies files exist)
40
+ * 3. Validate compact facets format — check name@version pattern
41
+ * 4. Detect naming collisions — fail if same name used within an asset type
42
+ * 5. Validate platform config — check known platform schemas, warn on unknown
43
+ * 6. Assemble archive — collect entries, compute per-asset hashes, build deterministic tar, compute integrity hash, compress for delivery
44
+ *
45
+ * Returns the resolved manifest and archive data on success, or collected errors on failure.
46
+ * Warnings are returned in both cases.
47
+ *
48
+ * An optional `onProgress` callback receives stage updates for UI display.
49
+ */
50
+ export async function runBuildPipeline(
51
+ rootDir: string,
52
+ onProgress?: (progress: BuildProgress) => void,
53
+ ): Promise<BuildResult | BuildFailure> {
54
+ const warnings: string[] = []
55
+
56
+ // Stage 1: Load manifest
57
+ onProgress?.({ stage: 'Validating manifest', status: 'running' })
58
+
59
+ const loadResult = await loadManifest(rootDir)
60
+ if (!loadResult.ok) {
61
+ onProgress?.({ stage: 'Validating manifest', status: 'failed' })
62
+ return { ok: false, errors: loadResult.errors, warnings }
63
+ }
64
+ const manifest = loadResult.data
65
+
66
+ // Stage 2: Resolve prompts (also serves as file existence verification)
67
+ const resolveResult = await resolvePrompts(manifest, rootDir)
68
+ if (!resolveResult.ok) {
69
+ onProgress?.({ stage: 'Validating manifest', status: 'failed' })
70
+ return { ok: false, errors: resolveResult.errors, warnings }
71
+ }
72
+
73
+ // Stage 3: Validate compact facets format
74
+ const facetsErrors = validateCompactFacets(manifest)
75
+ if (facetsErrors.length > 0) {
76
+ onProgress?.({ stage: 'Validating manifest', status: 'failed' })
77
+ return { ok: false, errors: facetsErrors, warnings }
78
+ }
79
+
80
+ // Stage 4: Detect naming collisions
81
+ const collisionErrors = detectNamingCollisions(manifest)
82
+ if (collisionErrors.length > 0) {
83
+ onProgress?.({ stage: 'Validating manifest', status: 'failed' })
84
+ return { ok: false, errors: collisionErrors, warnings }
85
+ }
86
+
87
+ // Stage 5: Validate platform config
88
+ const platformResult = validatePlatformConfigs(manifest)
89
+ if (platformResult.errors.length > 0) {
90
+ onProgress?.({ stage: 'Validating manifest', status: 'failed' })
91
+ return { ok: false, errors: platformResult.errors, warnings: [...warnings, ...platformResult.warnings] }
92
+ }
93
+ warnings.push(...platformResult.warnings)
94
+
95
+ onProgress?.({ stage: 'Validating manifest', status: 'done' })
96
+
97
+ // Stage 6: Assemble archive, compute content hashes, and compress for delivery
98
+ onProgress?.({ stage: 'Assembling archive', status: 'running' })
99
+
100
+ const resolved = resolveResult.data
101
+ const manifestContent = await Bun.file(join(rootDir, FACET_MANIFEST_FILE)).text()
102
+ const entries = collectArchiveEntries(resolved, manifestContent)
103
+ const assetHashes = computeAssetHashes(entries)
104
+ const tarBytes = assembleTar(entries)
105
+ const integrity = computeContentHash(tarBytes)
106
+ const archiveBytes = compressArchive(tarBytes)
107
+ const archiveFilename = `${resolved.name}-${resolved.version}.facet`
108
+
109
+ onProgress?.({ stage: 'Assembling archive', status: 'done' })
110
+
111
+ return {
112
+ ok: true,
113
+ data: resolved,
114
+ warnings,
115
+ archiveBytes,
116
+ integrity,
117
+ archiveFilename,
118
+ assetHashes,
119
+ }
120
+ }
@@ -0,0 +1,34 @@
1
+ import type { FacetManifest } from '../schemas/facet-manifest.ts'
2
+ import type { ValidationError } from '../types.ts'
3
+
4
+ /**
5
+ * Pattern for compact facets entries: "name@version" or "@scope/name@version".
6
+ * The last `@` before a non-empty version string is the separator.
7
+ */
8
+ const COMPACT_FACETS_PATTERN = /^(@?[^@]+)@(.+)$/
9
+
10
+ /**
11
+ * Validates compact facets entries conform to the "name@version" format.
12
+ * Selective (object) entries are skipped — they have their own structural validation.
13
+ */
14
+ export function validateCompactFacets(manifest: FacetManifest): ValidationError[] {
15
+ const errors: ValidationError[] = []
16
+
17
+ if (!manifest.facets) return errors
18
+
19
+ for (let i = 0; i < manifest.facets.length; i++) {
20
+ const entry = manifest.facets[i]
21
+ if (typeof entry !== 'string') continue
22
+
23
+ if (!COMPACT_FACETS_PATTERN.test(entry)) {
24
+ errors.push({
25
+ path: `facets[${i}]`,
26
+ message: `Compact facets entry "${entry}" does not match the expected "name@version" format`,
27
+ expected: '"name@version" or "@scope/name@version"',
28
+ actual: entry,
29
+ })
30
+ }
31
+ }
32
+
33
+ return errors
34
+ }