Files
OneOS-V2/scripts/enrich-prototype-registry-from-git.mjs
2026-07-29 16:04:39 +08:00

340 lines
12 KiB
JavaScript
Raw 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.
/**
* 从 Git 提交历史生成/补全原型导航注册表中的详细变更日志。
*
* 用法:
* node scripts/enrich-prototype-registry-from-git.mjs
* node scripts/enrich-prototype-registry-from-git.mjs --prototype vehicle-h2-fee-ledger
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const REGISTRY_PATH = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/prototype-registry.json');
const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']);
const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']);
const MAX_CHANGELOG = 30;
const MAX_RECENT = 40;
function parseArgs(argv) {
const args = { prototype: '' };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--prototype' && argv[i + 1]) args.prototype = argv[++i].trim();
}
return args;
}
function shouldTrackFile(relativePath) {
const base = path.basename(relativePath);
if (IGNORED_FILE_NAMES.has(base)) return false;
if (relativePath === 'annotation-source.json') return false;
return TRACKED_EXTENSIONS.has(path.extname(relativePath).toLowerCase());
}
function normalizePrototypePath(filePath, prototypeId) {
const normalized = String(filePath || '').replace(/\\/gu, '/').trim();
const prefix = `src/prototypes/${prototypeId}/`;
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
return null;
}
function parseCommitBlocks(raw) {
const blocks = raw.split(/\n?----\n/u).map((block) => block.trim()).filter(Boolean);
const commits = [];
for (const block of blocks) {
const lines = block.split('\n');
const hash = lines[0]?.trim();
if (!/^[0-9a-f]{7,40}$/iu.test(hash || '')) continue;
const dateLine = lines[1]?.trim();
const subject = lines[2]?.trim() || '';
if (!dateLine) continue;
const fileStart = lines.findIndex((line, index) => index > 2 && line.trim() === '');
const bodyEnd = fileStart >= 0 ? fileStart : lines.length;
const body = lines.slice(3, bodyEnd).join('\n').trim();
const files = (fileStart >= 0 ? lines.slice(fileStart + 1) : [])
.map((line) => line.trim())
.filter(Boolean);
commits.push({ hash, dateLine, subject, body, files });
}
return commits;
}
function getPrototypeCommits(prototypeId) {
const relDir = `src/prototypes/${prototypeId}`;
if (!fs.existsSync(path.join(projectRoot, relDir))) return [];
let raw = '';
try {
raw = execSync(
`git log --pretty=format:----%n%H%n%ci%n%s%n%b --name-only -- ${relDir}`,
{ cwd: projectRoot, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 },
);
} catch {
return [];
}
return parseCommitBlocks(raw);
}
function formatDateParts(dateLine) {
const match = dateLine.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/u);
if (!match) {
const date = new Date(dateLine);
if (Number.isNaN(date.getTime())) return { date: '1970-01-01', time: '00:00', iso: null };
return {
date: date.toISOString().slice(0, 10),
time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`,
iso: date.toISOString(),
};
}
const [, y, m, d, hh, mm] = match;
return {
date: `${y}-${m}-${d}`,
time: `${hh}:${mm}`,
iso: new Date(`${y}-${m}-${d}T${hh}:${mm}:00+08:00`).toISOString(),
};
}
function extractBodyLines(body) {
return body
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !/^Co-authored-by:/iu.test(line))
.map((line) => line.replace(/^[-*•]\s*/u, '').trim())
.filter(Boolean);
}
function strictKeywords(title, prototypeId) {
const keywords = new Set([title, prototypeId]);
for (const part of String(title || '').split(/[·、/()\s「」]+/u)) {
const trimmed = part.trim();
if (trimmed.length >= 3) keywords.add(trimmed);
}
if (title.includes('氢费明细')) keywords.add('氢费明细');
else if (title.includes('氢费')) keywords.add('氢费');
if (title.includes('原型导航')) keywords.add('原型导航');
if (title.includes('合同模板')) keywords.add('合同模板');
if (title.includes('租赁合同')) keywords.add('租赁合同');
if (title.includes('租赁业务')) keywords.add('租赁业务');
if (title.includes('收款记录')) keywords.add('收款记录');
if (title.includes('维修保养')) keywords.add('维修保养');
if (title.includes('还车应结')) keywords.add('还车应结');
return [...keywords];
}
function lineMatchesPrototype(line, title, prototypeId) {
if (!line) return false;
if (line.includes(title)) return true;
if (line.toLowerCase().includes(prototypeId.toLowerCase())) return true;
return strictKeywords(title, prototypeId).some((keyword) => (
keyword.length >= 3 && line.includes(keyword)
));
}
function commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines) {
if (changedFiles.length > 0) return true;
const onlyAnnotation = allChangedFiles.length > 0
&& allChangedFiles.every((file) => file === 'annotation-source.json');
const textMatches = [commit.subject, ...bodyLines].some((line) => (
lineMatchesPrototype(line, title, prototypeId)
));
if (textMatches && onlyAnnotation) return true;
if (textMatches) return true;
return false;
}
function summarizeFileChanges(files) {
const notes = [];
const prd = files.filter((f) => f.includes('.spec/') && f.endsWith('.md'));
const comments = files.filter((f) => f.includes('prototype-comments') || f.includes('annotation-source'));
const styles = files.filter((f) => f.endsWith('.css'));
const logic = files.filter((f) => /\.(tsx|jsx|ts|js)$/u.test(f) && !f.endsWith('.css'));
const data = files.filter((f) => f.includes('/data/') || f.endsWith('.json'));
if (logic.length) {
const names = [...new Set(logic.map((f) => path.basename(f)))].slice(0, 4);
notes.push(`页面逻辑与交互:${names.join('、')}${logic.length > names.length ? ' 等' : ''}`);
}
if (styles.length) notes.push(`样式与布局:${[...new Set(styles.map((f) => path.basename(f)))].join('、')}`);
if (prd.length) notes.push(`需求说明与 PRD${prd.map((f) => path.basename(f)).join('、')}`);
if (comments.length) notes.push('原型标注目录与批注说明同步');
if (data.length) notes.push(`演示数据与配置:${data.map((f) => path.basename(f)).join('、')}`);
return notes;
}
function buildSummary(subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation) {
const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId));
if (matched.length === 1) return matched[0];
if (matched.length > 1) return matched[0];
const fragments = splitSubjectFragments(subject, prototypeId, title);
if (fragments.length === 1) return fragments[0];
if (fragments.length > 1) return fragments[0];
if (onlyAnnotation && changedFiles.length === 0) return '原型标注目录同步';
if (changedFiles.length) return summarizeFileChanges(changedFiles)[0] || subject;
if (lineMatchesPrototype(subject, title, prototypeId)) return subject;
return subject;
}
function dedupeDetails(summary, details) {
return [...new Set(details)].filter((line) => (
line
&& line !== summary
&& !summary.includes(line)
&& line.length > 2
));
}
function splitSubjectFragments(subject, prototypeId, title) {
const segments = subject
.split(/[;]/u)
.map((part) => part.trim())
.filter(Boolean);
const matched = segments.filter((segment) => lineMatchesPrototype(segment, title, prototypeId));
if (!matched.length) return [];
const fragments = [];
for (const segment of matched) {
const colonIndex = segment.indexOf('');
const tail = colonIndex >= 0 ? segment.slice(colonIndex + 1) : segment;
tail.split(/[、,]/u)
.map((part) => part.trim())
.filter((part) => part.length >= 4)
.forEach((part) => fragments.push(part));
}
return [...new Set(fragments)];
}
function buildDetails(subject, bodyLines, prototypeId, title, changedFiles) {
const details = [];
const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId));
details.push(...matched);
for (const fragment of splitSubjectFragments(subject, prototypeId, title)) {
if (!details.includes(fragment) && fragment !== subject) details.push(fragment);
}
for (const note of summarizeFileChanges(changedFiles)) {
if (!details.includes(note)) details.push(note);
}
if (!details.length && changedFiles.length) {
details.push(`变更 ${changedFiles.length} 个文件`);
}
return details.filter((line) => line !== subject);
}
function bumpVersion(indexFromOldest) {
return `v1.${indexFromOldest}`;
}
function buildChangelogFromGit(prototypeId, title) {
const commits = getPrototypeCommits(prototypeId);
if (!commits.length) return [];
const chronological = [...commits].reverse();
const entries = [];
for (const commit of chronological) {
const { date, time } = formatDateParts(commit.dateLine);
const bodyLines = extractBodyLines(commit.body);
const changedFiles = commit.files
.map((file) => normalizePrototypePath(file, prototypeId))
.filter((file) => file && shouldTrackFile(file));
const allChangedFiles = commit.files
.map((file) => normalizePrototypePath(file, prototypeId))
.filter(Boolean);
const onlyAnnotation = allChangedFiles.length > 0
&& allChangedFiles.every((file) => file === 'annotation-source.json');
if (!commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines)) {
continue;
}
const summary = buildSummary(commit.subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation);
const details = dedupeDetails(
summary,
buildDetails(commit.subject, bodyLines, prototypeId, title, changedFiles),
);
entries.push({
version: '',
date,
time,
summary,
details,
commit: commit.hash.slice(0, 7),
files: [...new Set(changedFiles)].slice(0, 12),
});
}
return entries.reverse().map((entry, index, list) => ({
...entry,
version: bumpVersion(list.length - index),
}));
}
function rebuildRecentUpdates(registry) {
const rows = [];
for (const [prototypeId, record] of Object.entries(registry.prototypes || {})) {
const latest = record.changelog?.[0];
if (!latest) continue;
rows.push({
prototypeId,
title: record.title || prototypeId,
version: latest.version,
date: latest.date,
time: latest.time,
summary: latest.summary,
details: latest.details || [],
files: latest.files || [],
commit: latest.commit,
_sort: `${latest.date}T${latest.time}`,
});
}
rows.sort((a, b) => b._sort.localeCompare(a._sort));
return rows.slice(0, MAX_RECENT).map(({ _sort, ...row }) => row);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
const prototypeIds = args.prototype
? [args.prototype]
: Object.keys(registry.prototypes || {});
let updated = 0;
for (const prototypeId of prototypeIds) {
const record = registry.prototypes[prototypeId];
if (!record) continue;
const changelog = buildChangelogFromGit(prototypeId, record.title || prototypeId);
if (!changelog.length) continue;
record.changelog = changelog.slice(0, MAX_CHANGELOG);
record.revision = changelog.length;
record.version = changelog[0].version;
const latestIso = getPrototypeCommits(prototypeId)[0];
if (latestIso) {
const { iso } = formatDateParts(latestIso.dateLine);
if (iso) record.lastUpdated = iso;
}
registry.prototypes[prototypeId] = record;
updated += 1;
console.log(`[git] ${prototypeId}: ${changelog.length} entries → ${changelog[0].version}`);
}
registry.recentUpdates = rebuildRecentUpdates(registry);
registry.updatedAt = new Date().toISOString();
fs.writeFileSync(REGISTRY_PATH, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
console.log(`完成:补全 ${updated} 个原型的 Git 变更日志`);
}
main();