#!/usr/bin/env node import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; import { execFileSync } from "node:child_process"; const DEFAULT_SINCE_DAYS = 180; const DEFAULT_MODULE_DEPTH = 2; const DEFAULT_TOP_MODULES = 18; const DEFAULT_OUTPUT_DIR_NAME = "lime-project-heatmap"; const TEXT_FILE_EXTENSIONS = new Set([ ".cjs", ".conf", ".css", ".html", ".java", ".js", ".json", ".jsx", ".md", ".mdx", ".mjs", ".mts", ".ps1", ".py", ".rb", ".rs", ".scss", ".sh", ".sql", ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml", ]); const INCLUDED_FILE_NAMES = new Set([ "AGENTS.md", "CLAUDE.md", "Dockerfile", "LICENSE", "Makefile", "README.md", ]); const IGNORED_DIRECTORIES = new Set([ ".git", ".next", ".nuxt", ".output", ".turbo", "coverage", "dist", "node_modules", "out", "target", "target-codex-verify", "tmp", "vendor", "modified_files", ]); const IGNORED_FILES = new Set([ "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "Cargo.lock", ]); const COLOR_STOPS = [ { at: 0, color: "#ecfdf5" }, { at: 0.2, color: "#c7f9cc" }, { at: 0.45, color: "#6ee7b7" }, { at: 0.7, color: "#fbbf24" }, { at: 1, color: "#f97316" }, ]; const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); process.exit(0); } const gitCommand = process.platform === "win32" ? "git.exe" : "git"; const requestedRoot = path.resolve(options.root || process.cwd()); const repoRoot = resolveRepoRoot(requestedRoot); const outputDir = path.resolve( options.output || path.join( os.tmpdir(), `${path.basename(repoRoot) || DEFAULT_OUTPUT_DIR_NAME}-project-heatmap`, ), ); const scanResult = scanProjectFiles(repoRoot, options.moduleDepth); const gitChurn = collectGitChurn({ gitCommand, repoRoot, sinceDays: options.days, trackedFiles: scanResult.fileIndex, }); const report = buildReport({ repoRoot, scanResult, gitChurn, sinceDays: options.days, moduleDepth: options.moduleDepth, topModules: options.top, }); fs.mkdirSync(outputDir, { recursive: true }); const jsonOutputPath = path.join(outputDir, "project-heatmap.json"); const htmlOutputPath = path.join(outputDir, "index.html"); fs.writeFileSync(jsonOutputPath, JSON.stringify(report, null, 2), "utf8"); fs.writeFileSync(htmlOutputPath, renderHtml(report), "utf8"); console.log(`[heatmap] 报告已生成`); console.log(`[heatmap] HTML: ${htmlOutputPath}`); console.log(`[heatmap] JSON: ${jsonOutputPath}`); console.log(`[heatmap] 打开方式: file://${htmlOutputPath}`); function parseArgs(argv) { const result = { root: "", output: "", days: DEFAULT_SINCE_DAYS, moduleDepth: DEFAULT_MODULE_DEPTH, top: DEFAULT_TOP_MODULES, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--root" && argv[index + 1]) { result.root = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--output" && argv[index + 1]) { result.output = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--days" && argv[index + 1]) { result.days = normalizePositiveNumber(argv[index + 1], DEFAULT_SINCE_DAYS); index += 1; continue; } if (arg === "--depth" && argv[index + 1]) { result.moduleDepth = normalizePositiveNumber( argv[index + 1], DEFAULT_MODULE_DEPTH, ); index += 1; continue; } if (arg === "--top" && argv[index + 1]) { result.top = normalizePositiveNumber(argv[index + 1], DEFAULT_TOP_MODULES); index += 1; continue; } if (arg === "--help" || arg === "-h") { result.help = true; } } return result; } function normalizePositiveNumber(value, fallback) { const parsed = Number.parseInt(String(value), 10); if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } return parsed; } function printHelp() { console.log(` Lime 项目热力图生成器 用法: npm run heatmap:project npm run heatmap:project -- --days 90 npm run heatmap:project -- --root "../other-repo" --output "./tmp/heatmap" 选项: --root PATH 指定要分析的仓库路径,默认当前 Git 根目录 --output PATH 指定报告输出目录,默认系统临时目录 --days N 分析最近 N 天的 Git churn,默认 ${DEFAULT_SINCE_DAYS} --depth N 模块聚合目录深度,默认 ${DEFAULT_MODULE_DEPTH} --top N 矩阵热力图显示前 N 个热点模块,默认 ${DEFAULT_TOP_MODULES} -h, --help 显示帮助 说明: - 默认忽略 node_modules、dist、target 等目录 - 默认忽略 package-lock.json、pnpm-lock.yaml、Cargo.lock 等锁文件 - HTML 报告为纯本地静态文件,可直接使用浏览器打开 `); } function resolveRepoRoot(targetPath) { try { const output = execFileSync(gitCommand, ["-C", targetPath, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); if (output) { return path.resolve(output); } } catch { return targetPath; } return targetPath; } function scanProjectFiles(repoPath, moduleDepth) { const modules = new Map(); const files = []; const fileIndex = new Map(); walkDirectory(repoPath); const moduleList = Array.from(modules.values()).sort((left, right) => { if (right.loc !== left.loc) { return right.loc - left.loc; } return left.path.localeCompare(right.path); }); return { files, fileIndex, modules: moduleList, summary: { fileCount: files.length, loc: files.reduce((total, file) => total + file.loc, 0), }, }; function walkDirectory(currentDir) { const dirEntries = fs .readdirSync(currentDir, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)); for (const entry of dirEntries) { const absolutePath = path.join(currentDir, entry.name); const relativePath = toPosixPath(path.relative(repoPath, absolutePath)); if (entry.isDirectory()) { if (shouldIgnoreDirectory(entry.name, relativePath)) { continue; } walkDirectory(absolutePath); continue; } if (!entry.isFile()) { continue; } if (!shouldIncludeFile(entry.name, relativePath)) { continue; } const loc = countLinesSafely(absolutePath); const modulePath = resolveModulePath(relativePath, moduleDepth); const group = resolveGroup(modulePath); const fileRecord = { path: relativePath, modulePath, group, loc, }; files.push(fileRecord); fileIndex.set(relativePath, fileRecord); let moduleRecord = modules.get(modulePath); if (!moduleRecord) { moduleRecord = { id: modulePath, path: modulePath, group, loc: 0, fileCount: 0, }; modules.set(modulePath, moduleRecord); } moduleRecord.loc += loc; moduleRecord.fileCount += 1; } } } function shouldIgnoreDirectory(entryName, relativePath) { if (IGNORED_DIRECTORIES.has(entryName)) { return true; } const segments = relativePath.split("/").filter(Boolean); return segments.some((segment) => IGNORED_DIRECTORIES.has(segment)); } function shouldIncludeFile(fileName, relativePath) { if (IGNORED_FILES.has(fileName)) { return false; } if (relativePath.startsWith("docs/node_modules/")) { return false; } const extension = path.extname(fileName).toLowerCase(); if (TEXT_FILE_EXTENSIONS.has(extension)) { return true; } return INCLUDED_FILE_NAMES.has(fileName); } function countLinesSafely(filePath) { try { const content = fs.readFileSync(filePath, "utf8"); if (!content) { return 0; } return content.split(/\r?\n/).length; } catch { return 0; } } function resolveModulePath(relativePath, moduleDepth) { const parts = relativePath.split("/").filter(Boolean); if (parts.length <= 1) { return "(root)"; } const safeDepth = Math.max(1, moduleDepth); const moduleParts = parts.slice(0, Math.min(safeDepth, parts.length - 1)); return moduleParts.join("/"); } function resolveGroup(modulePath) { if (modulePath === "(root)") { return "root"; } const [firstSegment = "root"] = modulePath.split("/"); return firstSegment; } function collectGitChurn({ gitCommand, repoRoot, sinceDays, trackedFiles }) { const fileChurn = new Map(); const weekSet = new Set(); let gitAvailable = true; let commitCount = 0; let output = ""; try { output = execFileSync( gitCommand, [ "-C", repoRoot, "log", `--since=${sinceDays}.days`, "--numstat", "--date=short", "--format=format:@@@%cs", ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }, ); } catch { gitAvailable = false; } if (!gitAvailable || !output) { return { gitAvailable, commitCount, fileChurn, weeks: [], }; } let currentDate = ""; for (const line of output.split(/\r?\n/)) { if (!line) { continue; } if (line.startsWith("@@@")) { currentDate = line.slice(3).trim(); commitCount += 1; continue; } const parts = line.split("\t"); if (parts.length !== 3) { continue; } const [added, deleted, rawPath] = parts; if (added === "-" || deleted === "-" || !currentDate) { continue; } const normalizedPath = normalizeGitPath(rawPath); const trackedFile = trackedFiles.get(normalizedPath); if (!trackedFile) { continue; } const churn = Number.parseInt(added, 10) + Number.parseInt(deleted, 10); if (!Number.isFinite(churn) || churn <= 0) { continue; } const weekKey = toWeekKey(currentDate); weekSet.add(weekKey); let fileRecord = fileChurn.get(normalizedPath); if (!fileRecord) { fileRecord = { path: normalizedPath, churn: 0, weekly: {}, }; fileChurn.set(normalizedPath, fileRecord); } fileRecord.churn += churn; fileRecord.weekly[weekKey] = (fileRecord.weekly[weekKey] || 0) + churn; } return { gitAvailable, commitCount, fileChurn, weeks: Array.from(weekSet).sort(), }; } function normalizeGitPath(rawPath) { let normalized = rawPath.trim().replaceAll("\\", "/"); if (!normalized.includes("=>")) { return normalized; } normalized = normalized.replace( /\{([^{}]+)\s=>\s([^{}]+)\}/g, (_, _before, after) => after, ); if (normalized.includes("=>")) { const segments = normalized.split("=>"); normalized = segments[segments.length - 1].trim(); } return normalized.replaceAll("//", "/"); } function toWeekKey(dateString) { const date = new Date(`${dateString}T00:00:00Z`); const day = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - day); const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); const weekNumber = Math.ceil((((date - yearStart) / 86400000) + 1) / 7); return `${date.getUTCFullYear()}-W${String(weekNumber).padStart(2, "0")}`; } function buildReport({ repoRoot, scanResult, gitChurn, sinceDays, moduleDepth, topModules, }) { const modulesById = new Map(); for (const moduleRecord of scanResult.modules) { modulesById.set(moduleRecord.path, { ...moduleRecord, churn: 0, churnDensity: 0, weekly: {}, files: [], }); } const enrichedFiles = scanResult.files.map((file) => { const churnRecord = gitChurn.fileChurn.get(file.path); const churn = churnRecord?.churn || 0; const churnDensity = file.loc > 0 ? roundTo(churn / file.loc, 4) : 0; const weekly = churnRecord?.weekly || {}; const enriched = { ...file, churn, churnDensity, weekly, }; const moduleRecord = modulesById.get(file.modulePath); if (moduleRecord) { moduleRecord.churn += churn; moduleRecord.files.push({ path: file.path, loc: file.loc, churn, churnDensity, }); for (const [week, value] of Object.entries(weekly)) { moduleRecord.weekly[week] = (moduleRecord.weekly[week] || 0) + value; } } return enriched; }); const modules = Array.from(modulesById.values()) .map((moduleRecord) => ({ ...moduleRecord, activeWeekCount: Object.keys(moduleRecord.weekly).length, churnDensity: moduleRecord.loc > 0 ? roundTo(moduleRecord.churn / moduleRecord.loc, 4) : 0, files: moduleRecord.files .sort((left, right) => { if (right.churn !== left.churn) { return right.churn - left.churn; } return right.loc - left.loc; }) .slice(0, 6), })) .sort((left, right) => { if (right.churn !== left.churn) { return right.churn - left.churn; } if (right.loc !== left.loc) { return right.loc - left.loc; } return left.path.localeCompare(right.path); }); const hotFiles = enrichedFiles .filter((file) => file.churn > 0 || file.loc > 0) .sort((left, right) => { if (right.churn !== left.churn) { return right.churn - left.churn; } if (right.loc !== left.loc) { return right.loc - left.loc; } return left.path.localeCompare(right.path); }) .slice(0, 24); const lastTouchedByPath = resolveLastTouchedDates( repoRoot, hotFiles.map((file) => file.path), ); const hotFilesWithDates = hotFiles.map((file) => ({ ...file, lastTouchedAt: lastTouchedByPath.get(file.path) || "", })); const groups = buildGroups(modules); const weeks = gitChurn.weeks; const governance = buildGovernanceCandidates( modules, Math.max(8, Math.min(12, topModules)), ); return { meta: { repoName: path.basename(repoRoot), repoRoot: toPosixPath(repoRoot), generatedAt: new Date().toISOString(), sinceDays, moduleDepth, topModules, gitAvailable: gitChurn.gitAvailable, commitCount: gitChurn.commitCount, ignoredDirectories: Array.from(IGNORED_DIRECTORIES).sort(), ignoredFiles: Array.from(IGNORED_FILES).sort(), notes: [ "面积代表 LOC,颜色代表最近窗口内的 churn 强度。", "默认只统计当前仍存在的文件;历史已删除文件不会进入报告。", "重命名文件的早期历史可能无法完全归并到当前路径,这是当前 MVP 的已知取舍。", ], }, summary: { totalFiles: scanResult.summary.fileCount, totalLoc: scanResult.summary.loc, totalModules: modules.length, totalChurn: modules.reduce((total, moduleRecord) => total + moduleRecord.churn, 0), activeWeeks: weeks.length, }, weeks, groups, modules, hotFiles: hotFilesWithDates, governance, }; } function buildGovernanceCandidates(modules, topN) { if (modules.length === 0) { return { thresholds: { locHigh: 0, churnHigh: 0, densityHigh: 0, filesHigh: 0, activeWeeksHigh: 0, }, candidates: [], }; } const locValues = modules.map((moduleRecord) => moduleRecord.loc); const churnValues = modules.map((moduleRecord) => moduleRecord.churn); const densityValues = modules.map((moduleRecord) => moduleRecord.churnDensity); const fileCountValues = modules.map((moduleRecord) => moduleRecord.fileCount); const activeWeekValues = modules.map( (moduleRecord) => moduleRecord.activeWeekCount, ); const thresholds = { locHigh: percentileValue(locValues, 0.85), churnHigh: percentileValue(churnValues, 0.85), densityHigh: percentileValue(densityValues, 0.8), filesHigh: percentileValue(fileCountValues, 0.85), activeWeeksHigh: percentileValue(activeWeekValues, 0.8), }; const maxLoc = Math.max(...locValues, 1); const maxChurn = Math.max(...churnValues, 1); const maxDensity = Math.max(...densityValues, 0.0001); const maxFiles = Math.max(...fileCountValues, 1); const maxActiveWeeks = Math.max(...activeWeekValues, 1); const candidates = modules .map((moduleRecord) => { const sizeScore = Math.log1p(moduleRecord.loc) / Math.log1p(maxLoc); const churnScore = Math.log1p(moduleRecord.churn) / Math.log1p(Math.max(1, maxChurn)); const densityScore = moduleRecord.churnDensity / maxDensity; const scatterScore = Math.log1p(moduleRecord.fileCount) / Math.log1p(maxFiles); const persistenceScore = Math.log1p(moduleRecord.activeWeekCount) / Math.log1p(maxActiveWeeks); const governanceScore = roundTo( (sizeScore * 0.32 + churnScore * 0.28 + densityScore * 0.18 + scatterScore * 0.12 + persistenceScore * 0.1) * 100, 1, ); const reasons = buildGovernanceReasons(moduleRecord, thresholds); return { path: moduleRecord.path, group: moduleRecord.group, loc: moduleRecord.loc, churn: moduleRecord.churn, churnDensity: moduleRecord.churnDensity, fileCount: moduleRecord.fileCount, activeWeekCount: moduleRecord.activeWeekCount, governanceScore, severity: resolveGovernanceSeverity(governanceScore, reasons), reasons, suggestion: buildGovernanceSuggestion(reasons), }; }) .sort((left, right) => { if (right.governanceScore !== left.governanceScore) { return right.governanceScore - left.governanceScore; } if (right.churn !== left.churn) { return right.churn - left.churn; } return right.loc - left.loc; }) .slice(0, topN); return { thresholds: { ...thresholds, densityHigh: roundTo(thresholds.densityHigh, 4), }, candidates, }; } function percentileValue(values, percentile) { if (!values || values.length === 0) { return 0; } const sorted = [...values].sort((left, right) => left - right); const index = Math.min( sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * percentile)), ); return sorted[index]; } function buildGovernanceReasons(moduleRecord, thresholds) { const reasons = []; if (moduleRecord.loc >= thresholds.locHigh) { reasons.push("体量大"); } if (moduleRecord.churn >= thresholds.churnHigh && moduleRecord.churn > 0) { reasons.push("近期变更频繁"); } if ( moduleRecord.churnDensity >= thresholds.densityHigh && moduleRecord.churn > 0 ) { reasons.push("单位体量改动密"); } if (moduleRecord.fileCount >= thresholds.filesHigh) { reasons.push("文件分散"); } if ( moduleRecord.activeWeekCount >= thresholds.activeWeeksHigh && moduleRecord.activeWeekCount > 1 ) { reasons.push("持续发热"); } if (reasons.length === 0) { reasons.push("需要观察"); } return reasons; } function resolveGovernanceSeverity(governanceScore, reasons) { const hasScaleSignal = reasons.includes("体量大") || reasons.includes("文件分散"); const hasHeatSignal = reasons.includes("近期变更频繁") || reasons.includes("单位体量改动密"); if (governanceScore >= 80 && hasScaleSignal && hasHeatSignal) { return "立即治理"; } if (governanceScore >= 60 && reasons.length >= 2) { return "尽快治理"; } return "持续观察"; } function buildGovernanceSuggestion(reasons) { if (reasons.includes("体量大") && reasons.includes("文件分散")) { return "先定义唯一事实源,再收敛入口与目录边界。"; } if (reasons.includes("单位体量改动密") && reasons.includes("持续发热")) { return "先冻结抽象,再补守卫,避免继续长出平级实现。"; } if (reasons.includes("近期变更频繁")) { return "优先盘点入口层与服务层,找出重复分支后做减法。"; } return "先保持观测,等下一轮需求前确认是否要收口。"; } function buildGroups(modules) { const groupMap = new Map(); for (const moduleRecord of modules) { let group = groupMap.get(moduleRecord.group); if (!group) { group = { id: moduleRecord.group, label: moduleRecord.group === "root" ? "根目录" : moduleRecord.group, loc: 0, churn: 0, moduleCount: 0, fileCount: 0, }; groupMap.set(moduleRecord.group, group); } group.loc += moduleRecord.loc; group.churn += moduleRecord.churn; group.moduleCount += 1; group.fileCount += moduleRecord.fileCount; } const result = [ { id: "all", label: "全部", loc: modules.reduce((total, moduleRecord) => total + moduleRecord.loc, 0), churn: modules.reduce( (total, moduleRecord) => total + moduleRecord.churn, 0, ), moduleCount: modules.length, fileCount: modules.reduce( (total, moduleRecord) => total + moduleRecord.fileCount, 0, ), }, ...Array.from(groupMap.values()).sort((left, right) => { if (right.churn !== left.churn) { return right.churn - left.churn; } return right.loc - left.loc; }), ]; return result; } function resolveLastTouchedDates(repoRoot, filePaths) { const result = new Map(); if (filePaths.length === 0) { return result; } for (const filePath of filePaths) { try { const value = execFileSync( gitCommand, ["-C", repoRoot, "log", "-1", "--date=short", "--format=%cs", "--", filePath], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }, ).trim(); if (value) { result.set(filePath, value); } } catch { result.set(filePath, ""); } } return result; } function renderHtml(report) { const embeddedData = JSON.stringify(report).replace(/
这份报告聚合了当前仓库文件规模、最近 ${report.meta.sinceDays} 天 Git churn,以及按周汇总的模块活跃度。 面积优先回答“哪里大”,颜色优先回答“哪里热”,矩阵优先回答“什么时候热”。
先用总量判断项目体积,再用 churn 观察演化速度。
这里不是单纯看“谁最热”,而是综合了体量、近期 churn、单位体量热度、文件分散度和持续活跃度。 分数越高,越适合优先做“收口、减法、封老路”。
Treemap 使用模块级聚合结果:矩形面积代表 LOC,颜色深浅代表 churn/LOC。 这样既能看出大模块,也能看出“单位体量上特别热”的区域。
横轴是按周聚合的时间窗口,纵轴是当前筛选下 churn 最高的模块。 这张图最适合看“某一类模块是否持续发热”。
这里列出 churn 最高的当前文件,帮助从模块热区继续向下钻取到具体实现。
| 文件 | 模块 | LOC | ${report.meta.sinceDays} 天 churn | 热度密度 | 最近触达 |
|---|
这份报告刻意保持 KISS:只做项目观察最有用的三类指标,不混入过多推断。