import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' const docsSiteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const rnDocsRoot = path.join(docsSiteRoot, 'docs', 'rn') const vitepressConfig = path.join(docsSiteRoot, 'docs', '.vitepress', 'config.ts') const requiredPages = [ 'index.md', 'setup.md', 'common.md', 'session.md', 'update.md', 'bugcollect.md', 'xwebview.md', 'im.md', 'push.md', 'troubleshooting.md', ] const forbiddenPatterns = [ [/@xuqm\/rn-sdk\b/g, '内部聚合包不应进入对外文档'], [/\bXuqmSDK\.initialize\s*\(/g, '已删除的手动初始化 API'], [/\bcheckRnUpdate\s*\(/g, '已删除的旧 Update API'], [/\bdownloadAndApplyBundle\s*\(/g, '已删除的旧 Bundle API'], [/\brequestNativeRegistration\s*\(/g, '已删除的 RN Push Token API'], [/\bregisterToken\s*\(/g, '已删除的 RN Push Token API'], [/\bsetDeviceToken\s*\(/g, '已删除的 RN Push Token API'], [/@xuqm\/rn-common\/internal(?:-security)?\b/g, '官方扩展专用内部入口'], [ /\b(?:release[- ]set|Ed25519|NativeBundle|loadNativeBundle|planReleaseSet)\b/gi, '内部更新实现细节', ], [/(?:原生基线|签名算法|数据库隔离规则|服务拓扑)/g, '内部架构细节'], ] const requiredPackagePages = new Map([ ['@xuqm/rn-common', ['index.md', 'setup.md', 'common.md', 'session.md']], ['@xuqm/rn-update', ['update.md']], ['@xuqm/rn-bugcollect', ['bugcollect.md']], ['@xuqm/rn-xwebview', ['xwebview.md']], ['@xuqm/rn-im', ['im.md']], ['@xuqm/rn-push', ['push.md']], ]) const failures = [] const sourceByPage = new Map() for (const page of requiredPages) { const file = path.join(rnDocsRoot, page) if (!fs.existsSync(file)) { failures.push(`缺少 RN 对外文档页面:${page}`) continue } sourceByPage.set(page, fs.readFileSync(file, 'utf8')) } const actualMarkdownPages = fs .readdirSync(rnDocsRoot, { withFileTypes: true }) .filter(entry => entry.isFile() && entry.name.endsWith('.md')) .map(entry => entry.name) .sort() for (const page of actualMarkdownPages) { if (!requiredPages.includes(page)) { failures.push(`存在未纳入公开信息架构的 RN 页面:${page}`) } } for (const [page, source] of sourceByPage) { for (const [pattern, reason] of forbiddenPatterns) { pattern.lastIndex = 0 if (pattern.test(source)) { failures.push(`${page} 包含禁止内容(${reason}):${pattern}`) } } } for (const [packageName, pages] of requiredPackagePages) { for (const page of pages) { if (!sourceByPage.get(page)?.includes(packageName)) { failures.push(`${page} 未明确记录公开包 ${packageName}`) } } } const navigation = fs.readFileSync(vitepressConfig, 'utf8') for (const page of requiredPages) { const route = page === 'index.md' ? "/rn/'" : `/rn/${page.replace(/\.md$/, '')}'` if (!navigation.includes(route)) { failures.push(`VitePress RN 导航缺少 ${page}`) } } if (navigation.includes('/rn/group')) { failures.push('VitePress RN 导航仍引用已经合并的重复群聊页面') } // 本地多仓工作区存在 RN SDK 源码时,继续核对文档中的命名导入确实由 package index 导出。 // Jenkins 单仓构建没有兄弟仓库时跳过此增强检查,但上面的静态契约仍然生效。 const sdkRoot = process.env.XUQM_RN_SDK_DIR ?? path.resolve(docsSiteRoot, '..', '..', 'XuqmGroup-RNSDK') if (fs.existsSync(path.join(sdkRoot, 'packages'))) { const indexByPackage = new Map() for (const packageDir of ['common', 'update', 'bugcollect', 'xwebview', 'im', 'push']) { const packageJson = JSON.parse( fs.readFileSync(path.join(sdkRoot, 'packages', packageDir, 'package.json'), 'utf8'), ) indexByPackage.set( packageJson.name, fs.readFileSync(path.join(sdkRoot, 'packages', packageDir, 'src', 'index.ts'), 'utf8'), ) } const namedImport = /import\s*\{([\s\S]*?)\}\s*from\s*['"](@xuqm\/rn-(?:common|update|bugcollect|xwebview|im|push))['"]/g for (const [page, source] of sourceByPage) { for (const match of source.matchAll(namedImport)) { const packageSource = indexByPackage.get(match[2]) const symbols = match[1] .split(',') .map(value => value.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0]) .filter(value => /^[A-Za-z_$][\w$]*$/.test(value)) for (const symbol of symbols) { const exported = new RegExp( `(?:export\\s+(?:const|class|function|type|interface)\\s+${symbol}\\b|export\\s*\\{[\\s\\S]*?\\b${symbol}\\b[\\s\\S]*?\\})`, ).test(packageSource) if (!exported) { failures.push(`${page} 从 ${match[2]} 导入未公开的符号:${symbol}`) } } } } const objectSources = new Map([ ['XuqmSDK', path.join(sdkRoot, 'packages', 'common', 'src', 'sdk.ts')], ['UpdateSDK', path.join(sdkRoot, 'packages', 'update', 'src', 'UpdateSDK.ts')], [ 'NativeAppUpdate', path.join(sdkRoot, 'packages', 'update', 'src', 'NativeAppUpdate.ts'), ], ['XuqmRuntime', path.join(sdkRoot, 'packages', 'update', 'src', 'PluginRuntime.ts')], ['BugCollect', path.join(sdkRoot, 'packages', 'bugcollect', 'src', 'BugCollect.ts')], ['ImSDK', path.join(sdkRoot, 'packages', 'im', 'src', 'ImSDK.ts')], ['PushSDK', path.join(sdkRoot, 'packages', 'push', 'src', 'PushSDK.ts')], ]) for (const [objectName, sourceFile] of objectSources) { const implementation = fs.readFileSync(sourceFile, 'utf8') const usage = new RegExp(`\\b${objectName}\\.([A-Za-z_$][\\w$]*)\\s*\\(`, 'g') for (const [page, source] of sourceByPage) { for (const match of source.matchAll(usage)) { const method = match[1] const declared = new RegExp( `(?:^|\\n)\\s*(?:async\\s+)?${method}\\s*(?:\\([^\\n]*|:)`, ).test(implementation) if (!declared) { failures.push(`${page} 调用 ${objectName} 中不存在的公开方法:${method}`) } } } } } if (failures.length > 0) { console.error('RN 对外文档契约检查失败:') for (const failure of failures) console.error(`- ${failure}`) process.exit(1) } console.log(`RN 对外文档契约检查通过:${requiredPages.length} 个页面,6 个公开模块`)