@agent-facets/core 0.1.1 → 0.1.3
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.
- package/CHANGELOG.md +20 -0
- package/package.json +6 -3
- package/src/__tests__/build-pipeline.test.ts +90 -13
- package/src/__tests__/content-hash.test.ts +9 -9
- package/src/__tests__/edit.test.ts +190 -0
- package/src/__tests__/facet-loader.test.ts +2 -2
- package/src/__tests__/facet-manifest.test.ts +9 -21
- package/src/build/content-hash.ts +1 -1
- package/src/build/pipeline.ts +55 -22
- package/src/build/validate-content.ts +50 -0
- package/src/edit/manifest-writer.ts +12 -0
- package/src/edit/reconcile.ts +77 -0
- package/src/edit/scanner.ts +57 -0
- package/src/front-matter.ts +58 -0
- package/src/index.ts +12 -7
- package/src/loaders/facet.ts +11 -13
- package/src/loaders/validate.ts +4 -2
- package/src/schemas/facet-manifest.ts +21 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @agent-facets/core
|
|
2
2
|
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`098fd08`](https://github.com/agent-facets/facets/commit/098fd08bf5d9970babc5c57bee6a155bffcecd97) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Better CLI parameter validation
|
|
8
|
+
|
|
9
|
+
- [`5262cbe`](https://github.com/agent-facets/facets/commit/5262cbe66df02c625430309878e6061ccde183de) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Fix publishing by properly categorizing dev dependencies
|
|
10
|
+
|
|
11
|
+
- [`d3b9439`](https://github.com/agent-facets/facets/commit/d3b9439466e0eb65687901426e2ebd6c5a333c60) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Use better github attribution for changesets
|
|
12
|
+
|
|
13
|
+
## 0.1.2
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- bb87748: This is a CI improvement so we release faster and cleaner
|
|
18
|
+
- 95e2f38: Migrate NPM packages from `@ex-machina` to `@agent-facets` org.
|
|
19
|
+
|
|
20
|
+
- `@ex-machina/facet-core` is now `@agent-facets/core`
|
|
21
|
+
- `@ex-machina/facet` is now `agent-facets`
|
|
22
|
+
|
|
3
23
|
## 0.1.1
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -5,25 +5,28 @@
|
|
|
5
5
|
"url": "https://github.com/agent-facets/facets",
|
|
6
6
|
"directory": "packages/core"
|
|
7
7
|
},
|
|
8
|
-
"version": "0.1.
|
|
8
|
+
"version": "0.1.3",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
+
"prepack": "bun ../../scripts/prepack.ts",
|
|
15
|
+
"postpack": "bun ../../scripts/postpack.ts",
|
|
14
16
|
"types": "tsc --noEmit",
|
|
15
17
|
"test": "bun test"
|
|
16
18
|
},
|
|
17
19
|
"dependencies": {
|
|
18
20
|
"arktype": "2.1.29",
|
|
19
21
|
"comment-json": "^4.2.5",
|
|
20
|
-
"nanotar": "0.3.0"
|
|
22
|
+
"nanotar": "0.3.0",
|
|
23
|
+
"yaml": "2.8.3"
|
|
21
24
|
},
|
|
22
25
|
"devDependencies": {
|
|
23
26
|
"@types/bun": "1.3.10"
|
|
24
27
|
},
|
|
25
28
|
"peerDependencies": {
|
|
26
|
-
"typescript": "^5"
|
|
29
|
+
"typescript": "^5 || ^6"
|
|
27
30
|
},
|
|
28
31
|
"publishConfig": {
|
|
29
32
|
"access": "public",
|
|
@@ -206,7 +206,7 @@ describe('validatePlatformConfigs', () => {
|
|
|
206
206
|
describe('runBuildPipeline', () => {
|
|
207
207
|
test('successful build with valid facet', async () => {
|
|
208
208
|
const dir = await createFixtureDir('valid-build')
|
|
209
|
-
await Bun.write(join(dir, 'skills/example.md'), '# Example skill')
|
|
209
|
+
await Bun.write(join(dir, 'skills/example/SKILL.md'), '# Example skill')
|
|
210
210
|
await Bun.write(
|
|
211
211
|
join(dir, 'facet.json'),
|
|
212
212
|
JSON.stringify({
|
|
@@ -230,8 +230,8 @@ describe('runBuildPipeline', () => {
|
|
|
230
230
|
expect(result.archiveFilename).toBe('test-facet-1.0.0.facet')
|
|
231
231
|
expect(result.archiveBytes.length).toBeGreaterThan(0)
|
|
232
232
|
expect(Object.keys(result.assetHashes)).toContain('facet.json')
|
|
233
|
-
expect(Object.keys(result.assetHashes)).toContain('skills/example.md')
|
|
234
|
-
expect(result.assetHashes['skills/example.md']).toMatchInlineSnapshot(
|
|
233
|
+
expect(Object.keys(result.assetHashes)).toContain('skills/example/SKILL.md')
|
|
234
|
+
expect(result.assetHashes['skills/example/SKILL.md']).toMatchInlineSnapshot(
|
|
235
235
|
`"sha256:ded8057927e03783371d0d929e4a6e92da66eb9dd164377ad6845a5a1c0cb5ba"`,
|
|
236
236
|
)
|
|
237
237
|
expect(result.integrity).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
@@ -263,13 +263,13 @@ describe('runBuildPipeline', () => {
|
|
|
263
263
|
expect(result.ok).toBe(false)
|
|
264
264
|
if (!result.ok) {
|
|
265
265
|
expect(result.errors[0]?.path).toBe('skills.example')
|
|
266
|
-
expect(result.errors[0]?.message).toContain('skills/example.md')
|
|
266
|
+
expect(result.errors[0]?.message).toContain('skills/example/SKILL.md')
|
|
267
267
|
}
|
|
268
268
|
})
|
|
269
269
|
|
|
270
270
|
test('build succeeds with cross-type name sharing', async () => {
|
|
271
271
|
const dir = await createFixtureDir('cross-type')
|
|
272
|
-
await Bun.write(join(dir, 'skills/review.md'), '# Review skill')
|
|
272
|
+
await Bun.write(join(dir, 'skills/review/SKILL.md'), '# Review skill')
|
|
273
273
|
await Bun.write(join(dir, 'commands/review.md'), '# Review command')
|
|
274
274
|
await Bun.write(
|
|
275
275
|
join(dir, 'facet.json'),
|
|
@@ -291,8 +291,8 @@ describe('runBuildPipeline', () => {
|
|
|
291
291
|
|
|
292
292
|
test('build with all asset types includes all hashes', async () => {
|
|
293
293
|
const dir = await createFixtureDir('all-types')
|
|
294
|
-
await Bun.write(join(dir, 'skills/alpha.md'), '# Alpha skill')
|
|
295
|
-
await Bun.write(join(dir, 'skills/beta.md'), '# Beta skill')
|
|
294
|
+
await Bun.write(join(dir, 'skills/alpha/SKILL.md'), '# Alpha skill')
|
|
295
|
+
await Bun.write(join(dir, 'skills/beta/SKILL.md'), '# Beta skill')
|
|
296
296
|
await Bun.write(join(dir, 'agents/helper.md'), '# Helper agent')
|
|
297
297
|
await Bun.write(join(dir, 'commands/deploy.md'), '# Deploy command')
|
|
298
298
|
await Bun.write(
|
|
@@ -322,15 +322,15 @@ describe('runBuildPipeline', () => {
|
|
|
322
322
|
'agents/helper.md',
|
|
323
323
|
'commands/deploy.md',
|
|
324
324
|
'facet.json',
|
|
325
|
-
'skills/alpha.md',
|
|
326
|
-
'skills/beta.md',
|
|
325
|
+
'skills/alpha/SKILL.md',
|
|
326
|
+
'skills/beta/SKILL.md',
|
|
327
327
|
])
|
|
328
328
|
}
|
|
329
329
|
})
|
|
330
330
|
|
|
331
331
|
test('build fails on malformed compact facets entry', async () => {
|
|
332
332
|
const dir = await createFixtureDir('bad-facets')
|
|
333
|
-
await Bun.write(join(dir, 'skills/x.md'), '# Skill')
|
|
333
|
+
await Bun.write(join(dir, 'skills/x/SKILL.md'), '# Skill')
|
|
334
334
|
await Bun.write(
|
|
335
335
|
join(dir, 'facet.json'),
|
|
336
336
|
JSON.stringify({
|
|
@@ -351,12 +351,89 @@ describe('runBuildPipeline', () => {
|
|
|
351
351
|
})
|
|
352
352
|
})
|
|
353
353
|
|
|
354
|
+
// --- Content validation ---
|
|
355
|
+
|
|
356
|
+
describe('content validation', () => {
|
|
357
|
+
test('build fails on file with YAML front matter', async () => {
|
|
358
|
+
const dir = await createFixtureDir('front-matter')
|
|
359
|
+
await Bun.write(
|
|
360
|
+
join(dir, 'skills/review/SKILL.md'),
|
|
361
|
+
'---\nname: Review\ndescription: A review skill\n---\n# Review\nReview all code.',
|
|
362
|
+
)
|
|
363
|
+
await Bun.write(
|
|
364
|
+
join(dir, 'facet.json'),
|
|
365
|
+
JSON.stringify({
|
|
366
|
+
name: 'test-facet',
|
|
367
|
+
version: '1.0.0',
|
|
368
|
+
skills: {
|
|
369
|
+
review: { description: 'A review skill' },
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
const result = await runBuildPipeline(dir)
|
|
375
|
+
expect(result.ok).toBe(false)
|
|
376
|
+
if (!result.ok) {
|
|
377
|
+
expect(result.errors).toHaveLength(1)
|
|
378
|
+
expect(result.errors[0]?.path).toBe('skills.review')
|
|
379
|
+
expect(result.errors[0]?.message).toContain('front matter')
|
|
380
|
+
expect(result.errors[0]?.message).toContain('skills/review/SKILL.md')
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('build fails on empty content file', async () => {
|
|
385
|
+
const dir = await createFixtureDir('empty-file')
|
|
386
|
+
await Bun.write(join(dir, 'skills/empty/SKILL.md'), '')
|
|
387
|
+
await Bun.write(
|
|
388
|
+
join(dir, 'facet.json'),
|
|
389
|
+
JSON.stringify({
|
|
390
|
+
name: 'test-facet',
|
|
391
|
+
version: '1.0.0',
|
|
392
|
+
skills: {
|
|
393
|
+
empty: { description: 'An empty skill' },
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
const result = await runBuildPipeline(dir)
|
|
399
|
+
expect(result.ok).toBe(false)
|
|
400
|
+
if (!result.ok) {
|
|
401
|
+
expect(result.errors).toHaveLength(1)
|
|
402
|
+
expect(result.errors[0]?.path).toBe('skills.empty')
|
|
403
|
+
expect(result.errors[0]?.message).toContain('empty')
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('build fails on whitespace-only content file', async () => {
|
|
408
|
+
const dir = await createFixtureDir('whitespace-file')
|
|
409
|
+
await Bun.write(join(dir, 'agents/blank.md'), ' \n\n \n')
|
|
410
|
+
await Bun.write(
|
|
411
|
+
join(dir, 'facet.json'),
|
|
412
|
+
JSON.stringify({
|
|
413
|
+
name: 'test-facet',
|
|
414
|
+
version: '1.0.0',
|
|
415
|
+
agents: {
|
|
416
|
+
blank: { description: 'A blank agent' },
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
const result = await runBuildPipeline(dir)
|
|
422
|
+
expect(result.ok).toBe(false)
|
|
423
|
+
if (!result.ok) {
|
|
424
|
+
expect(result.errors).toHaveLength(1)
|
|
425
|
+
expect(result.errors[0]?.path).toBe('agents.blank')
|
|
426
|
+
expect(result.errors[0]?.message).toContain('empty')
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
})
|
|
430
|
+
|
|
354
431
|
// --- Build output generation ---
|
|
355
432
|
|
|
356
433
|
describe('writeBuildOutput', () => {
|
|
357
434
|
test('writes archive and build manifest to dist/', async () => {
|
|
358
435
|
const dir = await createFixtureDir('write-output')
|
|
359
|
-
await Bun.write(join(dir, 'skills/example.md'), '# Resolved content')
|
|
436
|
+
await Bun.write(join(dir, 'skills/example/SKILL.md'), '# Resolved content')
|
|
360
437
|
await Bun.write(
|
|
361
438
|
join(dir, 'facet.json'),
|
|
362
439
|
JSON.stringify({
|
|
@@ -385,7 +462,7 @@ describe('writeBuildOutput', () => {
|
|
|
385
462
|
expect(manifest.archive).toBe('test-facet-1.0.0.facet')
|
|
386
463
|
expect(manifest.integrity).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
387
464
|
expect(manifest.assets['facet.json']).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
388
|
-
expect(manifest.assets['skills/example.md']).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
465
|
+
expect(manifest.assets['skills/example/SKILL.md']).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
389
466
|
|
|
390
467
|
// No loose files
|
|
391
468
|
const looseManifest = await Bun.file(join(dir, 'dist/facet.json')).exists()
|
|
@@ -394,7 +471,7 @@ describe('writeBuildOutput', () => {
|
|
|
394
471
|
|
|
395
472
|
test('cleans previous dist/ before writing', async () => {
|
|
396
473
|
const dir = await createFixtureDir('clean-dist')
|
|
397
|
-
await Bun.write(join(dir, 'skills/x.md'), '# Skill')
|
|
474
|
+
await Bun.write(join(dir, 'skills/x/SKILL.md'), '# Skill')
|
|
398
475
|
await Bun.write(
|
|
399
476
|
join(dir, 'facet.json'),
|
|
400
477
|
JSON.stringify({
|
|
@@ -63,7 +63,7 @@ describe('collectArchiveEntries', () => {
|
|
|
63
63
|
|
|
64
64
|
expect(entries).toHaveLength(4)
|
|
65
65
|
expect(entries.map((e) => e.path)).toContain('facet.json')
|
|
66
|
-
expect(entries.map((e) => e.path)).toContain('skills/review.md')
|
|
66
|
+
expect(entries.map((e) => e.path)).toContain('skills/review/SKILL.md')
|
|
67
67
|
expect(entries.map((e) => e.path)).toContain('agents/helper.md')
|
|
68
68
|
expect(entries.map((e) => e.path)).toContain('commands/deploy.md')
|
|
69
69
|
})
|
|
@@ -84,7 +84,7 @@ describe('collectArchiveEntries', () => {
|
|
|
84
84
|
const entries = collectArchiveEntries(resolved, 'manifest content')
|
|
85
85
|
const paths = entries.map((e) => e.path)
|
|
86
86
|
|
|
87
|
-
expect(paths).toEqual(['agents/b-agent.md', 'facet.json', 'skills/a-skill.md', 'skills/z-skill.md'])
|
|
87
|
+
expect(paths).toEqual(['agents/b-agent.md', 'facet.json', 'skills/a-skill/SKILL.md', 'skills/z-skill/SKILL.md'])
|
|
88
88
|
})
|
|
89
89
|
|
|
90
90
|
test('handles manifest with no optional asset types', () => {
|
|
@@ -105,7 +105,7 @@ describe('computeAssetHashes', () => {
|
|
|
105
105
|
test('returns correct hash for each entry', () => {
|
|
106
106
|
const entries = [
|
|
107
107
|
{ path: 'facet.json', content: '{"name":"test"}' },
|
|
108
|
-
{ path: 'skills/review.md', content: '# Review' },
|
|
108
|
+
{ path: 'skills/review/SKILL.md', content: '# Review' },
|
|
109
109
|
]
|
|
110
110
|
|
|
111
111
|
const hashes = computeAssetHashes(entries)
|
|
@@ -114,7 +114,7 @@ describe('computeAssetHashes', () => {
|
|
|
114
114
|
expect(hashes['facet.json']).toMatchInlineSnapshot(
|
|
115
115
|
`"sha256:7d9fd2051fc32b32feab10946fab6bb91426ab7e39aa5439289ed892864aa91d"`,
|
|
116
116
|
)
|
|
117
|
-
expect(hashes['skills/review.md']).toMatchInlineSnapshot(
|
|
117
|
+
expect(hashes['skills/review/SKILL.md']).toMatchInlineSnapshot(
|
|
118
118
|
`"sha256:f1a9d9d60fba2e67d82d788760d147d95461a58456411e205bf33a6dbdc3497f"`,
|
|
119
119
|
)
|
|
120
120
|
})
|
|
@@ -134,7 +134,7 @@ describe('assembleTar', () => {
|
|
|
134
134
|
test('produces a valid tar archive', () => {
|
|
135
135
|
const entries = [
|
|
136
136
|
{ path: 'facet.json', content: '{"name":"test","version":"1.0.0"}' },
|
|
137
|
-
{ path: 'skills/review.md', content: '# Review skill' },
|
|
137
|
+
{ path: 'skills/review/SKILL.md', content: '# Review skill' },
|
|
138
138
|
]
|
|
139
139
|
|
|
140
140
|
const tar = assembleTar(entries)
|
|
@@ -147,7 +147,7 @@ describe('assembleTar', () => {
|
|
|
147
147
|
|
|
148
148
|
const names = parsed.map((f) => f.name)
|
|
149
149
|
expect(names).toContain('facet.json')
|
|
150
|
-
expect(names).toContain('skills/review.md')
|
|
150
|
+
expect(names).toContain('skills/review/SKILL.md')
|
|
151
151
|
})
|
|
152
152
|
|
|
153
153
|
test('tar contains correct file contents', () => {
|
|
@@ -205,7 +205,7 @@ describe('compressArchive', () => {
|
|
|
205
205
|
test('compressed archive can be decompressed to recover original tar', async () => {
|
|
206
206
|
const entries = [
|
|
207
207
|
{ path: 'facet.json', content: '{"name":"test","version":"1.0.0"}' },
|
|
208
|
-
{ path: 'skills/review.md', content: '# Review skill' },
|
|
208
|
+
{ path: 'skills/review/SKILL.md', content: '# Review skill' },
|
|
209
209
|
]
|
|
210
210
|
|
|
211
211
|
const tar = assembleTar(entries)
|
|
@@ -220,7 +220,7 @@ describe('compressArchive', () => {
|
|
|
220
220
|
|
|
221
221
|
const names = parsed.map((f) => f.name)
|
|
222
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')
|
|
223
|
+
expect(names).toContain('skills/review/SKILL.md')
|
|
224
|
+
expect(parsed.find((f) => f.name === 'skills/review/SKILL.md')?.text).toBe('# Review skill')
|
|
225
225
|
})
|
|
226
226
|
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { writeManifest } from '../edit/manifest-writer.ts'
|
|
6
|
+
import { reconcile } from '../edit/reconcile.ts'
|
|
7
|
+
import type { DiscoveredAsset } from '../edit/scanner.ts'
|
|
8
|
+
import { scanAssets } from '../edit/scanner.ts'
|
|
9
|
+
import type { FacetManifest } from '../schemas/facet-manifest.ts'
|
|
10
|
+
|
|
11
|
+
async function createFixtureDir(name: string): Promise<string> {
|
|
12
|
+
const dir = join(tmpdir(), `facets-edit-test-${name}-${Date.now()}`)
|
|
13
|
+
await mkdir(dir, { recursive: true })
|
|
14
|
+
return dir
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- Scanner ---
|
|
18
|
+
|
|
19
|
+
describe('scanAssets', () => {
|
|
20
|
+
test('discovers skills in directory convention', async () => {
|
|
21
|
+
const dir = await createFixtureDir('scan-skills')
|
|
22
|
+
await mkdir(join(dir, 'skills/review'), { recursive: true })
|
|
23
|
+
await mkdir(join(dir, 'skills/code-fix'), { recursive: true })
|
|
24
|
+
await Bun.write(join(dir, 'skills/review/SKILL.md'), '# Review')
|
|
25
|
+
await Bun.write(join(dir, 'skills/code-fix/SKILL.md'), '# Code Fix')
|
|
26
|
+
|
|
27
|
+
const assets = await scanAssets(dir)
|
|
28
|
+
expect(assets.filter((a) => a.type === 'skills')).toHaveLength(2)
|
|
29
|
+
expect(assets.find((a) => a.name === 'review')).toBeDefined()
|
|
30
|
+
expect(assets.find((a) => a.name === 'code-fix')).toBeDefined()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('discovers agents and commands in flat convention', async () => {
|
|
34
|
+
const dir = await createFixtureDir('scan-flat')
|
|
35
|
+
await mkdir(join(dir, 'agents'), { recursive: true })
|
|
36
|
+
await mkdir(join(dir, 'commands'), { recursive: true })
|
|
37
|
+
await Bun.write(join(dir, 'agents/reviewer.md'), '# Reviewer')
|
|
38
|
+
await Bun.write(join(dir, 'commands/deploy.md'), '# Deploy')
|
|
39
|
+
|
|
40
|
+
const assets = await scanAssets(dir)
|
|
41
|
+
expect(assets.find((a) => a.type === 'agents' && a.name === 'reviewer')).toBeDefined()
|
|
42
|
+
expect(assets.find((a) => a.type === 'commands' && a.name === 'deploy')).toBeDefined()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('skips non-kebab-case names', async () => {
|
|
46
|
+
const dir = await createFixtureDir('scan-skip')
|
|
47
|
+
await mkdir(join(dir, 'skills/InvalidName'), { recursive: true })
|
|
48
|
+
await mkdir(join(dir, 'agents'), { recursive: true })
|
|
49
|
+
await Bun.write(join(dir, 'skills/InvalidName/SKILL.md'), '# Bad')
|
|
50
|
+
await Bun.write(join(dir, 'agents/NOT_VALID.md'), '# Bad')
|
|
51
|
+
|
|
52
|
+
const assets = await scanAssets(dir)
|
|
53
|
+
expect(assets).toHaveLength(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('returns empty for directory with no assets', async () => {
|
|
57
|
+
const dir = await createFixtureDir('scan-empty')
|
|
58
|
+
const assets = await scanAssets(dir)
|
|
59
|
+
expect(assets).toHaveLength(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('returns sorted results', async () => {
|
|
63
|
+
const dir = await createFixtureDir('scan-sorted')
|
|
64
|
+
await mkdir(join(dir, 'skills/zebra'), { recursive: true })
|
|
65
|
+
await mkdir(join(dir, 'agents'), { recursive: true })
|
|
66
|
+
await mkdir(join(dir, 'skills/alpha'), { recursive: true })
|
|
67
|
+
await Bun.write(join(dir, 'skills/zebra/SKILL.md'), '# Z')
|
|
68
|
+
await Bun.write(join(dir, 'skills/alpha/SKILL.md'), '# A')
|
|
69
|
+
await Bun.write(join(dir, 'agents/beta.md'), '# B')
|
|
70
|
+
|
|
71
|
+
const assets = await scanAssets(dir)
|
|
72
|
+
const names = assets.map((a) => `${a.type}:${a.name}`)
|
|
73
|
+
expect(names).toEqual(['agents:beta', 'skills:alpha', 'skills:zebra'])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// --- Reconciliation ---
|
|
78
|
+
|
|
79
|
+
describe('reconcile', () => {
|
|
80
|
+
test('identifies additions (on disk, not in manifest)', () => {
|
|
81
|
+
const manifest: FacetManifest = {
|
|
82
|
+
name: 'test',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
skills: { review: { description: 'Review skill' } },
|
|
85
|
+
}
|
|
86
|
+
const discovered: DiscoveredAsset[] = [
|
|
87
|
+
{ type: 'skills', name: 'review', path: 'skills/review/SKILL.md' },
|
|
88
|
+
{ type: 'skills', name: 'new-skill', path: 'skills/new-skill/SKILL.md' },
|
|
89
|
+
{ type: 'agents', name: 'helper', path: 'agents/helper.md' },
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
const result = reconcile(manifest, discovered)
|
|
93
|
+
expect(result.additions).toHaveLength(2)
|
|
94
|
+
expect(result.additions.find((a) => a.name === 'new-skill')).toBeDefined()
|
|
95
|
+
expect(result.additions.find((a) => a.name === 'helper')).toBeDefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('identifies missing files (in manifest, not on disk)', () => {
|
|
99
|
+
const manifest: FacetManifest = {
|
|
100
|
+
name: 'test',
|
|
101
|
+
version: '1.0.0',
|
|
102
|
+
skills: {
|
|
103
|
+
review: { description: 'Review' },
|
|
104
|
+
gone: { description: 'Gone' },
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
const discovered: DiscoveredAsset[] = [{ type: 'skills', name: 'review', path: 'skills/review/SKILL.md' }]
|
|
108
|
+
|
|
109
|
+
const result = reconcile(manifest, discovered)
|
|
110
|
+
expect(result.missing).toHaveLength(1)
|
|
111
|
+
expect(result.missing[0]?.name).toBe('gone')
|
|
112
|
+
expect(result.missing[0]?.expectedPath).toBe('skills/gone/SKILL.md')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('identifies matched assets', () => {
|
|
116
|
+
const manifest: FacetManifest = {
|
|
117
|
+
name: 'test',
|
|
118
|
+
version: '1.0.0',
|
|
119
|
+
agents: { reviewer: { description: 'Review agent' } },
|
|
120
|
+
}
|
|
121
|
+
const discovered: DiscoveredAsset[] = [{ type: 'agents', name: 'reviewer', path: 'agents/reviewer.md' }]
|
|
122
|
+
|
|
123
|
+
const result = reconcile(manifest, discovered)
|
|
124
|
+
expect(result.matched).toHaveLength(1)
|
|
125
|
+
expect(result.matched[0]?.name).toBe('reviewer')
|
|
126
|
+
expect(result.additions).toHaveLength(0)
|
|
127
|
+
expect(result.missing).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('handles manifest with no assets', () => {
|
|
131
|
+
const manifest: FacetManifest = {
|
|
132
|
+
name: 'test',
|
|
133
|
+
version: '1.0.0',
|
|
134
|
+
facets: ['base@1.0.0'],
|
|
135
|
+
}
|
|
136
|
+
const discovered: DiscoveredAsset[] = [{ type: 'skills', name: 'extra', path: 'skills/extra/SKILL.md' }]
|
|
137
|
+
|
|
138
|
+
const result = reconcile(manifest, discovered)
|
|
139
|
+
expect(result.additions).toHaveLength(1)
|
|
140
|
+
expect(result.missing).toHaveLength(0)
|
|
141
|
+
expect(result.matched).toHaveLength(0)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('missing agent has flat file expected path', () => {
|
|
145
|
+
const manifest: FacetManifest = {
|
|
146
|
+
name: 'test',
|
|
147
|
+
version: '1.0.0',
|
|
148
|
+
agents: { gone: { description: 'Gone' } },
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const result = reconcile(manifest, [])
|
|
152
|
+
expect(result.missing[0]?.expectedPath).toBe('agents/gone.md')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// --- Manifest Writer ---
|
|
157
|
+
|
|
158
|
+
describe('writeManifest', () => {
|
|
159
|
+
test('writes valid JSON to facet.json', async () => {
|
|
160
|
+
const dir = await createFixtureDir('write-manifest')
|
|
161
|
+
const manifest: FacetManifest = {
|
|
162
|
+
name: 'test-facet',
|
|
163
|
+
version: '1.0.0',
|
|
164
|
+
skills: { review: { description: 'A review skill' } },
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await writeManifest(manifest, dir)
|
|
168
|
+
|
|
169
|
+
const content = await Bun.file(join(dir, 'facet.json')).text()
|
|
170
|
+
const parsed = JSON.parse(content)
|
|
171
|
+
expect(parsed.name).toBe('test-facet')
|
|
172
|
+
expect(parsed.version).toBe('1.0.0')
|
|
173
|
+
expect(parsed.skills.review.description).toBe('A review skill')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('output is 2-space indented', async () => {
|
|
177
|
+
const dir = await createFixtureDir('write-indent')
|
|
178
|
+
const manifest: FacetManifest = {
|
|
179
|
+
name: 'test',
|
|
180
|
+
version: '1.0.0',
|
|
181
|
+
skills: { x: { description: 'X' } },
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await writeManifest(manifest, dir)
|
|
185
|
+
|
|
186
|
+
const content = await Bun.file(join(dir, 'facet.json')).text()
|
|
187
|
+
// 2-space indent means lines like ' "name": "test"'
|
|
188
|
+
expect(content).toContain(' "name"')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -174,7 +174,7 @@ describe('loadManifest', () => {
|
|
|
174
174
|
describe('resolvePrompts', () => {
|
|
175
175
|
test('prompt content is resolved from conventional file paths', async () => {
|
|
176
176
|
const dir = await createFixtureDir('resolve-convention')
|
|
177
|
-
await writeFixture(dir, 'skills/review.md', '# Code Review\nReview all code.')
|
|
177
|
+
await writeFixture(dir, 'skills/review/SKILL.md', '# Code Review\nReview all code.')
|
|
178
178
|
await writeFixture(dir, 'agents/reviewer.md', '# Reviewer\nReview this code.')
|
|
179
179
|
await writeFixture(dir, 'commands/deploy.md', '# Deploy\nDeploy the code.')
|
|
180
180
|
|
|
@@ -242,7 +242,7 @@ describe('resolvePrompts', () => {
|
|
|
242
242
|
|
|
243
243
|
test('manifest without agents or commands resolves successfully', async () => {
|
|
244
244
|
const dir = await createFixtureDir('resolve-skills-only')
|
|
245
|
-
await writeFixture(dir, 'skills/x.md', '# Skill X\nDo x.')
|
|
245
|
+
await writeFixture(dir, 'skills/x/SKILL.md', '# Skill X\nDo x.')
|
|
246
246
|
|
|
247
247
|
const manifest = {
|
|
248
248
|
name: 'test',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import { type } from 'arktype'
|
|
3
|
-
import {
|
|
3
|
+
import { type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
|
|
4
4
|
|
|
5
5
|
// --- Valid manifests ---
|
|
6
6
|
|
|
@@ -88,9 +88,6 @@ describe('FacetManifestSchema — valid manifests', () => {
|
|
|
88
88
|
}
|
|
89
89
|
const result = FacetManifestSchema(input)
|
|
90
90
|
expect(result).not.toBeInstanceOf(type.errors)
|
|
91
|
-
const data = result as FacetManifest
|
|
92
|
-
const errors = checkFacetManifestConstraints(data)
|
|
93
|
-
expect(errors).toHaveLength(0)
|
|
94
91
|
})
|
|
95
92
|
})
|
|
96
93
|
|
|
@@ -137,38 +134,29 @@ describe('FacetManifestSchema — invalid manifests', () => {
|
|
|
137
134
|
const errors = result as InstanceType<typeof type.errors>
|
|
138
135
|
expect(errors.some((e) => e.path.includes('bad'))).toBe(true)
|
|
139
136
|
})
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
// --- Business-rule constraints ---
|
|
143
137
|
|
|
144
|
-
|
|
145
|
-
test('no text assets → error', () => {
|
|
138
|
+
test('no text assets → schema error', () => {
|
|
146
139
|
const input = {
|
|
147
140
|
name: 'empty',
|
|
148
141
|
version: '1.0.0',
|
|
149
142
|
servers: { jira: '1.0.0' },
|
|
150
143
|
}
|
|
151
144
|
const result = FacetManifestSchema(input)
|
|
152
|
-
expect(result).
|
|
153
|
-
const errors =
|
|
154
|
-
expect(errors).
|
|
155
|
-
const firstError = errors[0]
|
|
156
|
-
expect(firstError).toBeDefined()
|
|
157
|
-
expect(firstError?.message).toContain('at least one text asset')
|
|
145
|
+
expect(result).toBeInstanceOf(type.errors)
|
|
146
|
+
const errors = result as InstanceType<typeof type.errors>
|
|
147
|
+
expect(errors.some((e) => e.message.includes('at least one text asset'))).toBe(true)
|
|
158
148
|
})
|
|
159
149
|
|
|
160
|
-
test('selective facets entry with no asset selection → error', () => {
|
|
150
|
+
test('selective facets entry with no asset selection → schema error', () => {
|
|
161
151
|
const input = {
|
|
162
152
|
name: 'bad-selective',
|
|
163
153
|
version: '1.0.0',
|
|
164
154
|
facets: [{ name: 'other', version: '1.0.0' }],
|
|
165
155
|
}
|
|
166
156
|
const result = FacetManifestSchema(input)
|
|
167
|
-
expect(result).
|
|
168
|
-
const errors =
|
|
169
|
-
|
|
170
|
-
expect(selectiveError).toBeDefined()
|
|
171
|
-
expect(selectiveError?.path).toBe('facets[0]')
|
|
157
|
+
expect(result).toBeInstanceOf(type.errors)
|
|
158
|
+
const errors = result as InstanceType<typeof type.errors>
|
|
159
|
+
expect(errors.some((e) => e.message.includes('at least one asset type'))).toBe(true)
|
|
172
160
|
})
|
|
173
161
|
})
|
|
174
162
|
|
|
@@ -27,7 +27,7 @@ export function collectArchiveEntries(resolved: ResolvedFacetManifest, manifestC
|
|
|
27
27
|
|
|
28
28
|
if (resolved.skills) {
|
|
29
29
|
for (const [name, skill] of Object.entries(resolved.skills)) {
|
|
30
|
-
entries.push({ path: `skills/${name}.md`, content: skill.prompt })
|
|
30
|
+
entries.push({ path: `skills/${name}/SKILL.md`, content: skill.prompt })
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
package/src/build/pipeline.ts
CHANGED
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
computeContentHash,
|
|
10
10
|
} from './content-hash.ts'
|
|
11
11
|
import { detectNamingCollisions } from './detect-collisions.ts'
|
|
12
|
+
import { validateContentFiles } from './validate-content.ts'
|
|
12
13
|
import { validateCompactFacets } from './validate-facets.ts'
|
|
13
14
|
import { validatePlatformConfigs } from './validate-platforms.ts'
|
|
14
15
|
|
|
15
16
|
export interface BuildProgress {
|
|
16
|
-
stage:
|
|
17
|
+
stage: BuildStage
|
|
17
18
|
status: 'running' | 'done' | 'failed'
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -33,19 +34,33 @@ export interface BuildFailure {
|
|
|
33
34
|
warnings: string[]
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** Stage names emitted by the build pipeline via the onProgress callback. */
|
|
38
|
+
export const BUILD_STAGES = [
|
|
39
|
+
'Parsing manifest',
|
|
40
|
+
'Resolving prompts',
|
|
41
|
+
'Validating assets',
|
|
42
|
+
'Checking collisions',
|
|
43
|
+
'Validating platforms',
|
|
44
|
+
'Assembling archive',
|
|
45
|
+
'Writing output',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
export type BuildStage = (typeof BUILD_STAGES)[number]
|
|
49
|
+
|
|
36
50
|
/**
|
|
37
51
|
* Runs the full build pipeline:
|
|
38
|
-
* 1.
|
|
39
|
-
* 2. Resolve prompts — read prompt files at conventional paths
|
|
40
|
-
* 3. Validate
|
|
41
|
-
* 4.
|
|
42
|
-
* 5. Validate
|
|
43
|
-
* 6. Assemble archive — collect entries, compute
|
|
52
|
+
* 1. Parse manifest — read facet.json, parse JSON, validate schema, check constraints
|
|
53
|
+
* 2. Resolve prompts — read prompt files at conventional paths (also verifies files exist)
|
|
54
|
+
* 3. Validate content — no front matter, no empty files
|
|
55
|
+
* 4. Check collisions — fail if same name used within an asset type
|
|
56
|
+
* 5. Validate platforms — check known platform schemas, warn on unknown
|
|
57
|
+
* 6. Assemble archive — collect entries, compute hashes, build tar, compress
|
|
44
58
|
*
|
|
45
59
|
* Returns the resolved manifest and archive data on success, or collected errors on failure.
|
|
46
60
|
* Warnings are returned in both cases.
|
|
47
61
|
*
|
|
48
62
|
* An optional `onProgress` callback receives stage updates for UI display.
|
|
63
|
+
* The 'Writing output' stage is emitted by name but handled by the caller (BuildView).
|
|
49
64
|
*/
|
|
50
65
|
export async function runBuildPipeline(
|
|
51
66
|
rootDir: string,
|
|
@@ -53,46 +68,64 @@ export async function runBuildPipeline(
|
|
|
53
68
|
): Promise<BuildResult | BuildFailure> {
|
|
54
69
|
const warnings: string[] = []
|
|
55
70
|
|
|
56
|
-
// Stage 1:
|
|
57
|
-
onProgress?.({ stage: '
|
|
71
|
+
// Stage 1: Parse manifest
|
|
72
|
+
onProgress?.({ stage: 'Parsing manifest', status: 'running' })
|
|
58
73
|
|
|
59
74
|
const loadResult = await loadManifest(rootDir)
|
|
60
75
|
if (!loadResult.ok) {
|
|
61
|
-
onProgress?.({ stage: '
|
|
76
|
+
onProgress?.({ stage: 'Parsing manifest', status: 'failed' })
|
|
62
77
|
return { ok: false, errors: loadResult.errors, warnings }
|
|
63
78
|
}
|
|
64
79
|
const manifest = loadResult.data
|
|
65
80
|
|
|
81
|
+
onProgress?.({ stage: 'Parsing manifest', status: 'done' })
|
|
82
|
+
|
|
66
83
|
// Stage 2: Resolve prompts (also serves as file existence verification)
|
|
84
|
+
onProgress?.({ stage: 'Resolving prompts', status: 'running' })
|
|
85
|
+
|
|
67
86
|
const resolveResult = await resolvePrompts(manifest, rootDir)
|
|
68
87
|
if (!resolveResult.ok) {
|
|
69
|
-
onProgress?.({ stage: '
|
|
88
|
+
onProgress?.({ stage: 'Resolving prompts', status: 'failed' })
|
|
70
89
|
return { ok: false, errors: resolveResult.errors, warnings }
|
|
71
90
|
}
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
onProgress?.({ stage: 'Resolving prompts', status: 'done' })
|
|
93
|
+
|
|
94
|
+
// Stage 3: Validate assets (no front matter, no empty files)
|
|
95
|
+
onProgress?.({ stage: 'Validating assets', status: 'running' })
|
|
96
|
+
|
|
97
|
+
const contentErrors = validateContentFiles(resolveResult.data)
|
|
98
|
+
if (contentErrors.length > 0) {
|
|
99
|
+
onProgress?.({ stage: 'Validating assets', status: 'failed' })
|
|
100
|
+
return { ok: false, errors: contentErrors, warnings }
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
|
|
103
|
+
onProgress?.({ stage: 'Validating assets', status: 'done' })
|
|
104
|
+
|
|
105
|
+
// Stage 4: Check naming collisions
|
|
106
|
+
onProgress?.({ stage: 'Checking collisions', status: 'running' })
|
|
107
|
+
|
|
81
108
|
const collisionErrors = detectNamingCollisions(manifest)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
const facetsErrors = validateCompactFacets(manifest)
|
|
110
|
+
const checkErrors = [...collisionErrors, ...facetsErrors]
|
|
111
|
+
if (checkErrors.length > 0) {
|
|
112
|
+
onProgress?.({ stage: 'Checking collisions', status: 'failed' })
|
|
113
|
+
return { ok: false, errors: checkErrors, warnings }
|
|
85
114
|
}
|
|
86
115
|
|
|
116
|
+
onProgress?.({ stage: 'Checking collisions', status: 'done' })
|
|
117
|
+
|
|
87
118
|
// Stage 5: Validate platform config
|
|
119
|
+
onProgress?.({ stage: 'Validating platforms', status: 'running' })
|
|
120
|
+
|
|
88
121
|
const platformResult = validatePlatformConfigs(manifest)
|
|
89
122
|
if (platformResult.errors.length > 0) {
|
|
90
|
-
onProgress?.({ stage: 'Validating
|
|
123
|
+
onProgress?.({ stage: 'Validating platforms', status: 'failed' })
|
|
91
124
|
return { ok: false, errors: platformResult.errors, warnings: [...warnings, ...platformResult.warnings] }
|
|
92
125
|
}
|
|
93
126
|
warnings.push(...platformResult.warnings)
|
|
94
127
|
|
|
95
|
-
onProgress?.({ stage: 'Validating
|
|
128
|
+
onProgress?.({ stage: 'Validating platforms', status: 'done' })
|
|
96
129
|
|
|
97
130
|
// Stage 6: Assemble archive, compute content hashes, and compress for delivery
|
|
98
131
|
onProgress?.({ stage: 'Assembling archive', status: 'running' })
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { hasFrontMatter } from '../front-matter.ts'
|
|
2
|
+
import type { ResolvedFacetManifest } from '../loaders/facet.ts'
|
|
3
|
+
import type { ValidationError } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validates resolved prompt content for all assets:
|
|
7
|
+
* - No YAML front matter (manifest is the single source of truth for metadata)
|
|
8
|
+
* - No empty files (zero bytes or whitespace only)
|
|
9
|
+
*
|
|
10
|
+
* Returns an array of validation errors, one per offending file.
|
|
11
|
+
*/
|
|
12
|
+
export function validateContentFiles(resolved: ResolvedFacetManifest): ValidationError[] {
|
|
13
|
+
const errors: ValidationError[] = []
|
|
14
|
+
|
|
15
|
+
const assetTypes = [
|
|
16
|
+
{ type: 'skills', assets: resolved.skills },
|
|
17
|
+
{ type: 'agents', assets: resolved.agents },
|
|
18
|
+
{ type: 'commands', assets: resolved.commands },
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
for (const { type, assets } of assetTypes) {
|
|
22
|
+
if (!assets) continue
|
|
23
|
+
for (const [name, asset] of Object.entries(assets)) {
|
|
24
|
+
const relativePath = type === 'skills' ? `skills/${name}/SKILL.md` : `${type}/${name}.md`
|
|
25
|
+
|
|
26
|
+
// Check for empty content
|
|
27
|
+
if (asset.prompt.trim().length === 0) {
|
|
28
|
+
errors.push({
|
|
29
|
+
path: `${type}.${name}`,
|
|
30
|
+
message: `File is empty: ${relativePath}. Content files must contain prompt content.`,
|
|
31
|
+
expected: 'non-empty content',
|
|
32
|
+
actual: 'empty or whitespace only',
|
|
33
|
+
})
|
|
34
|
+
continue // Skip front matter check on empty files
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check for YAML front matter
|
|
38
|
+
if (hasFrontMatter(asset.prompt)) {
|
|
39
|
+
errors.push({
|
|
40
|
+
path: `${type}.${name}`,
|
|
41
|
+
message: `File contains YAML front matter: ${relativePath}. The manifest is the source of truth for metadata — use \`facet edit\` to reconcile.`,
|
|
42
|
+
expected: 'no front matter',
|
|
43
|
+
actual: 'front matter detected',
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return errors
|
|
50
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { FACET_MANIFEST_FILE } from '../loaders/facet.ts'
|
|
3
|
+
import type { FacetManifest } from '../schemas/facet-manifest.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Writes a facet manifest to disk as `facet.json`.
|
|
7
|
+
* Uses `JSON.stringify(data, null, 2)` per ADR-006.
|
|
8
|
+
*/
|
|
9
|
+
export async function writeManifest(manifest: FacetManifest, rootDir: string): Promise<void> {
|
|
10
|
+
const path = join(rootDir, FACET_MANIFEST_FILE)
|
|
11
|
+
await Bun.write(path, JSON.stringify(manifest, null, 2))
|
|
12
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FacetManifest } from '../schemas/facet-manifest.ts'
|
|
2
|
+
import type { AssetType, DiscoveredAsset } from './scanner.ts'
|
|
3
|
+
|
|
4
|
+
export interface MissingAsset {
|
|
5
|
+
type: AssetType
|
|
6
|
+
name: string
|
|
7
|
+
/** The path where the file was expected (e.g., 'skills/review/SKILL.md') */
|
|
8
|
+
expectedPath: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MatchedAsset {
|
|
12
|
+
type: AssetType
|
|
13
|
+
name: string
|
|
14
|
+
/** Relative path from the facet root */
|
|
15
|
+
path: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReconciliationResult {
|
|
19
|
+
/** Assets found on disk but not in the manifest */
|
|
20
|
+
additions: DiscoveredAsset[]
|
|
21
|
+
/** Assets in the manifest but missing from disk */
|
|
22
|
+
missing: MissingAsset[]
|
|
23
|
+
/** Assets that exist in both the manifest and on disk */
|
|
24
|
+
matched: MatchedAsset[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compares discovered assets on disk against manifest entries.
|
|
29
|
+
* Produces lists of additions (on disk, not in manifest),
|
|
30
|
+
* missing files (in manifest, not on disk), and matched assets.
|
|
31
|
+
*/
|
|
32
|
+
export function reconcile(manifest: FacetManifest, discovered: DiscoveredAsset[]): ReconciliationResult {
|
|
33
|
+
const additions: DiscoveredAsset[] = []
|
|
34
|
+
const missing: MissingAsset[] = []
|
|
35
|
+
const matched: MatchedAsset[] = []
|
|
36
|
+
|
|
37
|
+
// Build a set of discovered assets keyed by type:name
|
|
38
|
+
const discoveredMap = new Map<string, DiscoveredAsset>()
|
|
39
|
+
for (const asset of discovered) {
|
|
40
|
+
discoveredMap.set(`${asset.type}:${asset.name}`, asset)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check manifest entries against discovered assets
|
|
44
|
+
const assetSections: Array<{ type: AssetType; entries: Record<string, unknown> | undefined }> = [
|
|
45
|
+
{ type: 'skills', entries: manifest.skills },
|
|
46
|
+
{ type: 'agents', entries: manifest.agents },
|
|
47
|
+
{ type: 'commands', entries: manifest.commands },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const manifestKeys = new Set<string>()
|
|
51
|
+
|
|
52
|
+
for (const { type, entries } of assetSections) {
|
|
53
|
+
if (!entries) continue
|
|
54
|
+
for (const name of Object.keys(entries)) {
|
|
55
|
+
const key = `${type}:${name}`
|
|
56
|
+
manifestKeys.add(key)
|
|
57
|
+
|
|
58
|
+
const onDisk = discoveredMap.get(key)
|
|
59
|
+
if (onDisk) {
|
|
60
|
+
matched.push({ type, name, path: onDisk.path })
|
|
61
|
+
} else {
|
|
62
|
+
const expectedPath = type === 'skills' ? `skills/${name}/SKILL.md` : `${type}/${name}.md`
|
|
63
|
+
missing.push({ type, name, expectedPath })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check discovered assets not in manifest
|
|
69
|
+
for (const asset of discovered) {
|
|
70
|
+
const key = `${asset.type}:${asset.name}`
|
|
71
|
+
if (!manifestKeys.has(key)) {
|
|
72
|
+
additions.push(asset)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { additions, missing, matched }
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Glob } from 'bun'
|
|
2
|
+
|
|
3
|
+
/** Kebab-case pattern for valid asset names. */
|
|
4
|
+
export const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
|
|
5
|
+
|
|
6
|
+
export type AssetType = 'skills' | 'agents' | 'commands'
|
|
7
|
+
|
|
8
|
+
export interface DiscoveredAsset {
|
|
9
|
+
type: AssetType
|
|
10
|
+
name: string
|
|
11
|
+
/** Relative path from the facet root (e.g., 'skills/review/SKILL.md') */
|
|
12
|
+
path: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scans a facet directory for content files and returns discovered assets.
|
|
17
|
+
*
|
|
18
|
+
* Skills use the directory convention: `skills/<name>/SKILL.md`
|
|
19
|
+
* Agents use the flat convention: `agents/<name>.md`
|
|
20
|
+
* Commands use the flat convention: `commands/<name>.md`
|
|
21
|
+
*
|
|
22
|
+
* Only assets with valid kebab-case names are returned.
|
|
23
|
+
*/
|
|
24
|
+
export async function scanAssets(rootDir: string): Promise<DiscoveredAsset[]> {
|
|
25
|
+
const assets: DiscoveredAsset[] = []
|
|
26
|
+
|
|
27
|
+
// Scan skills: skills/*/SKILL.md
|
|
28
|
+
const skillGlob = new Glob('skills/*/SKILL.md')
|
|
29
|
+
for await (const match of skillGlob.scan({ cwd: rootDir, onlyFiles: true })) {
|
|
30
|
+
// match is e.g. 'skills/review/SKILL.md'
|
|
31
|
+
const parts = match.split('/')
|
|
32
|
+
const name = parts[1]
|
|
33
|
+
if (name && KEBAB_CASE.test(name)) {
|
|
34
|
+
assets.push({ type: 'skills', name, path: match })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Scan agents: agents/*.md
|
|
39
|
+
const agentGlob = new Glob('agents/*.md')
|
|
40
|
+
for await (const match of agentGlob.scan({ cwd: rootDir, onlyFiles: true })) {
|
|
41
|
+
const name = match.replace('agents/', '').replace('.md', '')
|
|
42
|
+
if (KEBAB_CASE.test(name)) {
|
|
43
|
+
assets.push({ type: 'agents', name, path: match })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Scan commands: commands/*.md
|
|
48
|
+
const commandGlob = new Glob('commands/*.md')
|
|
49
|
+
for await (const match of commandGlob.scan({ cwd: rootDir, onlyFiles: true })) {
|
|
50
|
+
const name = match.replace('commands/', '').replace('.md', '')
|
|
51
|
+
if (KEBAB_CASE.test(name)) {
|
|
52
|
+
assets.push({ type: 'commands', name, path: match })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return assets.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name))
|
|
57
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parse as parseYaml } from 'yaml'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Matches YAML front matter: opening `---`, YAML content, closing `---`.
|
|
5
|
+
* Anchored to start of string. Handles optional trailing content.
|
|
6
|
+
*/
|
|
7
|
+
const FRONT_MATTER_RE = /^---\n([\s\S]*?)\n---(?:\n([\s\S]*))?$/
|
|
8
|
+
|
|
9
|
+
/** Matches empty front matter: `---\n---` with optional trailing content. */
|
|
10
|
+
const EMPTY_FRONT_MATTER_RE = /^---\n---(?:\n([\s\S]*))?$/
|
|
11
|
+
|
|
12
|
+
/** Normalize BOM and line endings to LF. */
|
|
13
|
+
function normalize(raw: string): string {
|
|
14
|
+
return raw
|
|
15
|
+
.replace(/^\uFEFF/, '')
|
|
16
|
+
.replace(/\r\n/g, '\n')
|
|
17
|
+
.replace(/\r/g, '\n')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Returns true if the string contains YAML front matter. */
|
|
21
|
+
export function hasFrontMatter(raw: string): boolean {
|
|
22
|
+
const input = normalize(raw)
|
|
23
|
+
return FRONT_MATTER_RE.test(input) || EMPTY_FRONT_MATTER_RE.test(input)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FrontMatterResult<T = Record<string, unknown>> {
|
|
27
|
+
/** Parsed YAML attributes. Empty object if no front matter or parse failure. */
|
|
28
|
+
data: T
|
|
29
|
+
/** Markdown body with front matter stripped. Original content if no front matter. */
|
|
30
|
+
content: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extracts YAML front matter attributes and clean body from a string.
|
|
35
|
+
* Returns the original content unchanged if no front matter is found.
|
|
36
|
+
* Treats YAML parse failures as "no front matter" (returns empty data + original content).
|
|
37
|
+
*/
|
|
38
|
+
export function extractFrontMatter<T = Record<string, unknown>>(raw: string): FrontMatterResult<T> {
|
|
39
|
+
const input = normalize(raw)
|
|
40
|
+
|
|
41
|
+
const emptyMatch = input.match(EMPTY_FRONT_MATTER_RE)
|
|
42
|
+
if (emptyMatch) {
|
|
43
|
+
return { data: {} as T, content: emptyMatch[1] ?? '' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const match = input.match(FRONT_MATTER_RE)
|
|
47
|
+
if (!match) {
|
|
48
|
+
return { data: {} as T, content: raw }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const yaml = match[1] ?? ''
|
|
53
|
+
return { data: (parseYaml(yaml) ?? {}) as T, content: match[2] ?? '' }
|
|
54
|
+
} catch {
|
|
55
|
+
// Malformed YAML — treat as no front matter
|
|
56
|
+
return { data: {} as T, content: raw }
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// types
|
|
2
|
-
|
|
3
2
|
export type { ArchiveEntry } from './build/content-hash.ts'
|
|
4
3
|
export {
|
|
5
4
|
assembleTar,
|
|
@@ -9,13 +8,22 @@ export {
|
|
|
9
8
|
computeContentHash,
|
|
10
9
|
} from './build/content-hash.ts'
|
|
11
10
|
export { detectNamingCollisions } from './build/detect-collisions.ts'
|
|
12
|
-
export type { BuildFailure, BuildProgress, BuildResult } from './build/pipeline.ts'
|
|
11
|
+
export type { BuildFailure, BuildProgress, BuildResult, BuildStage } from './build/pipeline.ts'
|
|
13
12
|
// build pipeline
|
|
14
|
-
export { runBuildPipeline } from './build/pipeline.ts'
|
|
13
|
+
export { BUILD_STAGES, runBuildPipeline } from './build/pipeline.ts'
|
|
15
14
|
export { validateCompactFacets } from './build/validate-facets.ts'
|
|
16
15
|
export type { PlatformValidationResult } from './build/validate-platforms.ts'
|
|
17
16
|
export { validatePlatformConfigs } from './build/validate-platforms.ts'
|
|
18
17
|
export { writeBuildOutput } from './build/write-output.ts'
|
|
18
|
+
export { writeManifest } from './edit/manifest-writer.ts'
|
|
19
|
+
export type { MatchedAsset, MissingAsset, ReconciliationResult } from './edit/reconcile.ts'
|
|
20
|
+
export { reconcile } from './edit/reconcile.ts'
|
|
21
|
+
// edit
|
|
22
|
+
export type { AssetType, DiscoveredAsset } from './edit/scanner.ts'
|
|
23
|
+
export { KEBAB_CASE, scanAssets } from './edit/scanner.ts'
|
|
24
|
+
// front matter
|
|
25
|
+
export type { FrontMatterResult } from './front-matter.ts'
|
|
26
|
+
export { extractFrontMatter, hasFrontMatter } from './front-matter.ts'
|
|
19
27
|
export type { ResolvedFacetManifest } from './loaders/facet.ts'
|
|
20
28
|
// loaders
|
|
21
29
|
export { FACET_MANIFEST_FILE, loadManifest, resolvePrompts } from './loaders/facet.ts'
|
|
@@ -24,10 +32,7 @@ export type { BuildManifest } from './schemas/build-manifest.ts'
|
|
|
24
32
|
export { BuildManifestSchema } from './schemas/build-manifest.ts'
|
|
25
33
|
export type { FacetManifest } from './schemas/facet-manifest.ts'
|
|
26
34
|
// schemas
|
|
27
|
-
export {
|
|
28
|
-
checkFacetManifestConstraints,
|
|
29
|
-
FacetManifestSchema,
|
|
30
|
-
} from './schemas/facet-manifest.ts'
|
|
35
|
+
export { FacetManifestSchema } from './schemas/facet-manifest.ts'
|
|
31
36
|
export type { Lockfile } from './schemas/lockfile.ts'
|
|
32
37
|
export { LockfileSchema } from './schemas/lockfile.ts'
|
|
33
38
|
export type { ServerManifest } from './schemas/server-manifest.ts'
|
package/src/loaders/facet.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { type } from 'arktype'
|
|
3
|
-
import {
|
|
3
|
+
import { type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
|
|
4
4
|
import type { Result, ValidationError } from '../types.ts'
|
|
5
5
|
import { mapArkErrors, parseJson, readFile } from './validate.ts'
|
|
6
6
|
|
|
@@ -34,18 +34,13 @@ export async function loadManifest(dir: string): Promise<Result<FacetManifest>>
|
|
|
34
34
|
return { ok: false, errors: mapArkErrors(validated) }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// Phase 3: Business-rule constraints
|
|
38
|
-
const constraintErrors = checkFacetManifestConstraints(validated)
|
|
39
|
-
if (constraintErrors.length > 0) {
|
|
40
|
-
return { ok: false, errors: constraintErrors }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
37
|
return { ok: true, data: validated }
|
|
44
38
|
}
|
|
45
39
|
|
|
46
40
|
/**
|
|
47
41
|
* A manifest with all prompts resolved to their string content.
|
|
48
|
-
* File paths are derived from convention:
|
|
42
|
+
* File paths are derived from convention: skills use `skills/<name>/SKILL.md`,
|
|
43
|
+
* agents use `agents/<name>.md`, commands use `commands/<name>.md`.
|
|
49
44
|
*/
|
|
50
45
|
export interface ResolvedFacetManifest {
|
|
51
46
|
name: string
|
|
@@ -83,8 +78,9 @@ export interface ResolvedFacetManifest {
|
|
|
83
78
|
* Resolves prompt content for all skills, agents, and commands by reading
|
|
84
79
|
* files at conventional paths relative to the facet root directory.
|
|
85
80
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
81
|
+
* Skills use the Agent Skills directory convention: a skill named "code-review"
|
|
82
|
+
* resolves to `skills/code-review/SKILL.md`. Agents and commands use the flat
|
|
83
|
+
* file convention: `agents/<name>.md` and `commands/<name>.md`.
|
|
88
84
|
*
|
|
89
85
|
* This also serves as file existence verification for all three asset types —
|
|
90
86
|
* if an expected file doesn't exist, resolution fails with an error identifying
|
|
@@ -96,7 +92,7 @@ export interface ResolvedFacetManifest {
|
|
|
96
92
|
export async function resolvePrompts(manifest: FacetManifest, rootDir: string): Promise<Result<ResolvedFacetManifest>> {
|
|
97
93
|
const errors: ValidationError[] = []
|
|
98
94
|
|
|
99
|
-
// Resolve skill prompts from skills/<name
|
|
95
|
+
// Resolve skill prompts from skills/<name>/SKILL.md
|
|
100
96
|
let resolvedSkills: ResolvedFacetManifest['skills'] | undefined
|
|
101
97
|
if (manifest.skills) {
|
|
102
98
|
resolvedSkills = {}
|
|
@@ -158,11 +154,13 @@ export async function resolvePrompts(manifest: FacetManifest, rootDir: string):
|
|
|
158
154
|
}
|
|
159
155
|
|
|
160
156
|
/**
|
|
161
|
-
* Resolves prompt content for a single asset by reading
|
|
157
|
+
* Resolves prompt content for a single asset by reading the file at its
|
|
158
|
+
* conventional path. Skills use `skills/<name>/SKILL.md` (Agent Skills
|
|
159
|
+
* directory convention). Agents and commands use `<type>/<name>.md`.
|
|
162
160
|
* Returns the file content as a string, or a ValidationError if the file doesn't exist.
|
|
163
161
|
*/
|
|
164
162
|
async function resolveAssetPrompt(assetType: string, name: string, rootDir: string): Promise<string | ValidationError> {
|
|
165
|
-
const relativePath = `${assetType}/${name}.md`
|
|
163
|
+
const relativePath = assetType === 'skills' ? `${assetType}/${name}/SKILL.md` : `${assetType}/${name}.md`
|
|
166
164
|
const filePath = join(rootDir, relativePath)
|
|
167
165
|
const file = Bun.file(filePath)
|
|
168
166
|
const exists = await file.exists()
|
package/src/loaders/validate.ts
CHANGED
|
@@ -8,9 +8,11 @@ import type { ValidationError } from '../types.ts'
|
|
|
8
8
|
export function mapArkErrors(errors: InstanceType<typeof type.errors>): ValidationError[] {
|
|
9
9
|
return errors.map((err) => ({
|
|
10
10
|
path: err.path.join('.'),
|
|
11
|
-
|
|
11
|
+
// For predicate errors (.narrow()), err.message includes the full data object.
|
|
12
|
+
// Use err.expected directly — it's our clean sentence from ctx.mustBe().
|
|
13
|
+
message: err.code === 'predicate' ? (err.expected ?? err.message) : err.message,
|
|
12
14
|
expected: err.expected ?? 'unknown',
|
|
13
|
-
actual: err.actual ?? 'unknown',
|
|
15
|
+
actual: err.code === 'predicate' ? 'constraint not met' : (err.actual ?? 'unknown'),
|
|
14
16
|
}))
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -2,7 +2,7 @@ import { type } from 'arktype'
|
|
|
2
2
|
|
|
3
3
|
// --- Sub-schemas ---
|
|
4
4
|
|
|
5
|
-
/** Skill descriptor — description is required, prompt resolved from skills/<name
|
|
5
|
+
/** Skill descriptor — description is required, prompt resolved from skills/<name>/SKILL.md */
|
|
6
6
|
const SkillDescriptor = type({
|
|
7
7
|
description: 'string',
|
|
8
8
|
'platforms?': type.Record('string', 'unknown'),
|
|
@@ -37,9 +37,11 @@ const ServerReference = type('string').or({ image: 'string' })
|
|
|
37
37
|
// --- Main schema ---
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* The
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* The facet manifest schema — validates structure and business constraints.
|
|
41
|
+
*
|
|
42
|
+
* Structural validation covers field types and shapes. Narrow constraints enforce:
|
|
43
|
+
* 1. At least one text asset (skills, agents, commands, or facets) must be present
|
|
44
|
+
* 2. Selective facets entries must include at least one asset type selection
|
|
43
45
|
*/
|
|
44
46
|
export const FacetManifestSchema = type({
|
|
45
47
|
name: 'string',
|
|
@@ -51,63 +53,35 @@ export const FacetManifestSchema = type({
|
|
|
51
53
|
'commands?': type.Record('string', CommandDescriptor),
|
|
52
54
|
'facets?': FacetsEntry.array(),
|
|
53
55
|
'servers?': type.Record('string', ServerReference),
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
/** Inferred TypeScript type for a validated facet manifest */
|
|
57
|
-
export type FacetManifest = typeof FacetManifestSchema.infer
|
|
58
|
-
|
|
59
|
-
// --- Custom validation ---
|
|
60
|
-
|
|
61
|
-
export interface FacetManifestError {
|
|
62
|
-
path: string
|
|
63
|
-
message: string
|
|
64
|
-
expected: string
|
|
65
|
-
actual: string
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Checks business-rule constraints that ArkType's structural validation cannot express:
|
|
70
|
-
* 1. At least one text asset must be present (skills, agents, commands, or facets)
|
|
71
|
-
* 2. Selective facets entries must include at least one asset type
|
|
72
|
-
*/
|
|
73
|
-
export function checkFacetManifestConstraints(manifest: FacetManifest): FacetManifestError[] {
|
|
74
|
-
const errors: FacetManifestError[] = []
|
|
75
|
-
|
|
56
|
+
}).narrow((data, ctx) => {
|
|
76
57
|
// Constraint 1: at least one text asset
|
|
77
|
-
const hasSkills =
|
|
78
|
-
const hasAgents =
|
|
79
|
-
const hasCommands =
|
|
80
|
-
const hasFacets =
|
|
58
|
+
const hasSkills = data.skills && Object.keys(data.skills).length > 0
|
|
59
|
+
const hasAgents = data.agents && Object.keys(data.agents).length > 0
|
|
60
|
+
const hasCommands = data.commands && Object.keys(data.commands).length > 0
|
|
61
|
+
const hasFacets = data.facets && data.facets.length > 0
|
|
81
62
|
|
|
82
63
|
if (!hasSkills && !hasAgents && !hasCommands && !hasFacets) {
|
|
83
|
-
|
|
84
|
-
path: '',
|
|
85
|
-
message: 'Manifest must include at least one text asset (skills, agents, commands, or facets)',
|
|
86
|
-
expected: 'at least one of: skills, agents, commands, facets',
|
|
87
|
-
actual: 'none present',
|
|
88
|
-
})
|
|
64
|
+
ctx.mustBe('Manifest must include at least one text asset (skills, agents, commands, or facets)')
|
|
89
65
|
}
|
|
90
66
|
|
|
91
67
|
// Constraint 2: selective facets entries must select at least one asset type
|
|
92
|
-
if (
|
|
93
|
-
for (let i = 0; i <
|
|
94
|
-
const entry =
|
|
68
|
+
if (data.facets) {
|
|
69
|
+
for (let i = 0; i < data.facets.length; i++) {
|
|
70
|
+
const entry = data.facets[i]
|
|
95
71
|
if (typeof entry === 'object') {
|
|
96
72
|
const hasSelectedSkills = entry.skills && entry.skills.length > 0
|
|
97
73
|
const hasSelectedAgents = entry.agents && entry.agents.length > 0
|
|
98
74
|
const hasSelectedCommands = entry.commands && entry.commands.length > 0
|
|
99
75
|
|
|
100
76
|
if (!hasSelectedSkills && !hasSelectedAgents && !hasSelectedCommands) {
|
|
101
|
-
|
|
102
|
-
path: `facets[${i}]`,
|
|
103
|
-
message: 'Selective facets entry must include at least one asset type (skills, agents, or commands)',
|
|
104
|
-
expected: 'at least one of: skills, agents, commands',
|
|
105
|
-
actual: 'none selected',
|
|
106
|
-
})
|
|
77
|
+
ctx.mustBe('Selective facets entry must include at least one asset type (skills, agents, or commands)')
|
|
107
78
|
}
|
|
108
79
|
}
|
|
109
80
|
}
|
|
110
81
|
}
|
|
111
82
|
|
|
112
|
-
return
|
|
113
|
-
}
|
|
83
|
+
return true
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/** Inferred TypeScript type for a validated facet manifest */
|
|
87
|
+
export type FacetManifest = typeof FacetManifestSchema.infer
|