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 // 主集合判定: 标签内的 collection 绑定 const udbRe = /]*>/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))