@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,226 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { parseTar, parseTarGzip } from 'nanotar'
3
+ import {
4
+ assembleTar,
5
+ collectArchiveEntries,
6
+ compressArchive,
7
+ computeAssetHashes,
8
+ computeContentHash,
9
+ } from '../build/content-hash.ts'
10
+ import type { ResolvedFacetManifest } from '../loaders/facet.ts'
11
+
12
+ describe('computeContentHash', () => {
13
+ test('computes correct SHA-256 for string input', () => {
14
+ const hash = computeContentHash('hello world')
15
+ expect(hash).toMatchInlineSnapshot(`"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"`)
16
+ })
17
+
18
+ test('computes correct SHA-256 for Uint8Array input', () => {
19
+ const hash = computeContentHash(new TextEncoder().encode('hello world'))
20
+ expect(hash).toMatchInlineSnapshot(`"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"`)
21
+ })
22
+
23
+ test('identical content produces identical hashes', () => {
24
+ const hash1 = computeContentHash('identical content')
25
+ const hash2 = computeContentHash('identical content')
26
+ expect(hash1).toMatchInlineSnapshot(`"sha256:15bbe85aac4518db7da507997bd8b9baa07ddea5d0a08d098f85f1bf08c02521"`)
27
+ expect(hash2).toBe(hash1)
28
+ })
29
+
30
+ test('different content produces different hashes', () => {
31
+ const hash1 = computeContentHash('content A')
32
+ const hash2 = computeContentHash('content B')
33
+ expect(hash1).toMatchInlineSnapshot(`"sha256:49114a9a2b7d46ec27be62ae3eade12f78d46cf5a99c52cd4f80381d723eed6e"`)
34
+ expect(hash2).toMatchInlineSnapshot(`"sha256:d27a54dc662fff702c2183d536e87414d5fe6fc072f6bc270b01a34f6de265bc"`)
35
+ })
36
+
37
+ test('string and Uint8Array of same content produce same hash', () => {
38
+ const content = 'same content'
39
+ const hashStr = computeContentHash(content)
40
+ const hashBytes = computeContentHash(new TextEncoder().encode(content))
41
+ expect(hashStr).toMatchInlineSnapshot(`"sha256:a636bd7cd42060a4d07fa1bfbcc010eb7794c2ba721e1e3e4c20335a15b66eaf"`)
42
+ expect(hashBytes).toBe(hashStr)
43
+ })
44
+ })
45
+
46
+ describe('collectArchiveEntries', () => {
47
+ test('collects manifest and all asset types', () => {
48
+ const resolved: ResolvedFacetManifest = {
49
+ name: 'test',
50
+ version: '1.0.0',
51
+ skills: {
52
+ review: { description: 'Review skill', prompt: '# Review' },
53
+ },
54
+ agents: {
55
+ helper: { description: 'Helper agent', prompt: '# Helper' },
56
+ },
57
+ commands: {
58
+ deploy: { description: 'Deploy command', prompt: '# Deploy' },
59
+ },
60
+ }
61
+
62
+ const entries = collectArchiveEntries(resolved, '{"name":"test","version":"1.0.0"}')
63
+
64
+ expect(entries).toHaveLength(4)
65
+ expect(entries.map((e) => e.path)).toContain('facet.json')
66
+ expect(entries.map((e) => e.path)).toContain('skills/review.md')
67
+ expect(entries.map((e) => e.path)).toContain('agents/helper.md')
68
+ expect(entries.map((e) => e.path)).toContain('commands/deploy.md')
69
+ })
70
+
71
+ test('entries are sorted lexicographically by path', () => {
72
+ const resolved: ResolvedFacetManifest = {
73
+ name: 'test',
74
+ version: '1.0.0',
75
+ skills: {
76
+ 'z-skill': { description: 'Z', prompt: '# Z' },
77
+ 'a-skill': { description: 'A', prompt: '# A' },
78
+ },
79
+ agents: {
80
+ 'b-agent': { description: 'B', prompt: '# B' },
81
+ },
82
+ }
83
+
84
+ const entries = collectArchiveEntries(resolved, 'manifest content')
85
+ const paths = entries.map((e) => e.path)
86
+
87
+ expect(paths).toEqual(['agents/b-agent.md', 'facet.json', 'skills/a-skill.md', 'skills/z-skill.md'])
88
+ })
89
+
90
+ test('handles manifest with no optional asset types', () => {
91
+ const resolved: ResolvedFacetManifest = {
92
+ name: 'minimal',
93
+ version: '0.1.0',
94
+ skills: {
95
+ only: { description: 'Only skill', prompt: '# Only' },
96
+ },
97
+ }
98
+
99
+ const entries = collectArchiveEntries(resolved, 'manifest')
100
+ expect(entries).toHaveLength(2)
101
+ })
102
+ })
103
+
104
+ describe('computeAssetHashes', () => {
105
+ test('returns correct hash for each entry', () => {
106
+ const entries = [
107
+ { path: 'facet.json', content: '{"name":"test"}' },
108
+ { path: 'skills/review.md', content: '# Review' },
109
+ ]
110
+
111
+ const hashes = computeAssetHashes(entries)
112
+
113
+ expect(Object.keys(hashes)).toHaveLength(2)
114
+ expect(hashes['facet.json']).toMatchInlineSnapshot(
115
+ `"sha256:7d9fd2051fc32b32feab10946fab6bb91426ab7e39aa5439289ed892864aa91d"`,
116
+ )
117
+ expect(hashes['skills/review.md']).toMatchInlineSnapshot(
118
+ `"sha256:f1a9d9d60fba2e67d82d788760d147d95461a58456411e205bf33a6dbdc3497f"`,
119
+ )
120
+ })
121
+
122
+ test('hash matches computeContentHash for same content', () => {
123
+ const entries = [{ path: 'test.md', content: 'test content' }]
124
+
125
+ const hashes = computeAssetHashes(entries)
126
+
127
+ expect(hashes['test.md']).toMatchInlineSnapshot(
128
+ `"sha256:6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72"`,
129
+ )
130
+ })
131
+ })
132
+
133
+ describe('assembleTar', () => {
134
+ test('produces a valid tar archive', () => {
135
+ const entries = [
136
+ { path: 'facet.json', content: '{"name":"test","version":"1.0.0"}' },
137
+ { path: 'skills/review.md', content: '# Review skill' },
138
+ ]
139
+
140
+ const tar = assembleTar(entries)
141
+
142
+ expect(tar).toBeInstanceOf(Uint8Array)
143
+ expect(tar.length).toBeGreaterThan(0)
144
+
145
+ const parsed = parseTar(tar)
146
+ expect(parsed).toHaveLength(2)
147
+
148
+ const names = parsed.map((f) => f.name)
149
+ expect(names).toContain('facet.json')
150
+ expect(names).toContain('skills/review.md')
151
+ })
152
+
153
+ test('tar contains correct file contents', () => {
154
+ const entries = [{ path: 'test.md', content: 'hello world' }]
155
+
156
+ const tar = assembleTar(entries)
157
+ const parsed = parseTar(tar)
158
+
159
+ expect(parsed[0]?.text).toBe('hello world')
160
+ })
161
+
162
+ test('produces deterministic output — same input yields identical bytes', () => {
163
+ const entries = [
164
+ { path: 'a.md', content: 'content A' },
165
+ { path: 'b.md', content: 'content B' },
166
+ ]
167
+
168
+ const tar1 = assembleTar(entries)
169
+ const tar2 = assembleTar(entries)
170
+
171
+ expect(tar1.length).toBe(tar2.length)
172
+ expect(Buffer.from(tar1).equals(Buffer.from(tar2))).toBe(true)
173
+ })
174
+
175
+ test('deterministic tar produces stable hash', () => {
176
+ const entries = [
177
+ { path: 'a.md', content: 'content A' },
178
+ { path: 'b.md', content: 'content B' },
179
+ ]
180
+
181
+ const tar1 = assembleTar(entries)
182
+ const tar2 = assembleTar(entries)
183
+ const hash1 = computeContentHash(tar1)
184
+ const hash2 = computeContentHash(tar2)
185
+
186
+ expect(hash1).toMatch(/^sha256:[a-f0-9]{64}$/)
187
+ expect(hash1).toBe(hash2)
188
+ })
189
+
190
+ test('tar hash changes when content changes', () => {
191
+ const entries1 = [{ path: 'test.md', content: 'version 1' }]
192
+ const entries2 = [{ path: 'test.md', content: 'version 2' }]
193
+
194
+ const tar1 = assembleTar(entries1)
195
+ const tar2 = assembleTar(entries2)
196
+
197
+ const hash1 = computeContentHash(tar1)
198
+ const hash2 = computeContentHash(tar2)
199
+
200
+ expect(hash1).not.toBe(hash2)
201
+ })
202
+ })
203
+
204
+ describe('compressArchive', () => {
205
+ test('compressed archive can be decompressed to recover original tar', async () => {
206
+ const entries = [
207
+ { path: 'facet.json', content: '{"name":"test","version":"1.0.0"}' },
208
+ { path: 'skills/review.md', content: '# Review skill' },
209
+ ]
210
+
211
+ const tar = assembleTar(entries)
212
+ const compressed = compressArchive(tar)
213
+
214
+ expect(compressed.length).toBeGreaterThan(0)
215
+ expect(compressed.length).toBeLessThan(tar.length) // gzip should compress text content
216
+
217
+ // Decompress and verify contents survive the round-trip
218
+ const parsed = await parseTarGzip(compressed)
219
+ expect(parsed).toHaveLength(2)
220
+
221
+ const names = parsed.map((f) => f.name)
222
+ expect(names).toContain('facet.json')
223
+ expect(names).toContain('skills/review.md')
224
+ expect(parsed.find((f) => f.name === 'skills/review.md')?.text).toBe('# Review skill')
225
+ })
226
+ })
@@ -0,0 +1,264 @@
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 { loadManifest, resolvePrompts } from '../loaders/facet.ts'
6
+
7
+ let testDir: string
8
+
9
+ beforeAll(async () => {
10
+ testDir = await mkdtemp(join(tmpdir(), 'facet-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'), '') // ensure dir exists
26
+ return dir
27
+ }
28
+
29
+ // --- loadManifest ---
30
+
31
+ describe('loadManifest', () => {
32
+ test('successful load', async () => {
33
+ const dir = await createFixtureDir('valid')
34
+ await writeFixture(
35
+ dir,
36
+ 'facet.json',
37
+ JSON.stringify({
38
+ name: 'test-facet',
39
+ version: '1.0.0',
40
+ skills: {
41
+ 'code-review': {
42
+ description: 'Reviews code for issues',
43
+ },
44
+ },
45
+ }),
46
+ )
47
+
48
+ const result = await loadManifest(dir)
49
+ expect(result.ok).toBe(true)
50
+ if (result.ok) {
51
+ expect(result.data.name).toBe('test-facet')
52
+ expect(result.data.version).toBe('1.0.0')
53
+ expect(result.data.skills?.['code-review']?.description).toBe('Reviews code for issues')
54
+ }
55
+ })
56
+
57
+ test('file not found', async () => {
58
+ const dir = await createFixtureDir('missing')
59
+
60
+ const result = await loadManifest(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')
70
+ await writeFixture(dir, 'facet.json', '{ "name": [unterminated')
71
+
72
+ const result = await loadManifest(dir)
73
+ expect(result.ok).toBe(false)
74
+ if (!result.ok) {
75
+ expect(result.errors).toHaveLength(1)
76
+ expect(result.errors.at(0)?.message).toContain('JSON syntax error')
77
+ }
78
+ })
79
+
80
+ test('schema validation errors with correct paths', async () => {
81
+ const dir = await createFixtureDir('schema-error')
82
+ await writeFixture(
83
+ dir,
84
+ 'facet.json',
85
+ JSON.stringify({
86
+ name: 'test-facet',
87
+ version: '1.0.0',
88
+ agents: {
89
+ reviewer: {
90
+ // missing required description
91
+ },
92
+ },
93
+ }),
94
+ )
95
+
96
+ const result = await loadManifest(dir)
97
+ expect(result.ok).toBe(false)
98
+ if (!result.ok) {
99
+ const descriptionError = result.errors.find((e) => e.path.includes('description'))
100
+ expect(descriptionError).toBeDefined()
101
+ }
102
+ })
103
+
104
+ test('no text assets → business-rule error', async () => {
105
+ const dir = await createFixtureDir('no-text')
106
+ await writeFixture(
107
+ dir,
108
+ 'facet.json',
109
+ JSON.stringify({
110
+ name: 'empty-facet',
111
+ version: '1.0.0',
112
+ servers: {
113
+ jira: '1.0.0',
114
+ },
115
+ }),
116
+ )
117
+
118
+ const result = await loadManifest(dir)
119
+ expect(result.ok).toBe(false)
120
+ if (!result.ok) {
121
+ expect(result.errors.at(0)?.message).toContain('at least one text asset')
122
+ }
123
+ })
124
+
125
+ test('full manifest loads successfully', async () => {
126
+ const dir = await createFixtureDir('full')
127
+ await writeFixture(
128
+ dir,
129
+ 'facet.json',
130
+ JSON.stringify({
131
+ name: 'acme-dev',
132
+ version: '1.0.0',
133
+ description: 'Acme dev toolkit',
134
+ author: 'acme-org',
135
+ skills: {
136
+ 'code-standards': {
137
+ description: 'Org coding standards',
138
+ },
139
+ },
140
+ agents: {
141
+ reviewer: {
142
+ description: 'Code reviewer',
143
+ },
144
+ },
145
+ commands: {
146
+ review: {
147
+ description: 'Run review',
148
+ },
149
+ },
150
+ facets: ['base@1.0.0'],
151
+ servers: {
152
+ jira: '1.0.0',
153
+ slack: {
154
+ image: 'ghcr.io/acme/slack-bot:v2',
155
+ },
156
+ },
157
+ }),
158
+ )
159
+
160
+ const result = await loadManifest(dir)
161
+ expect(result.ok).toBe(true)
162
+ if (result.ok) {
163
+ expect(result.data.name).toBe('acme-dev')
164
+ expect(result.data.agents?.reviewer?.description).toBe('Code reviewer')
165
+ expect(result.data.servers?.slack).toEqual({
166
+ image: 'ghcr.io/acme/slack-bot:v2',
167
+ })
168
+ }
169
+ })
170
+ })
171
+
172
+ // --- resolvePrompts ---
173
+
174
+ describe('resolvePrompts', () => {
175
+ test('prompt content is resolved from conventional file paths', async () => {
176
+ const dir = await createFixtureDir('resolve-convention')
177
+ await writeFixture(dir, 'skills/review.md', '# Code Review\nReview all code.')
178
+ await writeFixture(dir, 'agents/reviewer.md', '# Reviewer\nReview this code.')
179
+ await writeFixture(dir, 'commands/deploy.md', '# Deploy\nDeploy the code.')
180
+
181
+ const manifest = {
182
+ name: 'test',
183
+ version: '1.0.0',
184
+ skills: {
185
+ review: { description: 'A review skill' },
186
+ },
187
+ agents: {
188
+ reviewer: { description: 'A reviewer agent' },
189
+ },
190
+ commands: {
191
+ deploy: { description: 'A deploy command' },
192
+ },
193
+ }
194
+
195
+ const result = await resolvePrompts(manifest, dir)
196
+ expect(result.ok).toBe(true)
197
+ if (result.ok) {
198
+ expect(result.data.skills?.review?.prompt).toBe('# Code Review\nReview all code.')
199
+ expect(result.data.agents?.reviewer?.prompt).toBe('# Reviewer\nReview this code.')
200
+ expect(result.data.commands?.deploy?.prompt).toBe('# Deploy\nDeploy the code.')
201
+ }
202
+ })
203
+
204
+ test('file-based prompt is resolved from agents/<name>.md', async () => {
205
+ const dir = await createFixtureDir('resolve-file')
206
+ await writeFixture(dir, 'agents/reviewer.md', '# Review\nCheck all code.')
207
+
208
+ const manifest = {
209
+ name: 'test',
210
+ version: '1.0.0',
211
+ agents: {
212
+ reviewer: { description: 'A reviewer' },
213
+ },
214
+ }
215
+
216
+ const result = await resolvePrompts(manifest, dir)
217
+ expect(result.ok).toBe(true)
218
+ if (result.ok) {
219
+ expect(result.data.agents?.reviewer?.prompt).toBe('# Review\nCheck all code.')
220
+ }
221
+ })
222
+
223
+ test('missing prompt file reports error with asset name', async () => {
224
+ const dir = await createFixtureDir('resolve-missing')
225
+
226
+ const manifest = {
227
+ name: 'test',
228
+ version: '1.0.0',
229
+ agents: {
230
+ reviewer: { description: 'A reviewer' },
231
+ },
232
+ }
233
+
234
+ const result = await resolvePrompts(manifest, dir)
235
+ expect(result.ok).toBe(false)
236
+ if (!result.ok) {
237
+ expect(result.errors).toHaveLength(1)
238
+ expect(result.errors.at(0)?.path).toBe('agents.reviewer')
239
+ expect(result.errors.at(0)?.message).toContain('agents/reviewer.md')
240
+ }
241
+ })
242
+
243
+ test('manifest without agents or commands resolves successfully', async () => {
244
+ const dir = await createFixtureDir('resolve-skills-only')
245
+ await writeFixture(dir, 'skills/x.md', '# Skill X\nDo x.')
246
+
247
+ const manifest = {
248
+ name: 'test',
249
+ version: '1.0.0',
250
+ skills: {
251
+ x: {
252
+ description: 'A skill',
253
+ },
254
+ },
255
+ }
256
+
257
+ const result = await resolvePrompts(manifest, dir)
258
+ expect(result.ok).toBe(true)
259
+ if (result.ok) {
260
+ expect(result.data.name).toBe('test')
261
+ expect(result.data.skills?.x?.prompt).toBe('# Skill X\nDo x.')
262
+ }
263
+ })
264
+ })
@@ -0,0 +1,208 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type } from 'arktype'
3
+ import { checkFacetManifestConstraints, type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
4
+
5
+ // --- Valid manifests ---
6
+
7
+ describe('FacetManifestSchema — valid manifests', () => {
8
+ test('minimal manifest with a skill', () => {
9
+ const input = {
10
+ name: 'my-facet',
11
+ version: '1.0.0',
12
+ skills: {
13
+ 'code-review': {
14
+ description: 'Reviews code for issues',
15
+ },
16
+ },
17
+ }
18
+ const result = FacetManifestSchema(input)
19
+ expect(result).not.toBeInstanceOf(type.errors)
20
+ const data = result as FacetManifest
21
+ expect(data.name).toBe('my-facet')
22
+ expect(data.version).toBe('1.0.0')
23
+ expect(data.skills?.['code-review']?.description).toBe('Reviews code for issues')
24
+ })
25
+
26
+ test('full manifest with all sections', () => {
27
+ const input = {
28
+ name: 'acme-dev',
29
+ version: '1.0.0',
30
+ description: 'Acme developer toolkit',
31
+ author: 'acme-org',
32
+ skills: {
33
+ 'code-standards': {
34
+ description: 'Org coding standards',
35
+ },
36
+ 'pr-template': {
37
+ description: 'PR template guidelines',
38
+ },
39
+ },
40
+ agents: {
41
+ reviewer: {
42
+ description: 'Org code reviewer',
43
+ platforms: {
44
+ opencode: { tools: { grep: true, bash: true } },
45
+ },
46
+ },
47
+ 'quick-check': {
48
+ description: 'Fast lint check',
49
+ },
50
+ },
51
+ commands: {
52
+ review: {
53
+ description: 'Run a code review',
54
+ },
55
+ },
56
+ facets: [
57
+ 'code-review-base@1.0.0',
58
+ {
59
+ name: 'typescript-patterns',
60
+ version: '2.1.0',
61
+ skills: ['ts-conventions', 'any-usage'],
62
+ },
63
+ ],
64
+ servers: {
65
+ jira: '1.0.0',
66
+ github: '2.3.0',
67
+ '@acme/deploy': '0.5.0',
68
+ slack: { image: 'ghcr.io/acme/slack-bot:v2' },
69
+ },
70
+ }
71
+ const result = FacetManifestSchema(input)
72
+ expect(result).not.toBeInstanceOf(type.errors)
73
+ const data = result as FacetManifest
74
+ expect(data.name).toBe('acme-dev')
75
+ expect(data.agents?.reviewer?.description).toBe('Org code reviewer')
76
+ expect(data.agents?.['quick-check']?.description).toBe('Fast lint check')
77
+ expect(data.servers?.jira).toBe('1.0.0')
78
+ expect(data.servers?.slack).toEqual({
79
+ image: 'ghcr.io/acme/slack-bot:v2',
80
+ })
81
+ })
82
+
83
+ test('manifest with only composed facets is valid', () => {
84
+ const input = {
85
+ name: 'composed-only',
86
+ version: '1.0.0',
87
+ facets: ['base@1.0.0'],
88
+ }
89
+ const result = FacetManifestSchema(input)
90
+ expect(result).not.toBeInstanceOf(type.errors)
91
+ const data = result as FacetManifest
92
+ const errors = checkFacetManifestConstraints(data)
93
+ expect(errors).toHaveLength(0)
94
+ })
95
+ })
96
+
97
+ // --- Invalid manifests ---
98
+
99
+ describe('FacetManifestSchema — invalid manifests', () => {
100
+ test('missing name', () => {
101
+ const input = { version: '1.0.0', skills: { x: { description: 'A skill' } } }
102
+ const result = FacetManifestSchema(input)
103
+ expect(result).toBeInstanceOf(type.errors)
104
+ })
105
+
106
+ test('missing version', () => {
107
+ const input = { name: 'my-facet', skills: { x: { description: 'A skill' } } }
108
+ const result = FacetManifestSchema(input)
109
+ expect(result).toBeInstanceOf(type.errors)
110
+ })
111
+
112
+ test('agent missing description', () => {
113
+ const input = {
114
+ name: 'my-facet',
115
+ version: '1.0.0',
116
+ agents: {
117
+ reviewer: { platforms: { opencode: {} } },
118
+ },
119
+ }
120
+ const result = FacetManifestSchema(input)
121
+ expect(result).toBeInstanceOf(type.errors)
122
+ const errors = result as InstanceType<typeof type.errors>
123
+ expect(errors.some((e) => e.path.includes('reviewer') && e.path.includes('description'))).toBe(true)
124
+ })
125
+
126
+ test('server reference object without image field', () => {
127
+ const input = {
128
+ name: 'my-facet',
129
+ version: '1.0.0',
130
+ skills: { x: { description: 'A skill' } },
131
+ servers: {
132
+ bad: { notImage: 'ghcr.io/something' },
133
+ },
134
+ }
135
+ const result = FacetManifestSchema(input)
136
+ expect(result).toBeInstanceOf(type.errors)
137
+ const errors = result as InstanceType<typeof type.errors>
138
+ expect(errors.some((e) => e.path.includes('bad'))).toBe(true)
139
+ })
140
+ })
141
+
142
+ // --- Business-rule constraints ---
143
+
144
+ describe('checkFacetManifestConstraints', () => {
145
+ test('no text assets → error', () => {
146
+ const input = {
147
+ name: 'empty',
148
+ version: '1.0.0',
149
+ servers: { jira: '1.0.0' },
150
+ }
151
+ const result = FacetManifestSchema(input)
152
+ expect(result).not.toBeInstanceOf(type.errors)
153
+ const errors = checkFacetManifestConstraints(result as FacetManifest)
154
+ expect(errors).toHaveLength(1)
155
+ const firstError = errors[0]
156
+ expect(firstError).toBeDefined()
157
+ expect(firstError?.message).toContain('at least one text asset')
158
+ })
159
+
160
+ test('selective facets entry with no asset selection → error', () => {
161
+ const input = {
162
+ name: 'bad-selective',
163
+ version: '1.0.0',
164
+ facets: [{ name: 'other', version: '1.0.0' }],
165
+ }
166
+ const result = FacetManifestSchema(input)
167
+ expect(result).not.toBeInstanceOf(type.errors)
168
+ const errors = checkFacetManifestConstraints(result as FacetManifest)
169
+ const selectiveError = errors.find((e) => e.message.includes('at least one asset type'))
170
+ expect(selectiveError).toBeDefined()
171
+ expect(selectiveError?.path).toBe('facets[0]')
172
+ })
173
+ })
174
+
175
+ // --- Unknown field pass-through ---
176
+
177
+ describe('FacetManifestSchema — unknown field tolerance', () => {
178
+ test('top-level unknown field is preserved', () => {
179
+ const input = {
180
+ name: 'my-facet',
181
+ version: '1.0.0',
182
+ skills: { x: { description: 'A skill' } },
183
+ license: 'MIT',
184
+ }
185
+ const result = FacetManifestSchema(input)
186
+ expect(result).not.toBeInstanceOf(type.errors)
187
+ const data = result as FacetManifest & { license: string }
188
+ expect(data.license).toBe('MIT')
189
+ })
190
+
191
+ test('unknown field nested in agent descriptor is preserved', () => {
192
+ const input = {
193
+ name: 'my-facet',
194
+ version: '1.0.0',
195
+ agents: {
196
+ reviewer: {
197
+ description: 'A reviewer agent',
198
+ model: 'claude-sonnet',
199
+ },
200
+ },
201
+ }
202
+ const result = FacetManifestSchema(input)
203
+ expect(result).not.toBeInstanceOf(type.errors)
204
+ const data = result as Record<string, unknown>
205
+ const agents = data.agents as Record<string, Record<string, unknown>> | undefined
206
+ expect(agents?.reviewer?.model).toBe('claude-sonnet')
207
+ })
208
+ })