Files
jiapuapp/scripts/check-project.mjs

277 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs'
import path from 'node:path'
import YAML from 'yaml'
const workspace = process.cwd()
const failures = []
const fail = (message) => failures.push(message)
const readText = (filePath) => fs.readFileSync(path.join(workspace, filePath), 'utf8')
const listFiles = (entryPaths, extensions) => {
const files = []
const visit = (entryPath) => {
const absolutePath = path.join(workspace, entryPath)
if (!fs.existsSync(absolutePath)) return
const stat = fs.statSync(absolutePath)
if (stat.isDirectory()) {
for (const name of fs.readdirSync(absolutePath)) {
visit(path.join(entryPath, name))
}
return
}
if (extensions.has(path.extname(entryPath))) files.push(entryPath)
}
entryPaths.forEach(visit)
return files
}
const parseJson = (filePath) => {
try {
return JSON.parse(readText(filePath))
} catch (error) {
fail(`${filePath} 不是有效 JSON${error.message}`)
return null
}
}
const pagesConfig = parseJson('pages.json')
parseJson('manifest.json')
parseJson('package.json')
parseJson('package-lock.json')
const runtimeConfigSource = readText('utils/runtime-config.js')
if (runtimeConfigSource.includes('import.meta.env')) {
fail('运行时配置直接读取 import.meta.env,会把构建机环境写入生产资源')
}
if (/\bmock\b|isMockMode|resolveRuntimeMode/.test(runtimeConfigSource)) {
fail('正式运行时配置仍保留不可达的 mock 双轨逻辑')
}
for (const productionValue of [
"const configuredBaseUrl = 'https://backend-api.ddxcjp.cn'",
"const configuredClientId = '428a8310cd442757ae699df5d894f051'",
"const configuredTenantId = '000000'",
]) {
if (!runtimeConfigSource.includes(productionValue)) {
fail(`运行时正式配置不完整:${productionValue}`)
}
}
if (fs.existsSync(path.join(workspace, 'data', 'preview'))) {
fail('正式项目仍保留不可达的本地预览数据目录 data/preview')
}
const routeSource = readText('utils/navigation/routes.js')
const routeEntries = [...routeSource.matchAll(/^\s{2}([A-Z]\d{2}): defineRoute\(\{[\s\S]*?^\s{2}\}\),/gm)]
const routeKeys = routeEntries.map((match) => match[1])
const routePaths = routeEntries.map((match) => match[0].match(/path: "([^"]+)"/)?.[1]?.replace(/^\//, ''))
const configuredPages = pagesConfig?.pages?.map((page) => page.path) || []
if (new Set(routeKeys).size !== routeKeys.length) fail('路由键存在重复')
if (routePaths.some((routePath) => !routePath)) fail('存在没有静态 path 的路由')
for (const pagePath of configuredPages) {
if (!fs.existsSync(path.join(workspace, `${pagePath}.vue`))) fail(`页面文件缺失:${pagePath}.vue`)
if (!routePaths.includes(pagePath)) fail(`页面没有路由语义:${pagePath}`)
}
for (const routePath of routePaths) {
if (!configuredPages.includes(routePath)) fail(`路由没有在 pages.json 注册:${routePath}`)
}
const sourceFiles = listFiles(
['App.vue', 'main.js', 'pages', 'components', 'composables', 'services', 'utils'],
new Set(['.js', '.vue']),
)
if (/hasRemoteConfig|REMOTE_(?:READ|WRITE)_REQUIRED|WRITE_UNAVAILABLE|本地预览/.test(
sourceFiles.map((filePath) => readText(filePath)).join('\n')
)) {
fail('正式源码仍保留已经不可达的本地预览守卫或错误码')
}
const importPattern = /(?:from\s*|import\s*)["']([^"']+)["']/g
for (const filePath of sourceFiles) {
const source = readText(filePath)
if (source.includes('@/data/preview')) {
fail(`${filePath} 仍引用本地预览数据`)
}
if (filePath.endsWith('.vue') && !/<(?:template|script)(?:\s|>)/.test(source)) {
fail(`${filePath} 缺少 <template> 或 <script>`)
}
for (const match of source.matchAll(importPattern)) {
const specifier = match[1]
if (!specifier.startsWith('.') && !specifier.startsWith('@/')) continue
const basePath = specifier.startsWith('@/')
? path.join(workspace, specifier.slice(2))
: path.resolve(workspace, path.dirname(filePath), specifier)
const candidates = [basePath, `${basePath}.js`, `${basePath}.vue`, path.join(basePath, 'index.js')]
if (!candidates.some((candidate) => fs.existsSync(candidate))) {
fail(`${filePath} 导入不存在:${specifier}`)
}
}
}
const visualFiles = listFiles(
['App.vue', 'uni.scss', 'pages', 'components', 'styles'],
new Set(['.vue', '.scss']),
)
const assetPattern = /["'(]((?:\/@?static\/|@\/static\/)[^"')\s]+)["')]/g
for (const filePath of visualFiles) {
for (const match of readText(filePath).matchAll(assetPattern)) {
const assetPath = match[1].replace(/^@?\//, '')
if (!fs.existsSync(path.join(workspace, assetPath))) fail(`${filePath} 引用不存在的资源:${match[1]}`)
}
}
let openApi = null
try {
openApi = YAML.parse(readText('genealogy-app-openapi.yaml'))
} catch (error) {
fail(`genealogy-app-openapi.yaml 无法解析:${error.message}`)
}
if (fs.existsSync(path.join(workspace, '家谱.openapi.json'))) {
fail('检测到旧 OpenAPI:家谱.openapi.jsongenealogy-app-openapi.yaml 必须是唯一所有者')
}
const resolveLocalOpenApiRef = (reference) => {
if (!openApi || typeof reference !== 'string' || !reference.startsWith('#/')) return undefined
return reference
.slice(2)
.split('/')
.map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'))
.reduce((value, segment) => value?.[segment], openApi)
}
const visitOpenApiNode = (value, location = '$') => {
if (Array.isArray(value)) {
value.forEach((entry, index) => visitOpenApiNode(entry, `${location}[${index}]`))
return
}
if (!value || typeof value !== 'object') return
for (const [key, child] of Object.entries(value)) {
if (key === '$ref' && typeof child === 'string' && child.startsWith('#/') && resolveLocalOpenApiRef(child) === undefined) {
fail(`OpenAPI 引用了不存在的本地定义:${child}${location}`)
continue
}
visitOpenApiNode(child, `${location}.${key}`)
}
}
visitOpenApiNode(openApi)
if (openApi?.servers?.[0]?.url !== 'https://backend-api.ddxcjp.cn') {
fail('OpenAPI 首选服务地址必须与正式运行时 HTTPS 接口一致')
}
for (const [endpointPath, pathItem] of Object.entries(openApi?.paths || {})) {
const requiredPathParams = [...endpointPath.matchAll(/\{([^}]+)\}/g)].map((match) => match[1])
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const operation = pathItem[method]
if (!operation) continue
const declaredPathParams = [...(pathItem.parameters || []), ...(operation.parameters || [])]
.map((parameter) => parameter?.$ref ? resolveLocalOpenApiRef(parameter.$ref) : parameter)
.filter((parameter) => parameter?.in === 'path')
.map((parameter) => parameter.name)
for (const name of requiredPathParams) {
if (!declaredPathParams.includes(name)) {
fail(`OpenAPI 路径参数未声明:${method.toUpperCase()} ${endpointPath} 缺少 ${name}`)
}
}
}
}
for (const compliancePath of [
'/genealogy/app/compliance/documents/{documentKey}',
'/genealogy/app/compliance/documents/{documentKey}/versions/{versionNo}',
]) {
const security = openApi?.paths?.[compliancePath]?.get?.security
if (!Array.isArray(security) || security.length !== 0) {
fail(`OpenAPI 合规文档必须允许登录前读取:GET ${compliancePath}`)
}
}
for (const contentProtectionPath of [
'/genealogy/app/genealogies/{genealogyId}/articles/{articleId}/content-protection',
'/genealogy/app/genealogies/{genealogyId}/growth-records/{recordId}/content-protection',
]) {
for (const method of ['put', 'delete']) {
const schemaRef = openApi?.paths?.[contentProtectionPath]?.[method]
?.responses?.['200']?.content?.['application/json']?.schema?.$ref
if (schemaRef !== '#/components/schemas/RVoid') {
fail(`OpenAPI 内容密码写操作必须返回 RVoid:${method.toUpperCase()} ${contentProtectionPath}`)
}
}
}
for (const [schemaName, expectedGrantType] of [
['PasswordRegisterBody', 'password'],
['PasswordLoginBody', 'password'],
['SmsLoginBody', 'sms'],
['SmsCodeBody', 'sms'],
['PasswordResetBody', 'password'],
]) {
const grantType = openApi?.components?.schemas?.[schemaName]?.properties?.grantType
if (!Array.isArray(grantType?.enum) || grantType.enum.length !== 1 || grantType.enum[0] !== expectedGrantType) {
fail(`OpenAPI ${schemaName}.grantType 必须固定为 ${expectedGrantType}`)
}
}
if (openApi?.components?.schemas?.PasswordChangeBody?.additionalProperties !== false) {
fail('OpenAPI PasswordChangeBody 必须拒绝未知字段')
}
const loginSchema = openApi?.components?.schemas?.LoginVo
if (
loginSchema?.additionalProperties !== false ||
!Array.isArray(loginSchema?.required) ||
loginSchema.required.length !== 1 ||
loginSchema.required[0] !== 'access_token' ||
!loginSchema?.properties?.access_token ||
['token', 'accessToken', 'tokenValue'].some((field) => loginSchema?.properties?.[field])
) {
fail('OpenAPI LoginVo 必须只以 access_token 作为会话令牌契约')
}
const normalizeEndpointPath = (endpointPath) => endpointPath
.split('?')[0]
.replace(/\$\{[^}]+\}/g, '{}')
.replace(/\{[^}]+\}/g, '{}')
const documentedPaths = new Set(Object.keys(openApi?.paths || {}).map(normalizeEndpointPath))
const documentedOperations = new Set(
Object.entries(openApi?.paths || {}).flatMap(([endpointPath, pathItem]) =>
['get', 'post', 'put', 'patch', 'delete']
.filter((method) => pathItem?.[method])
.map((method) => `${method.toUpperCase()} ${normalizeEndpointPath(endpointPath)}`),
),
)
const dynamicEndpointBuilders = new Set([
'/genealogy/app/genealogies/{}/{}',
'/genealogy/app/genealogies/{}/lineage/persons/{}/{}',
])
const servicePathBuilders = new Set([
'/genealogy/app/genealogies/{}/content-password-recovery/{}/{}',
])
const apiFiles = listFiles(['services/api'], new Set(['.js']))
.filter((filePath) => filePath.endsWith('-service.js') || filePath.endsWith('request-client.js'))
const endpointPattern = /([`'"])(\/(?:genealogy|captcha|auth)[\s\S]*?)\1/g
const serviceOperationPattern = /url:\s*([`'"])(\/(?:genealogy|captcha|auth)[^`'"\r\n]*)\1,\s*method:\s*(['"])(GET|POST|PUT|PATCH|DELETE)\3/g
for (const filePath of apiFiles) {
const source = readText(filePath)
for (const match of source.matchAll(endpointPattern)) {
const endpoint = match[2]
if (endpoint.includes('\n')) continue
const normalizedPath = normalizeEndpointPath(endpoint)
if (!documentedPaths.has(normalizedPath) && !dynamicEndpointBuilders.has(normalizedPath) && !servicePathBuilders.has(normalizedPath)) {
fail(`${filePath} 使用了 OpenAPI 未声明的路径:${endpoint}`)
}
}
for (const match of source.matchAll(serviceOperationPattern)) {
const endpoint = normalizeEndpointPath(match[2])
if (dynamicEndpointBuilders.has(endpoint)) continue
const operation = `${match[4]} ${endpoint}`
if (!documentedOperations.has(operation)) {
fail(`${filePath} 使用了 OpenAPI 未声明的操作:${operation}`)
}
}
}
if (failures.length > 0) {
console.error(`PROJECT CHECK FAILED (${failures.length})`)
failures.forEach((message) => console.error(`- ${message}`))
process.exit(1)
}
console.log(`PROJECT CHECK PASS pages=${configuredPages.length} routes=${routeKeys.length} sources=${sourceFiles.length}`)