233 lines
8.0 KiB
JavaScript
233 lines
8.0 KiB
JavaScript
const fs = require('fs')
|
|
const path = require('path')
|
|
|
|
const root = path.resolve(__dirname, '..')
|
|
const reportDir = path.join(root, 'reports')
|
|
const sourceRoots = ['pages', 'components', 'windows', 'js_sdk', 'App.vue', 'main.js']
|
|
const excludedDirs = new Set(['.git', 'node_modules', 'unpackage', '.zcode'])
|
|
const sourceExtensions = new Set(['.vue', '.js', '.json'])
|
|
|
|
function walk(target) {
|
|
const absolute = path.join(root, target)
|
|
if (!fs.existsSync(absolute)) return []
|
|
const stat = fs.statSync(absolute)
|
|
if (stat.isFile()) return [absolute]
|
|
const files = []
|
|
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
|
|
if (entry.isDirectory() && excludedDirs.has(entry.name)) continue
|
|
const child = path.join(absolute, entry.name)
|
|
if (entry.isDirectory()) files.push(...walk(path.relative(root, child)))
|
|
else if (sourceExtensions.has(path.extname(entry.name))) files.push(child)
|
|
}
|
|
return files
|
|
}
|
|
|
|
function relative(file) {
|
|
return path.relative(root, file).replace(/\\/g, '/')
|
|
}
|
|
|
|
function lineOf(text, index) {
|
|
return text.slice(0, index).split(/\r?\n/).length
|
|
}
|
|
|
|
function addFinding(list, severity, file, line, message, evidence = '') {
|
|
list.push({ severity, file: relative(file), line, message, evidence })
|
|
}
|
|
|
|
function readJson(file) {
|
|
try {
|
|
return JSON.parse(stripJsonComments(fs.readFileSync(file, 'utf8')))
|
|
} catch (error) {
|
|
return { __parseError: error.message }
|
|
}
|
|
}
|
|
|
|
// B1-2: schema 允许 uniCloud JSONC 注释头,比较前剥离
|
|
function stripJsonComments(text) {
|
|
let out = ''
|
|
let i = 0
|
|
const n = text.length
|
|
let inStr = false
|
|
while (i < n) {
|
|
const c = text[i]
|
|
if (inStr) {
|
|
out += c
|
|
if (c === '\\') { out += text[i + 1] || ''; i += 2; continue }
|
|
if (c === '"') inStr = false
|
|
i++
|
|
continue
|
|
}
|
|
if (c === '"') { inStr = true; out += c; i++; continue }
|
|
if (c === '/' && text[i + 1] === '/') {
|
|
const end = text.indexOf('\n', i)
|
|
const stop = end === -1 ? n : end
|
|
out += ' '.repeat(stop - i)
|
|
i = stop
|
|
continue
|
|
}
|
|
if (c === '/' && text[i + 1] === '*') {
|
|
const end = text.indexOf('*/', i + 2)
|
|
const stop = end === -1 ? n : end + 2
|
|
const skipped = text.slice(i, stop)
|
|
out += skipped.replace(/[^\n]/g, ' ')
|
|
i = stop
|
|
continue
|
|
}
|
|
out += c
|
|
i++
|
|
}
|
|
return out
|
|
}
|
|
|
|
function collectSchemas() {
|
|
const schemaFiles = []
|
|
for (const base of ['uniCloud-alipay/database', 'uni_modules']) {
|
|
schemaFiles.push(...walk(base).filter(file => file.endsWith('.schema.json')))
|
|
}
|
|
const schemas = new Map()
|
|
for (const file of schemaFiles) {
|
|
const name = path.basename(file, '.schema.json')
|
|
schemas.set(name, { file: relative(file), schema: readJson(file) })
|
|
}
|
|
return schemas
|
|
}
|
|
|
|
function collectFiles() {
|
|
return sourceRoots.flatMap(walk).filter(file => !file.endsWith('.schema.json'))
|
|
}
|
|
|
|
function checkVue3(files, findings) {
|
|
for (const file of files.filter(file => file.endsWith('.vue'))) {
|
|
const text = fs.readFileSync(file, 'utf8')
|
|
for (const pattern of [
|
|
{ re: /\s(slot|show-iconn)=/g, message: '发现 Vue3 兼容性标记或拼写错误' },
|
|
{ re: /<[^>]+\sslot="[^" está]+"/g, message: '发现旧版具名插槽语法' }
|
|
]) {
|
|
let match
|
|
while ((match = pattern.re.exec(text))) {
|
|
addFinding(findings, 'error', file, lineOf(text, match.index), pattern.message, match[0].trim())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkSensitiveFields(files, findings) {
|
|
const sensitive = /\b(password|token|password_secret|secret_key|private_key)\b/i
|
|
for (const file of files.filter(file => file.endsWith('.vue'))) {
|
|
const text = fs.readFileSync(file, 'utf8')
|
|
const isList = /<unicloud-db|download-excel/.test(text) && /list\.vue$/.test(file)
|
|
if (!isList) continue
|
|
const lines = text.split(/\r?\n/)
|
|
lines.forEach((line, index) => {
|
|
if (sensitive.test(line)) {
|
|
addFinding(findings, 'error', file, index + 1, '列表页面包含敏感字段,禁止直接查询、展示或导出', line.trim())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
function checkCollectionContracts(files, schemas, findings, warnings) {
|
|
// B1-2: 只认 <unicloud-db> 主标签的字面量 collection;辅助集合(uni-data-select 等)
|
|
// 只登记不比对,避免噪音 warning。:collection="变量" 绑定由 script 区首个定义解析。
|
|
const udbRe = /<unicloud-db\b[^>]*>/g
|
|
const udbCollectionRe = /collectionList\s*:\s*["']([^"']+)["']|collection\s*:\s*["']([^"']+)["']/
|
|
const scriptRe = /collectionList\s*:\s*["']([^"']+)["']/
|
|
for (const file of files.filter(file => file.endsWith('.vue') || file.endsWith('.js'))) {
|
|
const text = fs.readFileSync(file, 'utf8')
|
|
const mains = new Set()
|
|
let um
|
|
while ((um = udbRe.exec(text))) {
|
|
const cm = udbCollectionRe.exec(um[0])
|
|
if (cm) mains.add(cm[1] || cm[2])
|
|
}
|
|
if (!mains.size) {
|
|
const sm = scriptRe.exec(text)
|
|
if (sm) mains.add(sm[1])
|
|
}
|
|
for (const name of mains) {
|
|
if (name.startsWith('uni-') || name.startsWith('opendb-') || name.includes('comment') || name === 'cms-temp') {
|
|
if (!schemas.has(name)) {
|
|
addFinding(warnings, 'warning', file, 1, '页面引用的主集合未在本地 schema 中发现', name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkKnownPatterns(files, findings) {
|
|
const patterns = [
|
|
{ re: /if\s*\(this\.where\._id\)/, message: '使用不存在的 where._id 判断加载条件' },
|
|
{ re: /const\s+queryRe\s*=\s*new\s+RegExp\(query,\s*['"]i['"]\)/, message: '用户搜索输入未转义正则特殊字符' },
|
|
{ re: /field\.hasOwnProperty\(['"]value['"]\)/, message: '疑似使用未定义变量 field' },
|
|
{ re: /uploadSuccessTaskNames\.push\(name\)/, message: '疑似使用未定义变量 name' },
|
|
{ re: /:emptyText="error\.message\s*\|\|\s*loading\s*\?/, message: 'emptyText 条件优先级可能错误' }
|
|
]
|
|
for (const file of files) {
|
|
const text = fs.readFileSync(file, 'utf8')
|
|
for (const { re, message } of patterns) {
|
|
const match = re.exec(text)
|
|
if (match) addFinding(findings, 'error', file, lineOf(text, match.index), message, match[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkJsonParse(files, findings) {
|
|
for (const file of files.filter(file => file.endsWith('.json'))) {
|
|
const parsed = readJson(file)
|
|
if (parsed.__parseError) addFinding(findings, 'error', file, 1, 'JSON 解析失败', parsed.__parseError)
|
|
}
|
|
}
|
|
|
|
function makeReport(findings, warnings, schemas) {
|
|
const report = {
|
|
generatedAt: new Date().toISOString(),
|
|
project: 'uni-admin 2.5.1',
|
|
schemaCount: schemas.size,
|
|
errors: findings,
|
|
warnings,
|
|
summary: {
|
|
errors: findings.length,
|
|
warnings: warnings.length,
|
|
status: findings.length ? 'failed' : 'passed'
|
|
}
|
|
}
|
|
fs.mkdirSync(reportDir, { recursive: true })
|
|
fs.writeFileSync(path.join(reportDir, 'quality-report.json'), JSON.stringify(report, null, 2) + '\n')
|
|
const lines = [
|
|
'# Quality report',
|
|
'',
|
|
`Generated: ${report.generatedAt}`,
|
|
'',
|
|
`- Status: **${report.summary.status}**`,
|
|
`- Errors: ${report.summary.errors}`,
|
|
`- Warnings: ${report.summary.warnings}`,
|
|
`- Schemas indexed: ${report.schemaCount}`,
|
|
''
|
|
]
|
|
for (const [title, list] of [['Errors', findings], ['Warnings', warnings]]) {
|
|
lines.push(`## ${title}`, '')
|
|
if (!list.length) lines.push('None', '')
|
|
for (const item of list) lines.push(`- **${item.file}:${item.line}** ${item.message}${item.evidence ? ` — \`${item.evidence}\`` : ''}`)
|
|
lines.push('')
|
|
}
|
|
fs.writeFileSync(path.join(reportDir, 'quality-report.md'), lines.join('\n'))
|
|
return report
|
|
}
|
|
|
|
function main() {
|
|
const files = collectFiles()
|
|
const schemas = collectSchemas()
|
|
const findings = []
|
|
const warnings = []
|
|
checkJsonParse(files, findings)
|
|
checkVue3(files, findings)
|
|
checkSensitiveFields(files, findings)
|
|
checkKnownPatterns(files, findings)
|
|
checkCollectionContracts(files, schemas, findings, warnings)
|
|
const report = makeReport(findings, warnings, schemas)
|
|
console.log(JSON.stringify(report.summary))
|
|
if (findings.length) process.exitCode = 1
|
|
}
|
|
|
|
main()
|