feat(registry): 插件与应用关系契约4.2 / plugin and application contract (#10)

* feat(registry): 增加插件贡献与应用依赖的4.2契约

* fix(registry): preserve legacy App manifests for published clients

---------

Co-authored-by: yi-ge <6657330+yi-ge@users.noreply.github.com>
This commit is contained in:
2026-10-07 19:08:18 -07:00
committed by GitHub
parent 33f3ae4a8a
commit 3828aa6800
34 changed files with 3683 additions and 285 deletions

View File

@@ -0,0 +1,20 @@
/** 发布者关系声明的跨字段检查;不推导安装、权限或运行时健康。 */
export function validateApplicationContract(manifest) {
if (!['docker-app', 'native-app', 'artifact'].includes(manifest.type)) return []
const errors = []
const ids = new Set()
for (const entrypoint of Array.isArray(manifest.entrypoints) ? manifest.entrypoints : []) {
if (!entrypoint || typeof entrypoint !== 'object') continue
if (ids.has(entrypoint.id)) errors.push('entrypoints: duplicate entrypoint id ' + entrypoint.id)
ids.add(entrypoint.id)
if (entrypoint.kind === 'builtin') errors.push('entrypoints: third-party Registry cannot declare builtin routes')
if (/^(?:legacy|native)-port:/.test(entrypoint.serviceRef ?? '')) errors.push('entrypoints: reserved port references are minted only by the trusted client adapter')
}
ids.clear()
for (const contribution of Array.isArray(manifest.extension?.contributes) ? manifest.extension.contributes : []) {
if (!contribution || typeof contribution !== 'object') continue
if (ids.has(contribution.id)) errors.push('extension.contributes: duplicate contribution id ' + contribution.id)
ids.add(contribution.id)
}
return errors
}

View File

@@ -9,9 +9,9 @@ test('metrics are rejected without interpreting examples or configuration parame
assert.deepEqual(findStaticMetrics({ description: 'downloads files', toolCount: 13, configSchema: { properties: { rating: { type: 'number' } } } }), [])
})
test('Registry 4.1 keeps the exact published client schema identity', () => {
test('Registry 4.2 keeps the exact reviewed client schema identity', () => {
const version = readFileSync(new URL('../../SCHEMA_VERSION', import.meta.url), 'utf8').trim()
assert.equal(version, '4.1.0')
assert.equal(version, '4.2.0')
const bytes = readFileSync(new URL('../../schemas/registry-entry.schema.json', import.meta.url))
assert.equal(createHash('sha256').update(bytes).digest('hex'), '767e103a66b4f8f722d1eb4d9073c218536e8f551ef43ac766dcd1be8f88fc62')
assert.equal(createHash('sha256').update(bytes).digest('hex'), 'ea63d6305ae17994e595aea009a131c41300d2b7fdb07ec83cc2c19c37b32038')
})

View File

@@ -18,7 +18,7 @@ if (json) {
} else {
const { counts } = report
process.stdout.write(`Registry 校验:${report.ok ? '通过' : '失败'}\n`)
process.stdout.write(`条目 ${counts.totalEntries}(App ${counts.dockerApps + (counts.nativeApps ?? 0)}(原生 ${counts.nativeApps ?? 0}) / MCP ${counts.mcpServices} / HTTP ${counts.httpApis})\n`)
process.stdout.write(`条目 ${counts.totalEntries}(App ${counts.dockerApps + (counts.nativeApps ?? 0) + (counts.artifactApps ?? 0)}(原生 ${counts.nativeApps ?? 0} / 声明制品 ${counts.artifactApps ?? 0}) / MCP ${counts.mcpServices} / HTTP ${counts.httpApis})\n`)
process.stdout.write(`Catalog sidecar ${counts.sidecars},legacy fallback ${counts.legacyOnly}\n`)
for (const item of report.diagnostics) {
process.stdout.write(`${item.level === 'error' ? 'ERROR' : 'WARN '} ${item.file} ${item.path} [${item.code}] ${item.message}\n`)

View File

@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from 'node:util'
import { basename, join, relative, resolve, sep } from 'node:path'
import { validateJsonSchema } from './json-schema.mjs'
import { findStaticMetrics } from './static-metadata.mjs'
import { validateApplicationContract } from './application-contract.mjs'
export const CATALOG_SIDECAR_FILENAME = 'catalog-metadata.v1.json'
@@ -76,7 +77,7 @@ function timestampShapeIsConsistent(timestamp) {
function validateSidecarSemantics(manifest, sidecar, file) {
const errors = []
const add = (code, path, message) => errors.push(diagnostic('error', code, file, path, message))
const expectedKind = ['docker-app', 'native-app'].includes(manifest.type) ? 'app' : 'service'
const expectedKind = ['docker-app', 'native-app', 'artifact'].includes(manifest.type) ? 'app' : 'service'
if (sidecar.identity?.id !== manifest.id) add('identity-mismatch', '$.identity.id', 'sidecar identity.id 必须与 legacy manifest.id 一致')
if (sidecar.identity?.kind !== expectedKind) add('kind-mismatch', '$.identity.kind', `legacy ${manifest.type} 必须映射为 ${expectedKind}`)
@@ -128,6 +129,10 @@ function validateSidecarSemantics(manifest, sidecar, file) {
if (expectedKind === 'app') {
if (sidecar.spec?.category !== manifest.category) add('spec-mismatch', '$.spec.category', 'App category 必须与 legacy category 一致')
if (sidecar.spec?.productKind !== undefined && sidecar.spec.productKind !== (manifest.productKind ?? 'application')) add('product-kind-mismatch', '$.spec.productKind', '产品归属必须与 manifest 一致')
if (sidecar.spec?.extension !== undefined && !isDeepStrictEqual(sidecar.spec.extension, manifest.extension)) add('extension-mismatch', '$.spec.extension', '宿主贡献与直接依赖必须与 manifest 一致')
const entrypointKinds = [...new Set((Array.isArray(manifest.entrypoints) ? manifest.entrypoints : []).map(entry => entry?.kind))]
if (sidecar.spec?.entrypointKinds !== undefined && !sameStringSet(sidecar.spec.entrypointKinds, entrypointKinds)) add('entrypoint-mismatch', '$.spec.entrypointKinds', '入口类型摘要必须与 manifest 一致')
} else {
const expectedProtocol = manifest.type === 'mcp' ? 'mcp' : 'http'
if (sidecar.spec?.protocol !== expectedProtocol) add('spec-mismatch', '$.spec.protocol', `Service protocol 必须是 ${expectedProtocol}`)
@@ -135,7 +140,7 @@ function validateSidecarSemantics(manifest, sidecar, file) {
if (manifest.toolCount !== undefined && sidecar.spec?.toolCount !== manifest.toolCount) add('spec-mismatch', '$.spec.toolCount', 'toolCount 必须与 legacy 一致')
}
if (manifest.type === 'native-app') {
if (['native-app', 'artifact'].includes(manifest.type)) {
for (const field of ['stewardship', 'availability', 'redistribution', 'listingMaintainer', 'upstreamMaintainer', 'branding', 'compliance']) {
if (!isDeepStrictEqual(manifest[field], sidecar.governance?.[field])) add('native-governance-mismatch', '$.governance.' + field, '原生应用 manifest 与 sidecar 的治理事实必须一致')
}
@@ -201,7 +206,7 @@ export function validateRegistry(repoRoot, options = {}) {
const manifestFile = join(root, 'manifest.json')
const schemaVersionFile = join(root, 'SCHEMA_VERSION')
const rootSchema = readJson(join(root, 'schemas', 'registry-manifest.v3.schema.json'), diagnostics)
const entrySchema = readJson(join(root, 'schemas', 'registry-entry.v3.schema.json'), diagnostics)
const legacyEntrySchema = readJson(join(root, 'schemas', 'registry-entry.v3.schema.json'), diagnostics)
const externalEntrySchema = readJson(join(root, 'schemas', 'registry-entry.schema.json'), diagnostics)
const sidecarSchema = readJson(join(root, 'schemas', 'catalog-metadata.v1.schema.json'), diagnostics)
const rootManifest = readJson(manifestFile, diagnostics)
@@ -210,8 +215,10 @@ export function validateRegistry(repoRoot, options = {}) {
if (rootManifest && rootSchema) diagnostics.push(...schemaDiagnostics(rootManifest, rootSchema, relativeFile(root, manifestFile)))
const schemaVersion = existsSync(schemaVersionFile) ? readFileSync(schemaVersionFile, 'utf8').trim() : ''
const currentEntrySchema = /^4\./.test(schemaVersion) ? externalEntrySchema : legacyEntrySchema
if (!schemaVersion) diagnostics.push(diagnostic('error', 'missing-schema-version', 'SCHEMA_VERSION', '$', 'SCHEMA_VERSION 不能为空'))
if (rootManifest?.version !== schemaVersion) diagnostics.push(diagnostic('error', 'schema-version-mismatch', 'manifest.json', '$.version', 'manifest.version 必须与 SCHEMA_VERSION 一致'))
if (rootManifest?.dataVersion !== schemaVersion) diagnostics.push(diagnostic('error', 'schema-version-mismatch', 'manifest.json', '$.dataVersion', 'manifest.dataVersion 必须与 SCHEMA_VERSION 一致'))
const seenIds = new Set()
for (const entryDir of entryDirs) {
@@ -229,11 +236,13 @@ export function validateRegistry(repoRoot, options = {}) {
counts.totalEntries += 1
if (entry.type === 'docker-app') counts.dockerApps += 1
if (entry.type === 'native-app') counts.nativeApps = (counts.nativeApps ?? 0) + 1
if (entry.type === 'artifact') counts.artifactApps = (counts.artifactApps ?? 0) + 1
if (entry.type === 'mcp') counts.mcpServices += 1
if (entry.type === 'http-api') counts.httpApis += 1
if (entry.type === 'external-integration') counts.externalIntegrations += 1
const effectiveEntrySchema = ['external-integration', 'native-app'].includes(entry.type) ? externalEntrySchema : entrySchema
const effectiveEntrySchema = ['external-integration', 'native-app', 'artifact'].includes(entry.type) ? externalEntrySchema : currentEntrySchema
if (effectiveEntrySchema) diagnostics.push(...schemaDiagnostics(entry, effectiveEntrySchema, manifestRelative))
for (const message of validateApplicationContract(entry)) diagnostics.push(diagnostic('error', 'application-contract', manifestRelative, '$', message))
if (entry.id !== basename(entryDir)) diagnostics.push(diagnostic('error', 'directory-id-mismatch', manifestRelative, '$.id', 'manifest.id 必须与 entries/<id> 目录名一致'))
if (seenIds.has(entry.id)) diagnostics.push(diagnostic('error', 'duplicate-id', manifestRelative, '$.id', 'Registry 条目 ID 重复'))
seenIds.add(entry.id)
@@ -255,10 +264,10 @@ export function validateRegistry(repoRoot, options = {}) {
}
if (rootManifest?.stats) {
for (const key of ['totalEntries', 'dockerApps', 'nativeApps', 'mcpServices', 'httpApis', 'externalIntegrations']) {
if (key === 'nativeApps' && rootManifest.stats[key] === undefined && !counts.nativeApps) continue
for (const key of ['totalEntries', 'dockerApps', 'nativeApps', 'artifactApps', 'mcpServices', 'httpApis', 'externalIntegrations']) {
if (['nativeApps', 'artifactApps'].includes(key) && rootManifest.stats[key] === undefined && !counts[key]) continue
if (rootManifest.stats[key] === undefined && key === 'externalIntegrations' && counts[key] === 0) continue
if (rootManifest.stats[key] !== (key === 'nativeApps' ? (counts.nativeApps ?? 0) : counts[key])) diagnostics.push(diagnostic('error', 'stats-mismatch', 'manifest.json', `$.stats.${key}`, `声明 ${rootManifest.stats[key]},实际 ${counts[key]}`))
if (rootManifest.stats[key] !== (counts[key] ?? 0)) diagnostics.push(diagnostic('error', 'stats-mismatch', 'manifest.json', `$.stats.${key}`, `声明 ${rootManifest.stats[key]},实际 ${counts[key] ?? 0}`))
}
}

View File

@@ -197,7 +197,7 @@ function codes(report) {
return report.errors.map((item) => item.code)
}
test('当前 v4.1 条目保持可读,原生应用与既有 App/Service 具备 sidecar', () => {
test('当前 v4.2 条目保持可读,原生应用与既有 App/Service 具备 sidecar', () => {
const report = validateRegistry(repoRoot)
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
assert.deepEqual({

View File

@@ -17,7 +17,7 @@ function fixture() {
const manifest = JSON.parse(readFileSync(join(source, 'manifest.json'), 'utf8'))
manifest.stats = { totalEntries: 1, dockerApps: 0, nativeApps: 1, mcpServices: 0, httpApis: 0, externalIntegrations: 0 }
writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest))
writeFileSync(join(root, 'SCHEMA_VERSION'), '4.1.0\n')
writeFileSync(join(root, 'SCHEMA_VERSION'), manifest.version + '\n')
return root
}
function mutate(root, file, change) {

View File

@@ -0,0 +1,147 @@
import assert from 'node:assert/strict'
import { test, afterEach } from 'node:test'
import { cpSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { validateRegistry } from './catalog/validator.mjs'
const source = join(dirname(fileURLToPath(import.meta.url)), '..')
const temporary = []
afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }) })
const contribution = { id: 'help', point: 'session.panels', contractVersion: 1, definitionRef: { path: 'panel.json', sha256: 'a'.repeat(64) } }
const requires = [{ sourceId: 'official', appId: 'capability-provider', contributionId: 'help', contractVersion: 1, optional: false }]
const extension = { apiVersion: '1.0.0', hosts: ['agent-service'], runtime: { kind: 'declarative' }, contributes: [contribution] }
function fixture(type = 'artifact') {
const root = mkdtempSync(join(tmpdir(), 'registry-plugin-'))
temporary.push(root)
cpSync(join(source, 'schemas'), join(root, 'schemas'), { recursive: true })
cpSync(join(source, 'entries/desirecore-control'), join(root, 'entries/desirecore-control'), { recursive: true })
const manifest = JSON.parse(readFileSync(join(source, 'manifest.json'), 'utf8'))
manifest.stats = { totalEntries: 1, dockerApps: 0, nativeApps: type === 'native-app' ? 1 : 0, artifactApps: type === 'artifact' ? 1 : 0, mcpServices: 0, httpApis: 0, externalIntegrations: 0 }
writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest))
writeFileSync(join(root, 'SCHEMA_VERSION'), manifest.version + '\n')
mutate(root, 'manifest.json', entry => {
entry.type = type
entry.productKind = 'plugin'
entry.entrypoints = []
entry.extension = structuredClone(extension)
if (type === 'artifact') entry.install = { method: 'artifact', requirements: { docker: false, ports: [] } }
})
mutate(root, 'catalog-metadata.v1.json', sidecar => {
sidecar.spec.productKind = 'plugin'
sidecar.spec.entrypointKinds = []
sidecar.spec.extension = structuredClone(extension)
})
return root
}
function mutate(root, file, change) {
const path = join(root, 'entries/desirecore-control', file)
const data = JSON.parse(readFileSync(path, 'utf8'))
change(data)
writeFileSync(path, JSON.stringify(data))
}
function result(root) {
const cli = spawnSync(process.execPath, [join(source, 'scripts/validate-registry.mjs'), '--root', root], { encoding: 'utf8' })
return { cli, offline: validateRegistry(root) }
}
function expectValid(root) {
const report = result(root)
assert.equal(report.cli.status, 0, report.cli.stderr)
assert.equal(report.offline.ok, true, JSON.stringify(report.offline.errors))
return report
}
function expectInvalid(root) {
const report = result(root)
assert.equal(report.cli.status, 1, report.cli.stdout + report.cli.stderr)
assert.equal(report.offline.ok, false, JSON.stringify(report.offline))
}
test('无 UI artifact 插件仍为 App,无端口与执行进程', () => {
const report = expectValid(fixture())
assert.equal(report.offline.counts.artifactApps, 1)
assert.equal(report.offline.counts.mcpServices, 0)
})
test('普通原生应用可携带宿主贡献', () => {
const root = fixture('native-app')
mutate(root, 'manifest.json', data => { data.productKind = 'application'; data.entrypoints = [{ id: 'manage', name: '维护', kind: 'agent' }] })
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.productKind = 'application'; data.spec.entrypointKinds = ['agent'] })
expectValid(root)
})
test('旧普通应用省略主要归属时受信归一 application,保留显式维护入口', () => {
const root = fixture('native-app')
mutate(root, 'manifest.json', data => {
delete data.productKind
delete data.extension
data.entrypoints = [{ id: 'manage', name: '维护', kind: 'agent' }]
})
mutate(root, 'catalog-metadata.v1.json', data => {
data.spec.productKind = 'application'
delete data.spec.extension
data.spec.entrypointKinds = ['agent']
})
expectValid(root)
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.productKind = 'plugin' })
const report = result(root)
assert.equal(report.offline.ok, false)
assert.ok(report.offline.errors.some(error => error.code === 'product-kind-mismatch'))
})
test('省略归属不能把无入口插件声明冒充旧普通应用', () => {
const root = fixture('native-app')
mutate(root, 'manifest.json', data => { delete data.productKind })
expectInvalid(root)
})
test('原生应用的真实服务 runtime 由固定 descriptorRef 定位', () => {
const root = fixture('native-app')
const runtime = { kind: 'service', protocol: 'http', descriptorRef: { path: 'runtime.json', sha256: 'c'.repeat(64) } }
mutate(root, 'manifest.json', data => { data.extension.runtime = structuredClone(runtime) })
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.extension.runtime = structuredClone(runtime) })
expectValid(root)
})
test('普通应用可只依赖能力,依赖 sourceId 不冒充目录身份', () => {
const root = fixture('native-app')
mutate(root, 'manifest.json', data => { data.productKind = 'application'; data.entrypoints = [{ id: 'manage', name: '维护', kind: 'agent' }]; data.extension.contributes = []; data.extension.requires = structuredClone(requires) })
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.productKind = 'application'; data.spec.entrypointKinds = ['agent']; data.spec.extension.contributes = []; data.spec.extension.requires = structuredClone(requires) })
expectValid(root)
})
for (const [name, change] of [
['插件无贡献', data => { data.extension.contributes = []; data.extension.requires = structuredClone(requires) }],
['贡献与依赖均为空', data => { data.extension.contributes = [] }],
['非法贡献点', data => { data.extension.contributes[0].point = 'arbitrary.code' }],
['材料相对路径穿越', data => { data.extension.contributes[0].definitionRef.path = '../panel.json' }],
['材料缺摘要', data => { delete data.extension.contributes[0].definitionRef.sha256 }],
['artifact 声明端口', data => { data.install.requirements.ports = [9333] }],
['artifact 伪造执行服务', data => { data.extension.runtime = { kind: 'service', protocol: 'http', descriptorRef: { path: 'runtime.json', sha256: 'c'.repeat(64) } } }],
['伪造条目来源', data => { data.sourceId = 'official' }],
['其他层级伪造来源', data => { data.extension.runtime.sourceId = 'official' }],
['重复贡献 ID', data => { data.extension.contributes.push(structuredClone(contribution)) }],
['重复入口 ID', data => { data.entrypoints = [{ id: 'x', name: '一', kind: 'agent' }, { id: 'x', name: '二', kind: 'agent' }] }],
['第三方伪造 builtin', data => { data.entrypoints = [{ id: 'x', name: '一', kind: 'builtin', route: '/settings' }] }],
['第三方指定 reserved port', data => { data.entrypoints = [{ id: 'x', name: '一', kind: 'external', serviceRef: 'native-port:9333' }] }],
['结构错误 entrypoints', data => { data.entrypoints = {} }],
['结构错误贡献数组', data => { data.extension.contributes = [null] }],
]) test('CLI 与离线校验都拒绝 ' + name, () => {
const root = fixture()
mutate(root, 'manifest.json', change)
expectInvalid(root)
})
test('同一产品侧录不能改变 productKind 或关系声明', () => {
const root = fixture()
mutate(root, 'catalog-metadata.v1.json', data => { data.spec.productKind = 'application' })
const report = validateRegistry(root)
assert.equal(report.ok, false)
assert.ok(report.errors.some(error => error.code === 'product-kind-mismatch'))
})
test('artifact 统计必须对应真实文件,来源摘要不可漂移', () => {
const root = fixture()
mutate(root, 'catalog-metadata.v1.json', data => { data.provenance.content.sha256 = 'b'.repeat(64) })
assert.equal(validateRegistry(root).ok, false)
const manifestPath = join(root, 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
manifest.stats.artifactApps = 0
writeFileSync(manifestPath, JSON.stringify(manifest))
expectInvalid(root)
})

View File

@@ -3,6 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import Ajv from 'ajv'
import { findStaticMetrics } from './catalog/static-metadata.mjs'
import { validateApplicationContract } from './catalog/application-contract.mjs'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const rootArgumentIndex = process.argv.indexOf('--root')
@@ -46,11 +47,13 @@ const sorted = (values) => [...values].sort()
const sameSet = (actual, expected) =>
JSON.stringify(sorted(actual)) === JSON.stringify(sorted(expected))
const containsKey = (value, prohibitedKey) => {
if (Array.isArray(value)) return value.some((item) => containsKey(item, prohibitedKey))
const containsKey = (value, prohibitedKey, path = '') => {
if (Array.isArray(value)) return value.some((item, index) => containsKey(item, prohibitedKey, `${path}[${index}]`))
if (!value || typeof value !== 'object') return false
if (Object.hasOwn(value, prohibitedKey)) return true
return Object.values(value).some((item) => containsKey(item, prohibitedKey))
// 静态依赖必须指明产品来源;这不是客户端注入的条目来源身份。
const declaredDependency = prohibitedKey === 'sourceId' && /^extension\.requires\[\d+\]$/.test(path)
if (Object.hasOwn(value, prohibitedKey) && !declaredDependency) return true
return Object.entries(value).some(([key, item]) => containsKey(item, prohibitedKey, path ? `${path}.${key}` : key))
}
const assertHttpsUrl = (rawUrl, context) => {
@@ -290,7 +293,8 @@ if (rootManifest) {
let validateEntry = null
if (entrySchema) {
try {
const ajv = new Ajv({ allErrors: true, strict: true, validateFormats: false })
// Draft-07 条件块补充父 Schema 的约束;无需在每个 then/not 再写局部 type。
const ajv = new Ajv({ allErrors: true, strict: true, strictTypes: false, strictRequired: false, validateFormats: false })
validateEntry = ajv.compile(entrySchema)
} catch (error) {
addError(`schemas/registry-entry.schema.json: unable to compile (${error.message})`)
@@ -343,6 +347,7 @@ for (const directoryEntry of await readdir(entriesRoot, { withFileTypes: true })
if (validateEntry && !validateEntry(manifest)) {
addError(`entries/${directoryEntry.name}/manifest.json: ${formatAjvErrors(validateEntry.errors)}`)
}
for (const message of validateApplicationContract(manifest)) addError(`entries/${directoryEntry.name}/manifest.json: ${message}`)
if (manifest.type === 'external-integration') {
await validateExternalIntegration(entryDirectory, manifest)
}
@@ -353,6 +358,7 @@ if (rootManifest) {
totalEntries: manifests.length,
dockerApps: manifests.filter((entry) => entry.type === 'docker-app').length,
...(manifests.some((entry) => entry.type === 'native-app') || rootManifest.stats?.nativeApps !== undefined ? { nativeApps: manifests.filter((entry) => entry.type === 'native-app').length } : {}),
...(manifests.some((entry) => entry.type === 'artifact') || rootManifest.stats?.artifactApps !== undefined ? { artifactApps: manifests.filter((entry) => entry.type === 'artifact').length } : {}),
mcpServices: manifests.filter((entry) => entry.type === 'mcp').length,
httpApis: manifests.filter((entry) => entry.type === 'http-api').length,
externalIntegrations: manifests.filter((entry) => entry.type === 'external-integration').length,
@@ -380,7 +386,7 @@ if (errors.length > 0) {
const counts = rootManifest.stats
console.log(
`Registry validation passed: ${counts.totalEntries} entries ` +
`(${counts.dockerApps} Docker, ${counts.mcpServices} MCP, ` +
`(${counts.dockerApps} Docker, ${counts.nativeApps ?? 0} native, ${counts.artifactApps ?? 0} artifact, ${counts.mcpServices} MCP, ` +
`${counts.httpApis} HTTP API, ${counts.externalIntegrations} external integration).`,
)
}