mirror of
https://git.openapi.site/https://github.com/desirecore/registry.git
synced 2026-09-05 16:54:16 +08:00
feat: 建立统一目录元数据与获取契约 (#5)
* feat: 建立 Registry 目录元数据验证基础 * feat: 迁移 Registry 应用目录元数据 * test: 解耦目录迁移进度断言 * feat: 迁移 Registry 服务目录元数据 * fix(catalog): 强制 Registry 目录元数据 * fix(catalog): 兼容 Registry v4 外部集成
This commit is contained in:
173
scripts/catalog/json-schema.mjs
Normal file
173
scripts/catalog/json-schema.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
function joinPath(base, segment) {
|
||||
if (typeof segment === 'number') return `${base}[${segment}]`
|
||||
return base === '$' ? `$.${segment}` : `${base}.${segment}`
|
||||
}
|
||||
|
||||
function resolveRef(rootSchema, ref) {
|
||||
if (!ref.startsWith('#/')) throw new Error(`只支持本地 JSON Pointer $ref,收到 ${ref}`)
|
||||
return ref
|
||||
.slice(2)
|
||||
.split('/')
|
||||
.map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'))
|
||||
.reduce((current, part) => current?.[part], rootSchema)
|
||||
}
|
||||
|
||||
function typeMatches(value, type) {
|
||||
if (type === 'null') return value === null
|
||||
if (type === 'array') return Array.isArray(value)
|
||||
if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
if (type === 'integer') return Number.isInteger(value)
|
||||
if (type === 'number') return typeof value === 'number' && Number.isFinite(value)
|
||||
return typeof value === type
|
||||
}
|
||||
|
||||
function isUri(value) {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
return Boolean(parsed.protocol && parsed.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validateNode(value, schema, rootSchema, path, errors) {
|
||||
if (schema === true) return
|
||||
if (schema === false) {
|
||||
errors.push({ path, message: 'Schema 明确拒绝该值' })
|
||||
return
|
||||
}
|
||||
if (!schema || typeof schema !== 'object') return
|
||||
|
||||
if (schema.$ref) {
|
||||
const resolved = resolveRef(rootSchema, schema.$ref)
|
||||
if (!resolved) {
|
||||
errors.push({ path, message: `无法解析 $ref ${schema.$ref}` })
|
||||
return
|
||||
}
|
||||
validateNode(value, resolved, rootSchema, path, errors)
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.allOf)) {
|
||||
for (const child of schema.allOf) validateNode(value, child, rootSchema, path, errors)
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
const candidates = schema.anyOf.map((child) => {
|
||||
const childErrors = []
|
||||
validateNode(value, child, rootSchema, path, childErrors)
|
||||
return childErrors
|
||||
})
|
||||
if (!candidates.some((candidate) => candidate.length === 0)) {
|
||||
errors.push({ path, message: '值不匹配 anyOf 中的任何分支' })
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.oneOf)) {
|
||||
const candidates = schema.oneOf.map((child) => {
|
||||
const childErrors = []
|
||||
validateNode(value, child, rootSchema, path, childErrors)
|
||||
return childErrors
|
||||
})
|
||||
const matches = candidates.filter((candidate) => candidate.length === 0).length
|
||||
if (matches !== 1) errors.push({ path, message: `值应且仅应匹配 oneOf 的一个分支,实际匹配 ${matches} 个` })
|
||||
}
|
||||
|
||||
if (schema.not) {
|
||||
const childErrors = []
|
||||
validateNode(value, schema.not, rootSchema, path, childErrors)
|
||||
if (childErrors.length === 0) errors.push({ path, message: '值命中了 not 禁止的结构' })
|
||||
}
|
||||
|
||||
if (schema.if) {
|
||||
const conditionErrors = []
|
||||
validateNode(value, schema.if, rootSchema, path, conditionErrors)
|
||||
const selected = conditionErrors.length === 0 ? schema.then : schema.else
|
||||
if (selected) validateNode(value, selected, rootSchema, path, errors)
|
||||
}
|
||||
|
||||
if (schema.const !== undefined && !isDeepStrictEqual(value, schema.const)) {
|
||||
errors.push({ path, message: `必须等于 ${JSON.stringify(schema.const)}` })
|
||||
}
|
||||
if (Array.isArray(schema.enum) && !schema.enum.some((candidate) => isDeepStrictEqual(value, candidate))) {
|
||||
errors.push({ path, message: `必须是 ${schema.enum.map((candidate) => JSON.stringify(candidate)).join(' / ')}` })
|
||||
}
|
||||
|
||||
if (schema.type) {
|
||||
const allowed = Array.isArray(schema.type) ? schema.type : [schema.type]
|
||||
if (!allowed.some((type) => typeMatches(value, type))) {
|
||||
errors.push({ path, message: `类型必须是 ${allowed.join(' / ')}` })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
||||
errors.push({ path, message: `长度不能小于 ${schema.minLength}` })
|
||||
}
|
||||
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
||||
errors.push({ path, message: `长度不能大于 ${schema.maxLength}` })
|
||||
}
|
||||
if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) {
|
||||
errors.push({ path, message: `不匹配 pattern ${schema.pattern}` })
|
||||
}
|
||||
if (schema.format === 'uri' && !isUri(value)) errors.push({ path, message: '必须是绝对 URI' })
|
||||
if (schema.format === 'date-time' && Number.isNaN(Date.parse(value))) {
|
||||
errors.push({ path, message: '必须是可解析的 date-time' })
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
if (schema.minimum !== undefined && value < schema.minimum) errors.push({ path, message: `不能小于 ${schema.minimum}` })
|
||||
if (schema.maximum !== undefined && value > schema.maximum) errors.push({ path, message: `不能大于 ${schema.maximum}` })
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
||||
errors.push({ path, message: `元素数量不能少于 ${schema.minItems}` })
|
||||
}
|
||||
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
||||
errors.push({ path, message: `元素数量不能多于 ${schema.maxItems}` })
|
||||
}
|
||||
if (schema.uniqueItems) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value.slice(0, index).some((candidate) => isDeepStrictEqual(candidate, value[index]))) {
|
||||
errors.push({ path: joinPath(path, index), message: '数组元素必须唯一' })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (schema.items) {
|
||||
value.forEach((item, index) => validateNode(item, schema.items, rootSchema, joinPath(path, index), errors))
|
||||
}
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const properties = schema.properties ?? {}
|
||||
for (const required of schema.required ?? []) {
|
||||
if (!Object.hasOwn(value, required)) errors.push({ path: joinPath(path, required), message: '缺少必需字段' })
|
||||
}
|
||||
if (schema.minProperties !== undefined && Object.keys(value).length < schema.minProperties) {
|
||||
errors.push({ path, message: `字段数量不能少于 ${schema.minProperties}` })
|
||||
}
|
||||
for (const [key, childValue] of Object.entries(value)) {
|
||||
if (Object.hasOwn(properties, key)) {
|
||||
validateNode(childValue, properties[key], rootSchema, joinPath(path, key), errors)
|
||||
} else if (schema.additionalProperties === false) {
|
||||
errors.push({ path: joinPath(path, key), message: '不允许额外字段' })
|
||||
} else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
||||
validateNode(childValue, schema.additionalProperties, rootSchema, joinPath(path, key), errors)
|
||||
}
|
||||
if (schema.propertyNames?.pattern && !new RegExp(schema.propertyNames.pattern, 'u').test(key)) {
|
||||
errors.push({ path: joinPath(path, key), message: `字段名不匹配 pattern ${schema.propertyNames.pattern}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateJsonSchema(value, schema) {
|
||||
const errors = []
|
||||
validateNode(value, schema, schema, '$', errors)
|
||||
return errors
|
||||
}
|
||||
28
scripts/catalog/validate-registry.mjs
Normal file
28
scripts/catalog/validate-registry.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateRegistry } from './validator.mjs'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const defaultRoot = resolve(scriptDir, '..', '..')
|
||||
const args = process.argv.slice(2)
|
||||
const rootIndex = args.indexOf('--root')
|
||||
const root = rootIndex >= 0 ? resolve(args[rootIndex + 1]) : defaultRoot
|
||||
const json = args.includes('--json')
|
||||
const requireSidecars = args.includes('--require-sidecars') ? true : undefined
|
||||
const report = validateRegistry(root, { requireSidecars })
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
||||
} else {
|
||||
const { counts } = report
|
||||
process.stdout.write(`Registry 校验:${report.ok ? '通过' : '失败'}\n`)
|
||||
process.stdout.write(`条目 ${counts.totalEntries}(App ${counts.dockerApps} / 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`)
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = report.ok ? 0 : 1
|
||||
254
scripts/catalog/validator.mjs
Normal file
254
scripts/catalog/validator.mjs
Normal file
@@ -0,0 +1,254 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { basename, join, relative, resolve, sep } from 'node:path'
|
||||
import { validateJsonSchema } from './json-schema.mjs'
|
||||
|
||||
export const CATALOG_SIDECAR_FILENAME = 'catalog-metadata.v1.json'
|
||||
|
||||
const UNKNOWN_LICENSES = new Set(['', 'unknown', 'none', 'noassertion', 'unlicensed', 'proprietary-unknown'])
|
||||
const IMMUTABLE_GIT_REF = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i
|
||||
const CONTAINER_DIGEST = /^sha256:[a-f0-9]{64}$/i
|
||||
const SHA256 = /^[a-f0-9]{64}$/i
|
||||
const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
||||
const CALVER = /^(?:19|20)\d{2}\.(?:0?[1-9]|1[0-2])\.(?:0?[1-9]|[12]\d|3[01])(?:[-+][0-9A-Za-z.-]+)?$/
|
||||
|
||||
function diagnostic(level, code, file, path, message) {
|
||||
return { level, code, file, path, message }
|
||||
}
|
||||
|
||||
function readJson(file, diagnostics) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, 'utf8'))
|
||||
} catch (error) {
|
||||
diagnostics.push(diagnostic('error', 'invalid-json', file, '$', error instanceof Error ? error.message : String(error)))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function schemaDiagnostics(value, schema, file) {
|
||||
return validateJsonSchema(value, schema).map((error) =>
|
||||
diagnostic('error', 'schema', file, error.path, error.message),
|
||||
)
|
||||
}
|
||||
|
||||
function safeRelativePath(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
!value.startsWith('/') &&
|
||||
!value.startsWith('\\') &&
|
||||
!value.split(/[\\/]+/u).includes('..') &&
|
||||
!/^[a-zA-Z]:[\\/]/u.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
function sameStringSet(left, right) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right)) return false
|
||||
return [...left].sort().join('\0') === [...right].sort().join('\0')
|
||||
}
|
||||
|
||||
function isHttpsUrl(value) {
|
||||
try {
|
||||
return new URL(value).protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function sourceIsImmutable(source) {
|
||||
if (!source || !isHttpsUrl(source.url) || !safeRelativePath(source.path ?? '.')) return false
|
||||
if (source.kind === 'git') return IMMUTABLE_GIT_REF.test(source.ref)
|
||||
if (source.kind === 'container') return CONTAINER_DIGEST.test(source.ref)
|
||||
if (['web', 'zip', 'release', 'package'].includes(source.kind)) return SHA256.test(source.sha256 ?? '')
|
||||
return false
|
||||
}
|
||||
|
||||
function timestampShapeIsConsistent(timestamp) {
|
||||
if (!timestamp) return true
|
||||
if (timestamp.state === 'unknown') return true
|
||||
if (timestamp.state !== 'known') return false
|
||||
if (timestamp.precision === 'day') return /^\d{4}-\d{2}-\d{2}$/u.test(timestamp.value)
|
||||
if (timestamp.precision === 'second') return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u.test(timestamp.value)
|
||||
return false
|
||||
}
|
||||
|
||||
function validateSidecarSemantics(manifest, sidecar, file) {
|
||||
const errors = []
|
||||
const add = (code, path, message) => errors.push(diagnostic('error', code, file, path, message))
|
||||
const expectedKind = manifest.type === 'docker-app' ? '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}`)
|
||||
if (sidecar.spec?.kind !== expectedKind) add('spec-kind-mismatch', '$.spec.kind', 'spec.kind 必须与 identity.kind 一致')
|
||||
if (sidecar.release?.state !== 'known' || sidecar.release.version !== manifest.version) {
|
||||
add('version-mismatch', '$.release', 'Registry legacy manifest 已声明版本,sidecar 必须以 known 状态保留同一版本')
|
||||
}
|
||||
|
||||
if (sidecar.release?.state === 'known' && sidecar.release.versionScheme === 'semver' && !SEMVER.test(sidecar.release.version)) {
|
||||
add('version-scheme', '$.release.version', '声明 semver 时必须是完整 SemVer')
|
||||
}
|
||||
if (sidecar.release?.state === 'known' && sidecar.release.versionScheme === 'calver' && !CALVER.test(sidecar.release.version)) {
|
||||
add('version-scheme', '$.release.version', '声明 calver 时必须是 YYYY.M.D 形状')
|
||||
}
|
||||
|
||||
const defaultLocale = sidecar.presentation?.defaultLocale
|
||||
const locales = sidecar.presentation?.i18n ?? {}
|
||||
const defaultText = locales[defaultLocale]
|
||||
if (!defaultText) {
|
||||
add('missing-default-locale', '$.presentation.defaultLocale', 'defaultLocale 必须在 i18n 中存在')
|
||||
} else {
|
||||
if (defaultText.name !== manifest.name) add('i18n-legacy-mismatch', `$.presentation.i18n.${defaultLocale}.name`, '默认语言 name 必须与 legacy name 一致')
|
||||
if (defaultText.summary !== manifest.description) add('i18n-legacy-mismatch', `$.presentation.i18n.${defaultLocale}.summary`, '默认语言 summary 必须与 legacy description 一致')
|
||||
if (defaultText.description !== undefined && manifest.fullDesc !== undefined && defaultText.description !== manifest.fullDesc) {
|
||||
add('i18n-legacy-mismatch', `$.presentation.i18n.${defaultLocale}.description`, '默认语言 description 必须与 legacy fullDesc 一致')
|
||||
}
|
||||
for (const [locale, text] of Object.entries(locales)) {
|
||||
if (locale === defaultLocale) continue
|
||||
if (text.summary === defaultText.summary && (text.description ?? '') === (defaultText.description ?? '')) {
|
||||
add('duplicate-translation', `$.presentation.i18n.${locale}`, '不同 locale 不能用相同源文案伪装成已翻译内容')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sameStringSet(sidecar.presentation?.tags, manifest.tags ?? [])) {
|
||||
add('presentation-mismatch', '$.presentation.tags', 'sidecar tags 必须与 legacy tags 集合一致')
|
||||
}
|
||||
|
||||
for (const [name, timestamp] of Object.entries(sidecar.timestamps ?? {})) {
|
||||
if (!timestampShapeIsConsistent(timestamp)) add('timestamp-precision', `$.timestamps.${name}`, '时间值必须与 day/second precision 一致且使用 UTC')
|
||||
}
|
||||
|
||||
const platforms = sidecar.compatibility?.platforms
|
||||
if (platforms?.state === 'known' && !sameStringSet(platforms.values, manifest.platformSupport ?? [])) {
|
||||
add('platform-mismatch', '$.compatibility.platforms.values', 'known 平台必须与 legacy platformSupport 集合一致')
|
||||
}
|
||||
if (platforms?.state === 'all' && !sameStringSet(manifest.platformSupport ?? [], ['macos', 'windows', 'linux'])) {
|
||||
add('platform-mismatch', '$.compatibility.platforms', 'all 只能用于 legacy 已显式声明三平台的条目')
|
||||
}
|
||||
|
||||
if (expectedKind === 'app') {
|
||||
if (sidecar.spec?.category !== manifest.category) add('spec-mismatch', '$.spec.category', 'App category 必须与 legacy category 一致')
|
||||
} else {
|
||||
const expectedProtocol = manifest.type === 'mcp' ? 'mcp' : 'http'
|
||||
if (sidecar.spec?.protocol !== expectedProtocol) add('spec-mismatch', '$.spec.protocol', `Service protocol 必须是 ${expectedProtocol}`)
|
||||
if (!sameStringSet(sidecar.spec?.capabilities, manifest.capabilities)) add('spec-mismatch', '$.spec.capabilities', 'Service capabilities 必须与 legacy 集合一致')
|
||||
if (manifest.toolCount !== undefined && sidecar.spec?.toolCount !== manifest.toolCount) add('spec-mismatch', '$.spec.toolCount', 'toolCount 必须与 legacy 一致')
|
||||
}
|
||||
|
||||
const source = sidecar.provenance?.content
|
||||
if (source?.path !== undefined && !safeRelativePath(source.path)) add('unsafe-evidence-path', '$.provenance.content.path', '内容 path 必须是安全相对路径')
|
||||
const compliance = sidecar.governance?.compliance
|
||||
for (const key of ['licenseEvidencePath', 'noticePath']) {
|
||||
if (compliance?.[key] !== undefined && !safeRelativePath(compliance[key])) {
|
||||
add('unsafe-evidence-path', `$.governance.compliance.${key}`, '证据路径必须是安全相对路径')
|
||||
}
|
||||
}
|
||||
if (sidecar.governance?.license?.state === 'known' && sidecar.governance.license.evidencePath !== undefined && !safeRelativePath(sidecar.governance.license.evidencePath)) {
|
||||
add('unsafe-evidence-path', '$.governance.license.evidencePath', '许可证证据路径必须是安全相对路径')
|
||||
}
|
||||
if (compliance && source && compliance.reviewedRef !== source.ref) {
|
||||
add('review-ref-mismatch', '$.governance.compliance.reviewedRef', '审核 ref 必须与内容来源 ref 完全一致')
|
||||
}
|
||||
if (compliance && sidecar.timestamps?.reviewedAt?.state !== 'known') {
|
||||
add('review-time-mismatch', '$.timestamps.reviewedAt', '存在 compliance 时 reviewedAt 必须是 known')
|
||||
} else if (compliance && compliance.reviewedAt !== sidecar.timestamps.reviewedAt.value) {
|
||||
add('review-time-mismatch', '$.governance.compliance.reviewedAt', '治理审核时间必须与 timestamps.reviewedAt 一致')
|
||||
}
|
||||
|
||||
if (sidecar.governance?.availability === 'installable') {
|
||||
const missing = []
|
||||
if (!sourceIsImmutable(source)) missing.push('不可变 HTTPS 内容来源')
|
||||
if (!sidecar.governance.stewardship) missing.push('stewardship')
|
||||
if (
|
||||
sidecar.governance.license?.state !== 'known' ||
|
||||
UNKNOWN_LICENSES.has((sidecar.governance.license.value ?? '').trim().toLowerCase())
|
||||
) missing.push('已知 license')
|
||||
if (!['allowed', 'source-pointer-only'].includes(sidecar.governance.redistribution)) missing.push('已复核 redistribution')
|
||||
if (!sidecar.governance.listingMaintainer?.name || !sidecar.governance.upstreamMaintainer?.name) missing.push('双维护者身份')
|
||||
if (!sidecar.governance.branding) missing.push('branding 证据')
|
||||
if (!compliance) missing.push('compliance 证据')
|
||||
if (missing.length > 0) add('installable-without-evidence', '$.governance.availability', `installable 缺少:${missing.join('、')}`)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
function findEntryDirs(entriesDir) {
|
||||
if (!existsSync(entriesDir)) return []
|
||||
return readdirSync(entriesDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
||||
.map((entry) => join(entriesDir, entry.name))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function relativeFile(root, file) {
|
||||
return relative(root, file).split(sep).join('/')
|
||||
}
|
||||
|
||||
export function validateRegistry(repoRoot, options = {}) {
|
||||
const root = resolve(repoRoot)
|
||||
const diagnostics = []
|
||||
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 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)
|
||||
const entryDirs = findEntryDirs(join(root, 'entries'))
|
||||
const counts = { totalEntries: 0, dockerApps: 0, mcpServices: 0, httpApis: 0, externalIntegrations: 0, sidecars: 0, legacyOnly: 0 }
|
||||
|
||||
if (rootManifest && rootSchema) diagnostics.push(...schemaDiagnostics(rootManifest, rootSchema, relativeFile(root, manifestFile)))
|
||||
const schemaVersion = existsSync(schemaVersionFile) ? readFileSync(schemaVersionFile, 'utf8').trim() : ''
|
||||
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 一致'))
|
||||
|
||||
const seenIds = new Set()
|
||||
for (const entryDir of entryDirs) {
|
||||
const manifestPath = join(entryDir, 'manifest.json')
|
||||
const manifestRelative = relativeFile(root, manifestPath)
|
||||
if (!existsSync(manifestPath)) {
|
||||
diagnostics.push(diagnostic('error', 'missing-entry-manifest', relativeFile(root, entryDir), '$', 'entry 目录缺少 manifest.json'))
|
||||
continue
|
||||
}
|
||||
const entry = readJson(manifestPath, diagnostics)
|
||||
if (!entry) continue
|
||||
counts.totalEntries += 1
|
||||
if (entry.type === 'docker-app') counts.dockerApps += 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 = entry.type === 'external-integration' ? externalEntrySchema : entrySchema
|
||||
if (effectiveEntrySchema) diagnostics.push(...schemaDiagnostics(entry, effectiveEntrySchema, manifestRelative))
|
||||
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)
|
||||
|
||||
const sidecarPath = join(entryDir, CATALOG_SIDECAR_FILENAME)
|
||||
if (entry.type === 'external-integration') continue
|
||||
if (!existsSync(sidecarPath)) {
|
||||
counts.legacyOnly += 1
|
||||
const requireSidecars = options.requireSidecars ?? rootManifest?.catalogMetadata?.required ?? false
|
||||
diagnostics.push(diagnostic(requireSidecars ? 'error' : 'warning', 'missing-sidecar', manifestRelative, '$', `缺少可选 ${CATALOG_SIDECAR_FILENAME},继续使用 legacy manifest`))
|
||||
continue
|
||||
}
|
||||
counts.sidecars += 1
|
||||
const sidecar = readJson(sidecarPath, diagnostics)
|
||||
if (!sidecar) continue
|
||||
const sidecarRelative = relativeFile(root, sidecarPath)
|
||||
if (sidecarSchema) diagnostics.push(...schemaDiagnostics(sidecar, sidecarSchema, sidecarRelative))
|
||||
diagnostics.push(...validateSidecarSemantics(entry, sidecar, sidecarRelative))
|
||||
}
|
||||
|
||||
if (rootManifest?.stats) {
|
||||
for (const key of ['totalEntries', 'dockerApps', 'mcpServices', 'httpApis', 'externalIntegrations']) {
|
||||
if (rootManifest.stats[key] === undefined && key === 'externalIntegrations' && counts[key] === 0) continue
|
||||
if (rootManifest.stats[key] !== counts[key]) diagnostics.push(diagnostic('error', 'stats-mismatch', 'manifest.json', `$.stats.${key}`, `声明 ${rootManifest.stats[key]},实际 ${counts[key]}`))
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.sort((left, right) =>
|
||||
left.file.localeCompare(right.file) || left.path.localeCompare(right.path) || left.code.localeCompare(right.code),
|
||||
)
|
||||
const errors = diagnostics.filter((item) => item.level === 'error')
|
||||
const warnings = diagnostics.filter((item) => item.level === 'warning')
|
||||
return { ok: errors.length === 0, root, counts, errors, warnings, diagnostics }
|
||||
}
|
||||
415
scripts/catalog/validator.test.mjs
Normal file
415
scripts/catalog/validator.test.mjs
Normal file
@@ -0,0 +1,415 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { afterEach, test } from 'node:test'
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateRegistry } from './validator.mjs'
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const temporaryRoots = []
|
||||
|
||||
afterEach(() => {
|
||||
while (temporaryRoots.length > 0) rmSync(temporaryRoots.pop(), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeJson(file, value) {
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function legacyApp(overrides = {}) {
|
||||
return {
|
||||
id: 'demo-app',
|
||||
name: 'Demo App',
|
||||
type: 'docker-app',
|
||||
version: '1.2.3',
|
||||
author: 'Demo Org',
|
||||
description: '一个用于测试的应用',
|
||||
tags: ['demo'],
|
||||
icon: 'box',
|
||||
iconLetter: 'D',
|
||||
platformSupport: ['macos', 'windows', 'linux'],
|
||||
category: 'tools',
|
||||
shortDesc: '一个用于测试的应用',
|
||||
fullDesc: '这是用于验证 Registry 契约的测试应用。',
|
||||
install: {
|
||||
method: 'docker',
|
||||
requirements: { docker: true, minMemory: '1GB', minDisk: '1GB', ports: [8080] },
|
||||
configNeeded: ['Docker'],
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function legacyMcp(overrides = {}) {
|
||||
return {
|
||||
id: 'demo-mcp',
|
||||
name: 'Demo MCP',
|
||||
type: 'mcp',
|
||||
version: '2026.8.31',
|
||||
author: 'Demo Org',
|
||||
description: '一个用于测试的 MCP 服务',
|
||||
tags: ['demo'],
|
||||
icon: 'terminal',
|
||||
platformSupport: ['macos', 'windows', 'linux'],
|
||||
capabilities: ['read'],
|
||||
toolCount: 1,
|
||||
install: { method: 'npx', packageName: '@demo/mcp', command: 'npx', args: ['-y', '@demo/mcp@1.0.0'] },
|
||||
connection: { transport: 'stdio', command: 'npx', args: ['-y', '@demo/mcp@1.0.0'] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function legacyHttp(overrides = {}) {
|
||||
return {
|
||||
id: 'demo-http',
|
||||
name: 'Demo HTTP',
|
||||
type: 'http-api',
|
||||
version: '3.0',
|
||||
author: 'Demo Org',
|
||||
description: '一个用于测试的 HTTP 服务',
|
||||
tags: ['demo'],
|
||||
icon: 'globe',
|
||||
platformSupport: ['macos', 'windows', 'linux'],
|
||||
endpoint: 'https://api.example.com/v1',
|
||||
capabilities: ['query'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function unknownTimestamps(overrides = {}) {
|
||||
return {
|
||||
catalogUpdatedAt: { state: 'unknown' },
|
||||
releasePublishedAt: { state: 'unknown' },
|
||||
reviewedAt: { state: 'unknown' },
|
||||
upstreamObservedAt: { state: 'unknown' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function appSidecar(overrides = {}) {
|
||||
return {
|
||||
$schema: '../../schemas/catalog-metadata.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
identity: { kind: 'app', id: 'demo-app' },
|
||||
presentation: {
|
||||
defaultLocale: 'zh-CN',
|
||||
i18n: {
|
||||
'zh-CN': {
|
||||
name: 'Demo App',
|
||||
summary: '一个用于测试的应用',
|
||||
description: '这是用于验证 Registry 契约的测试应用。',
|
||||
},
|
||||
},
|
||||
tags: ['demo'],
|
||||
},
|
||||
release: { state: 'known', version: '1.2.3', versionScheme: 'semver' },
|
||||
timestamps: unknownTimestamps(),
|
||||
provenance: {},
|
||||
governance: { availability: 'listing-only', license: { state: 'unknown' }, redistribution: 'verify-package-terms' },
|
||||
compatibility: { platforms: { state: 'all' } },
|
||||
spec: { kind: 'app', category: 'tools' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mcpSidecar(overrides = {}) {
|
||||
return {
|
||||
$schema: '../../schemas/catalog-metadata.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
identity: { kind: 'service', id: 'demo-mcp' },
|
||||
presentation: {
|
||||
defaultLocale: 'zh-CN',
|
||||
i18n: { 'zh-CN': { name: 'Demo MCP', summary: '一个用于测试的 MCP 服务' } },
|
||||
tags: ['demo'],
|
||||
},
|
||||
release: { state: 'known', version: '2026.8.31', versionScheme: 'calver' },
|
||||
timestamps: unknownTimestamps(),
|
||||
provenance: {},
|
||||
governance: { availability: 'listing-only', license: { state: 'unknown' }, redistribution: 'verify-package-terms' },
|
||||
compatibility: { platforms: { state: 'known', values: ['macos', 'windows', 'linux'] } },
|
||||
spec: { kind: 'service', protocol: 'mcp', capabilities: ['read'], toolCount: 1 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function httpSidecar(overrides = {}) {
|
||||
return {
|
||||
$schema: '../../schemas/catalog-metadata.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
identity: { kind: 'service', id: 'demo-http' },
|
||||
presentation: {
|
||||
defaultLocale: 'zh-CN',
|
||||
i18n: { 'zh-CN': { name: 'Demo HTTP', summary: '一个用于测试的 HTTP 服务' } },
|
||||
tags: ['demo'],
|
||||
},
|
||||
release: { state: 'known', version: '3.0', versionScheme: 'opaque' },
|
||||
timestamps: unknownTimestamps(),
|
||||
provenance: {},
|
||||
governance: { availability: 'listing-only', license: { state: 'unknown' }, redistribution: 'verify-package-terms' },
|
||||
compatibility: { platforms: { state: 'all' } },
|
||||
spec: { kind: 'service', protocol: 'http', authType: 'unknown', capabilities: ['query'] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRegistry(entries, sidecars = new Map(), rootOverrides = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'registry-validator-'))
|
||||
temporaryRoots.push(root)
|
||||
cpSync(join(repoRoot, 'schemas'), join(root, 'schemas'), { recursive: true })
|
||||
const counts = {
|
||||
totalEntries: entries.length,
|
||||
dockerApps: entries.filter((entry) => entry.type === 'docker-app').length,
|
||||
mcpServices: entries.filter((entry) => entry.type === 'mcp').length,
|
||||
httpApis: entries.filter((entry) => entry.type === 'http-api').length,
|
||||
}
|
||||
writeFileSync(join(root, 'SCHEMA_VERSION'), '3.1.0\n')
|
||||
writeJson(join(root, 'manifest.json'), {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
id: 'desirecore-registry-manifest',
|
||||
version: '3.1.0',
|
||||
name: 'Test Registry',
|
||||
description: 'Test Registry',
|
||||
maintainer: 'Test',
|
||||
repository: 'https://example.com/registry',
|
||||
lastUpdated: '2026-08-31',
|
||||
stats: counts,
|
||||
dataVersion: '3.1.0',
|
||||
catalogMetadata: {
|
||||
version: '1.0.0',
|
||||
schema: 'schemas/catalog-metadata.v1.schema.json',
|
||||
sidecarPath: 'entries/<id>/catalog-metadata.v1.json',
|
||||
required: false,
|
||||
legacyFallback: true,
|
||||
},
|
||||
...rootOverrides,
|
||||
})
|
||||
for (const entry of entries) {
|
||||
const entryDir = join(root, 'entries', entry.id)
|
||||
writeJson(join(entryDir, 'manifest.json'), entry)
|
||||
if (sidecars.has(entry.id)) writeJson(join(entryDir, 'catalog-metadata.v1.json'), sidecars.get(entry.id))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function codes(report) {
|
||||
return report.errors.map((item) => item.code)
|
||||
}
|
||||
|
||||
test('当前 v4 条目保持可读,21 个 App/Service 具备 sidecar', () => {
|
||||
const report = validateRegistry(repoRoot)
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
assert.deepEqual({
|
||||
totalEntries: report.counts.totalEntries,
|
||||
dockerApps: report.counts.dockerApps,
|
||||
mcpServices: report.counts.mcpServices,
|
||||
httpApis: report.counts.httpApis,
|
||||
externalIntegrations: report.counts.externalIntegrations,
|
||||
}, {
|
||||
totalEntries: 22,
|
||||
dockerApps: 8,
|
||||
mcpServices: 8,
|
||||
httpApis: 5,
|
||||
externalIntegrations: 1,
|
||||
})
|
||||
assert.equal(report.counts.sidecars + report.counts.legacyOnly, report.counts.totalEntries - report.counts.externalIntegrations)
|
||||
assert.equal(report.warnings.filter((item) => item.code === 'missing-sidecar').length, report.counts.legacyOnly)
|
||||
})
|
||||
|
||||
test('合法 listing-only App sidecar 通过', () => {
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', appSidecar()]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
assert.equal(report.counts.sidecars, 1)
|
||||
})
|
||||
|
||||
test('合法 MCP sidecar 保留 CalVer、能力和 toolCount', () => {
|
||||
const report = validateRegistry(makeRegistry([legacyMcp()], new Map([['demo-mcp', mcpSidecar()]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
})
|
||||
|
||||
test('合法 HTTP sidecar 保留 opaque 版本、协议、鉴权 unknown 和能力', () => {
|
||||
const report = validateRegistry(makeRegistry([legacyHttp()], new Map([['demo-http', httpSidecar()]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
})
|
||||
|
||||
test('stats 与实际目录不一致时失败', () => {
|
||||
const root = makeRegistry([legacyApp()], new Map(), { stats: { totalEntries: 99, dockerApps: 8, mcpServices: 0, httpApis: 0 } })
|
||||
const report = validateRegistry(root)
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('stats-mismatch'))
|
||||
})
|
||||
|
||||
test('目录名与 manifest.id 不一致时失败', () => {
|
||||
const root = makeRegistry([legacyApp()])
|
||||
const source = join(root, 'entries', 'demo-app')
|
||||
const target = join(root, 'entries', 'wrong-directory')
|
||||
cpSync(source, target, { recursive: true })
|
||||
rmSync(source, { recursive: true, force: true })
|
||||
const report = validateRegistry(root)
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('directory-id-mismatch'))
|
||||
})
|
||||
|
||||
test('legacy type-specific 必需字段缺失时失败', () => {
|
||||
const entry = legacyMcp()
|
||||
delete entry.connection
|
||||
const report = validateRegistry(makeRegistry([entry]))
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('schema'))
|
||||
})
|
||||
|
||||
test('sidecar 严格拒绝额外字段和正文 sourceId', () => {
|
||||
const sidecar = appSidecar({ sourceId: 'registry:official', unexpected: true })
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(report.errors.filter((item) => item.code === 'schema').length >= 2)
|
||||
})
|
||||
|
||||
test('sidecar 拒绝正文 official 治理声明', () => {
|
||||
const sidecar = appSidecar({
|
||||
governance: {
|
||||
availability: 'listing-only',
|
||||
stewardship: 'official',
|
||||
license: { state: 'unknown' },
|
||||
redistribution: 'verify-package-terms',
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('schema'))
|
||||
})
|
||||
|
||||
test('i18n 必须包含默认语言且不能伪造重复翻译', () => {
|
||||
const missingDefault = appSidecar({
|
||||
presentation: { defaultLocale: 'en-US', i18n: { 'zh-CN': appSidecar().presentation.i18n['zh-CN'] } },
|
||||
})
|
||||
let report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', missingDefault]])))
|
||||
assert.ok(codes(report).includes('missing-default-locale'))
|
||||
|
||||
const duplicated = appSidecar()
|
||||
duplicated.presentation.i18n['en-US'] = { ...duplicated.presentation.i18n['zh-CN'] }
|
||||
report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', duplicated]])))
|
||||
assert.ok(codes(report).includes('duplicate-translation'))
|
||||
})
|
||||
|
||||
test('证据路径拒绝绝对路径和目录穿越', () => {
|
||||
const sidecar = appSidecar({
|
||||
provenance: { content: { kind: 'git', url: 'https://example.com/repo.git', ref: 'a'.repeat(40), path: '../secret' } },
|
||||
governance: {
|
||||
availability: 'listing-only',
|
||||
license: { state: 'unknown' },
|
||||
redistribution: 'verify-package-terms',
|
||||
compliance: {
|
||||
licenseEvidencePath: '/tmp/LICENSE',
|
||||
reviewedRef: 'a'.repeat(40),
|
||||
reviewedAt: '2026-08-31',
|
||||
reviewedBy: 'test',
|
||||
upstreamEndorsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, false)
|
||||
assert.equal(codes(report).filter((code) => code === 'unsafe-evidence-path').length, 2)
|
||||
})
|
||||
|
||||
test('mutable source 可 listing-only,但不能 installable', () => {
|
||||
const mutableSource = { kind: 'git', url: 'https://example.com/repo.git', ref: 'main' }
|
||||
let sidecar = appSidecar({ provenance: { content: mutableSource } })
|
||||
let report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
|
||||
sidecar = appSidecar({
|
||||
provenance: { content: mutableSource },
|
||||
timestamps: unknownTimestamps({ reviewedAt: { state: 'known', value: '2026-08-31', precision: 'day' } }),
|
||||
governance: {
|
||||
availability: 'installable',
|
||||
stewardship: 'pointer',
|
||||
license: { state: 'known', value: 'MIT', evidencePath: 'LICENSE' },
|
||||
redistribution: 'source-pointer-only',
|
||||
listingMaintainer: { name: 'DesireCore Team', verified: true },
|
||||
upstreamMaintainer: { name: 'Demo Org', verified: false },
|
||||
branding: { relationship: 'independent-listing', nameUsage: 'nominative', logoStatus: 'not-used' },
|
||||
compliance: {
|
||||
licenseEvidencePath: 'LICENSE',
|
||||
reviewedRef: 'main',
|
||||
reviewedAt: '2026-08-31',
|
||||
reviewedBy: 'catalog-review-v1',
|
||||
upstreamEndorsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('installable-without-evidence'))
|
||||
})
|
||||
|
||||
test('完整不可变来源与审核证据允许 installable', () => {
|
||||
const ref = 'a'.repeat(40)
|
||||
const sidecar = appSidecar({
|
||||
provenance: { content: { kind: 'git', url: 'https://example.com/repo.git', ref } },
|
||||
timestamps: unknownTimestamps({ reviewedAt: { state: 'known', value: '2026-08-31', precision: 'day' } }),
|
||||
governance: {
|
||||
availability: 'installable',
|
||||
stewardship: 'pointer',
|
||||
license: { state: 'known', value: 'MIT', evidencePath: 'LICENSE' },
|
||||
redistribution: 'source-pointer-only',
|
||||
listingMaintainer: { name: 'DesireCore Team', verified: true },
|
||||
upstreamMaintainer: { name: 'Demo Org', verified: false },
|
||||
branding: { relationship: 'independent-listing', nameUsage: 'nominative', logoStatus: 'not-used' },
|
||||
compliance: {
|
||||
licenseEvidencePath: 'LICENSE',
|
||||
reviewedRef: ref,
|
||||
reviewedAt: '2026-08-31',
|
||||
reviewedBy: 'catalog-review-v1',
|
||||
upstreamEndorsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.equal(report.ok, true, JSON.stringify(report.errors, null, 2))
|
||||
})
|
||||
|
||||
test('审核 ref 和审核时间必须绑定同一来源与 timestamps', () => {
|
||||
const sidecar = appSidecar({
|
||||
provenance: { content: { kind: 'git', url: 'https://example.com/repo.git', ref: 'a'.repeat(40) } },
|
||||
timestamps: unknownTimestamps({ reviewedAt: { state: 'known', value: '2026-08-31', precision: 'day' } }),
|
||||
governance: {
|
||||
availability: 'listing-only',
|
||||
license: { state: 'unknown' },
|
||||
redistribution: 'verify-package-terms',
|
||||
compliance: {
|
||||
licenseEvidencePath: 'LICENSE',
|
||||
reviewedRef: 'b'.repeat(40),
|
||||
reviewedAt: '2026-08-30',
|
||||
reviewedBy: 'catalog-review-v1',
|
||||
upstreamEndorsed: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(makeRegistry([legacyApp()], new Map([['demo-app', sidecar]])))
|
||||
assert.ok(codes(report).includes('review-ref-mismatch'))
|
||||
assert.ok(codes(report).includes('review-time-mismatch'))
|
||||
})
|
||||
|
||||
test('--require-sidecars 等价策略会让 legacy-only 失败', () => {
|
||||
const report = validateRegistry(makeRegistry([legacyApp()]), { requireSidecars: true })
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('missing-sidecar'))
|
||||
})
|
||||
|
||||
test('根 manifest 声明 required 时默认拒绝缺失 sidecar', () => {
|
||||
const root = makeRegistry([legacyApp()], new Map(), {
|
||||
catalogMetadata: {
|
||||
version: '1.0.0',
|
||||
schema: 'schemas/catalog-metadata.v1.schema.json',
|
||||
sidecarPath: 'entries/<id>/catalog-metadata.v1.json',
|
||||
required: true,
|
||||
legacyFallback: true,
|
||||
},
|
||||
})
|
||||
const report = validateRegistry(root)
|
||||
assert.equal(report.ok, false)
|
||||
assert.ok(codes(report).includes('missing-sidecar'))
|
||||
})
|
||||
Reference in New Issue
Block a user