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:
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user