G1: scripts/data-contract-report.js + quality-check.js 删除 Generated 时间戳行
(时间戳保留在 gitignored 的 *.json generatedAt 备查;reports/*.md
此后跑门禁不再变脏,归属:门禁产物噪音)
G2: pay-order/list.vue confirmRefund 云端拒绝 errCode 分支 + 校验/网络
catch 分支均补 toast 用户可见提示(此前静默失败)
门禁: npm test 62 schemas/0 漂移/0 错误全绿;diff --check 按
core.whitespace=cr-at-eol 通过(仓库既有 CRLF 惯例,autocrlf=false)
258 lines
9.0 KiB
JavaScript
258 lines
9.0 KiB
JavaScript
const fs = require('fs')
|
||
const path = require('path')
|
||
|
||
const root = path.resolve(__dirname, '..')
|
||
const reportDir = path.join(root, 'reports')
|
||
const excluded = new Set(['.git', 'node_modules', 'unpackage', '.zcode'])
|
||
|
||
function walk(dir) {
|
||
if (!fs.existsSync(dir)) return []
|
||
const result = []
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
if (entry.isDirectory() && excluded.has(entry.name)) continue
|
||
const file = path.join(dir, entry.name)
|
||
if (entry.isDirectory()) result.push(...walk(file))
|
||
else result.push(file)
|
||
}
|
||
return result
|
||
}
|
||
|
||
function rel(file) {
|
||
return path.relative(root, file).replace(/\\/g, '/')
|
||
}
|
||
|
||
function parseJson(file) {
|
||
try {
|
||
return JSON.parse(stripJsonComments(fs.readFileSync(file, 'utf8')))
|
||
} catch (error) {
|
||
return { __error: error.message }
|
||
}
|
||
}
|
||
|
||
// B1: 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 schemaFiles() {
|
||
const localSchemas = walk(path.join(root, 'uniCloud-alipay', 'database'))
|
||
.filter(file => file.endsWith('.schema.json'))
|
||
const moduleSchemas = walk(path.join(root, 'uni_modules'))
|
||
.filter(file => file.endsWith('.schema.json'))
|
||
return [...localSchemas, ...moduleSchemas]
|
||
}
|
||
|
||
function sourceFiles() {
|
||
return [
|
||
...walk(path.join(root, 'pages')),
|
||
...walk(path.join(root, 'components')),
|
||
...walk(path.join(root, 'cloudfunctions'))
|
||
].filter(file => /\.(vue|js)$/.test(file))
|
||
}
|
||
|
||
function getSchemas() {
|
||
const result = new Map()
|
||
for (const file of schemaFiles()) {
|
||
result.set(path.basename(file, '.schema.json'), { file: rel(file), schema: parseJson(file) })
|
||
}
|
||
return result
|
||
}
|
||
|
||
function addMap(map, key, value) {
|
||
if (!map.has(key)) map.set(key, [])
|
||
map.get(key).push(value)
|
||
}
|
||
|
||
function collectCollections(sources) {
|
||
const collections = new Map()
|
||
const patterns = [
|
||
/collectionList\s*:\s*["']([^"']+)["']/g,
|
||
/collection\s*:\s*["']([^"']+)["']/g,
|
||
/collection\(["']([^"']+)["']\)/g
|
||
]
|
||
for (const file of sources) {
|
||
const text = fs.readFileSync(file, 'utf8')
|
||
for (const pattern of patterns) {
|
||
let match
|
||
while ((match = pattern.exec(text))) addMap(collections, match[1], rel(file))
|
||
}
|
||
}
|
||
return collections
|
||
}
|
||
|
||
function collectFields(sources, collections) {
|
||
const fields = new Map()
|
||
// B1-1: 前置边界 [\s"] 排除 group-field/groupField;只认属性 field=(不认 .field( JS 调用)
|
||
const fieldPattern = /[\s"]field\s*=\s*["']([^"']+)["']/g
|
||
// 主集合判定:<unicloud-db ...> 标签内的 collection 绑定
|
||
const udbRe = /<unicloud-db\b[^>]*>/g
|
||
const udbCollectionRe = /collectionList\s*:\s*["']([^"']+)["']|collection\s*:\s*["']([^"']+)["']/
|
||
for (const file of sources.filter(file => file.endsWith('.vue'))) {
|
||
const text = fs.readFileSync(file, 'utf8')
|
||
// script 区 collectionList: "x" 字面量(:collection="collectionList" 绑定的解析目标)
|
||
const scriptCollections = []
|
||
const scriptRe = /collectionList\s*:\s*["']([^"']+)["']/g
|
||
let sm
|
||
while ((sm = scriptRe.exec(text))) scriptCollections.push(sm[1])
|
||
const arrayRe = /collectionList\s*:\s*\[([^\]]*)\]/g
|
||
let am
|
||
while ((am = arrayRe.exec(text))) {
|
||
const inner = am[1]
|
||
const strRe = /db\.collection\(\s*["']([^"']+)["']\s*\)/g
|
||
let im
|
||
while ((im = strRe.exec(inner))) scriptCollections.push(im[1])
|
||
}
|
||
let um
|
||
let foundMain = false
|
||
const pendingTags = []
|
||
while ((um = udbRe.exec(text))) {
|
||
const tag = um[0]
|
||
const cm = udbCollectionRe.exec(tag)
|
||
if (!cm) {
|
||
if (/[:\s]collection\s*=/.test(tag)) pendingTags.push(tag)
|
||
continue
|
||
}
|
||
const main = cm[1] || cm[2]
|
||
foundMain = true
|
||
collectTagFields(tag, main, file, fields)
|
||
}
|
||
if (pendingTags.length && scriptCollections.length) {
|
||
// :collection="collectionList" 绑定的 unicloud-db 标签:只收该标签自身的 field,
|
||
// 不收 uni-data-select / uni-data-checkbox 等辅助组件的 field
|
||
foundMain = true
|
||
const main = scriptCollections[0]
|
||
for (const tag of pendingTags) collectTagFields(tag, main, file, fields)
|
||
}
|
||
}
|
||
return fields
|
||
}
|
||
|
||
function collectTagFields(tag, main, file, fields) {
|
||
const fieldRe = /[\s"]field\s*=\s*["']([^"']+)["']/g
|
||
let fm
|
||
while ((fm = fieldRe.exec(tag))) addFieldEntries(main, fm[1], file, fields)
|
||
}
|
||
|
||
function addFieldEntries(main, raw, file, fields) {
|
||
for (const field of raw.split(',')) {
|
||
// B1-1: 去 as 别名,去表前缀;聚合表达式(含括号)直接丢弃
|
||
const name = field.trim().split(/\s+as\s+/i)[0].split('.')[0].trim()
|
||
if (!name || name.includes('(') || name.includes(')')) continue
|
||
addMap(fields, `${main}:${name}`, rel(file))
|
||
}
|
||
}
|
||
|
||
function buildReport() {
|
||
const schemas = getSchemas()
|
||
const sources = sourceFiles()
|
||
const collections = collectCollections(sources)
|
||
const fields = collectFields(sources, collections)
|
||
const missingSchemas = []
|
||
for (const [collection, files] of collections) {
|
||
if (!schemas.has(collection)) missingSchemas.push({ collection, files: [...new Set(files)] })
|
||
}
|
||
const fieldDrift = []
|
||
for (const [key, files] of fields) {
|
||
const [collection, field] = key.split(':')
|
||
const schema = schemas.get(collection)
|
||
if (!schema || schema.schema.__error) {
|
||
fieldDrift.push({ collection, field, files: [...new Set(files)], schema: schema ? schema.file : null, driftKind: 'missing-in-schema' })
|
||
} else if (!schema.schema.properties || !Object.prototype.hasOwnProperty.call(schema.schema.properties, field)) {
|
||
fieldDrift.push({ collection, field, files: [...new Set(files)], schema: schema.file, driftKind: 'page-field-typo' })
|
||
}
|
||
}
|
||
const knownMigrations = [
|
||
{
|
||
source: 'opendb-news-articles',
|
||
target: 'uni-cms-articles',
|
||
status: 'requires-review',
|
||
mapping: { avatar: 'thumbnail[]', content: 'content Delta/object', category_id: 'uni-cms-categories._id' }
|
||
},
|
||
{
|
||
source: 'opendb-news-comments',
|
||
target: 'comment',
|
||
status: 'missing-target-schema',
|
||
mapping: { comment_content: 'content', comment_date: 'create_time', comment_ip: 'ip_location' }
|
||
},
|
||
{
|
||
source: 'opendb-news-favorite',
|
||
target: 'comment_like',
|
||
status: 'requires-review',
|
||
mapping: { create_date: 'create_time', article_id: 'comment_id (semantic mismatch)' }
|
||
}
|
||
]
|
||
const report = {
|
||
generatedAt: new Date().toISOString(),
|
||
schemas: [...schemas.entries()].map(([name, value]) => ({ name, file: value.file })),
|
||
collections: [...collections.entries()].map(([name, files]) => ({ name, files: [...new Set(files)] })),
|
||
missingSchemas,
|
||
fieldDrift,
|
||
knownMigrations,
|
||
summary: {
|
||
schemaCount: schemas.size,
|
||
collectionCount: collections.size,
|
||
missingSchemaCount: missingSchemas.length,
|
||
fieldDriftCount: fieldDrift.length
|
||
}
|
||
}
|
||
fs.mkdirSync(reportDir, { recursive: true })
|
||
fs.writeFileSync(path.join(reportDir, 'data-contract-report.json'), JSON.stringify(report, null, 2) + '\n')
|
||
const lines = [
|
||
'# Data contract report',
|
||
'',
|
||
// G1收尾:去掉 Generated 时间戳行 —— 每次跑门禁必变导致 reports/*.md 常脏;
|
||
// 时间戳仍保留在 gitignored 的 data-contract-report.json(generatedAt)中备查
|
||
`- Schemas: ${report.summary.schemaCount}`,
|
||
`- Referenced collections: ${report.summary.collectionCount}`,
|
||
`- Missing schemas: ${report.summary.missingSchemaCount}`,
|
||
`- Field drift entries: ${report.summary.fieldDriftCount}`,
|
||
'',
|
||
'## Missing schemas',
|
||
''
|
||
]
|
||
if (!missingSchemas.length) lines.push('None', '')
|
||
for (const item of missingSchemas) lines.push(`- **${item.collection}** — ${item.files.join(', ')}`)
|
||
lines.push('', '## Field drift', '')
|
||
if (!fieldDrift.length) lines.push('None', '')
|
||
for (const item of fieldDrift.slice(0, 200)) lines.push(`- **${item.collection}.${item.field}** [\`${item.driftKind}\`] — ${item.files.join(', ')}`)
|
||
lines.push('', '## Migration review queue', '')
|
||
for (const item of knownMigrations) lines.push(`- **${item.source} → ${item.target}** (${item.status}) — ${JSON.stringify(item.mapping)}`)
|
||
fs.writeFileSync(path.join(reportDir, 'data-contract-report.md'), lines.join('\n') + '\n')
|
||
return report
|
||
}
|
||
|
||
const report = buildReport()
|
||
console.log(JSON.stringify(report.summary))
|