#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { execFileSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { cruise } from "dependency-cruiser"; import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config"; import YAML from "yaml"; import { buildLegacySurfaceReport } from "./lib/legacy-surface-report-core.mjs"; import { buildRustModuleIndex, buildRustModulePathFromFile, createDirNodeId, expandRustUseTree, isFrontendCodePath, isPagePath, isRustCodePath, isTestLikePath, normalizePath, resolveMatchingGovernanceRule, resolveRustUseToFile, validateGovernanceRules, } from "./lib/governance-graph-core.mjs"; const DEFAULT_SINCE_DAYS = 30; const DEFAULT_OUTPUT_DIR = "./tmp/project-heatmap-governance"; const DEFAULT_RULES_PATH = "./governance/surfaces.yml"; const DEFAULT_KNIP_CONFIG = "./knip.governance.json"; const IGNORED_DIRECTORIES = new Set([ ".git", ".next", ".nuxt", ".output", ".turbo", "coverage", "dist", "node_modules", "out", "target", "target-codex-verify", "tmp", "vendor", "modified_files", ]); const FRONTEND_ENTRYPOINTS = ["src/main.tsx", "src/RootRouter.tsx"]; const STATUS_PRIORITY = new Map([ ["dead", 4], ["deprecated", 3], ["compat", 2], ["current", 1], ["unclassified", 0], ]); const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); process.exit(0); } const repoRoot = resolveRepoRoot(path.resolve(options.root || process.cwd())); const outputDir = path.resolve(repoRoot, options.output || DEFAULT_OUTPUT_DIR); const rulesPath = path.resolve(repoRoot, options.rules || DEFAULT_RULES_PATH); const knipConfigPath = path.resolve( repoRoot, options.knipConfig || DEFAULT_KNIP_CONFIG, ); const report = await buildGovernanceGraphReport({ repoRoot, outputDir, sinceDays: options.days, rulesPath, knipConfigPath, }); fs.mkdirSync(outputDir, { recursive: true }); const jsonOutputPath = path.join(outputDir, "governance-graph.json"); const htmlOutputPath = path.join(outputDir, "governance-graph.html"); fs.writeFileSync(jsonOutputPath, JSON.stringify(report, null, 2), "utf8"); fs.writeFileSync(htmlOutputPath, renderHtml(report), "utf8"); console.log("[governance-graph] 治理图谱已生成"); console.log(`[governance-graph] HTML: ${htmlOutputPath}`); console.log(`[governance-graph] JSON: ${jsonOutputPath}`); if (report.links.heatmapHtmlHref) { console.log( `[governance-graph] 同目录热力图: ${path.join(outputDir, "index.html")}`, ); } function parseArgs(argv) { const result = { root: "", output: "", rules: "", knipConfig: "", days: DEFAULT_SINCE_DAYS, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if ((arg === "-h" || arg === "--help") && !result.help) { result.help = true; continue; } 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 === "--rules" && argv[index + 1]) { result.rules = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--knip-config" && argv[index + 1]) { result.knipConfig = 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; } } 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 governance:graph npm run governance:graph -- --output "./tmp/project-heatmap-governance" npm run governance:graph -- --days 60 --rules "./governance/surfaces.yml" 选项: --root PATH 指定仓库根目录,默认当前目录 --output PATH 指定输出目录,默认 ${DEFAULT_OUTPUT_DIR} --rules PATH 指定治理规则 YAML,默认 ${DEFAULT_RULES_PATH} --knip-config PATH 指定 knip 配置,默认 ${DEFAULT_KNIP_CONFIG} --days N 最近 N 天 churn,默认 ${DEFAULT_SINCE_DAYS} -h, --help 显示帮助 `); } async function buildGovernanceGraphReport({ repoRoot, outputDir, sinceDays, rulesPath, knipConfigPath, }) { const fileInventory = collectGovernanceFiles(repoRoot); const fileIndex = new Map( fileInventory.map((fileRecord) => [fileRecord.path, fileRecord]), ); const gitCommand = process.platform === "win32" ? "git.exe" : "git"; const fileChurn = collectGitChurn({ gitCommand, repoRoot, sinceDays, trackedFiles: fileIndex, }); const frontendGraph = await collectFrontendDependencies(repoRoot, fileIndex); const rustGraph = collectRustDependencies(repoRoot, fileInventory, fileIndex); const governanceRules = loadGovernanceRules(rulesPath); const knipSignals = collectKnipSignals(repoRoot, knipConfigPath); const legacyReport = buildLegacySurfaceReport(); const legacyOverlays = buildLegacyOverlays(legacyReport); const frontendReachable = collectReachableFiles( FRONTEND_ENTRYPOINTS.filter((entryPath) => fileIndex.has(entryPath)), frontendGraph.edges, ); const baseNodes = buildFileNodes({ repoRoot, fileInventory, fileChurn, governanceRules, knipSignals, legacyOverlays, reachableFrontendPaths: frontendReachable, }); const edges = dedupeEdges([...frontendGraph.edges, ...rustGraph.edges]); const nodes = finalizeNodeSignals(baseNodes, edges, governanceRules); const dirNodes = buildDirectoryNodes(nodes); const allNodes = [...dirNodes, ...nodes]; const signalsSummary = summarizeSignals(nodes); const summary = buildSummary(nodes, edges, signalsSummary); const links = { selfHtmlHref: pathToFileURL(path.join(outputDir, "governance-graph.html")) .href, selfJsonHref: pathToFileURL(path.join(outputDir, "governance-graph.json")) .href, heatmapHtmlHref: fs.existsSync(path.join(outputDir, "index.html")) ? pathToFileURL(path.join(outputDir, "index.html")).href : "", }; return { generatedAt: new Date().toISOString(), repoRoot: normalizePath(repoRoot), rulesPath: normalizePath(path.relative(repoRoot, rulesPath)), knipConfigPath: normalizePath(path.relative(repoRoot, knipConfigPath)), summary, signalsSummary, nodes: allNodes, edges, links, }; } function resolveRepoRoot(targetPath) { const gitCommand = process.platform === "win32" ? "git.exe" : "git"; 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 collectGovernanceFiles(repoRoot) { const records = []; const roots = ["src", "src-tauri/src"]; for (const root of roots) { const absoluteRoot = path.join(repoRoot, root); if (!fs.existsSync(absoluteRoot)) { continue; } walkDirectory(absoluteRoot, repoRoot, (absolutePath) => { const relativePath = normalizePath(path.relative(repoRoot, absolutePath)); const isCodePath = isFrontendCodePath(relativePath) || isRustCodePath(relativePath); if (!isCodePath || isTestLikePath(relativePath)) { return; } const ext = path.extname(relativePath).toLowerCase(); const language = ext === ".rs" ? "rs" : ext.replace(/^\./, ""); const layer = relativePath.startsWith("src-tauri/src/") ? "rust" : "frontend"; records.push({ path: relativePath, kind: isPagePath(relativePath) ? "page" : "file", layer, language, loc: countLinesSafely(absolutePath), }); }); } return records.sort((left, right) => left.path.localeCompare(right.path)); } function walkDirectory(currentDir, repoRoot, onFile) { 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 = normalizePath(path.relative(repoRoot, absolutePath)); if (entry.isDirectory()) { if (shouldIgnoreDirectory(entry.name, relativePath)) { continue; } walkDirectory(absolutePath, repoRoot, onFile); continue; } if (entry.isFile()) { onFile(absolutePath); } } } function shouldIgnoreDirectory(entryName, relativePath) { if (IGNORED_DIRECTORIES.has(entryName)) { return true; } return relativePath .split("/") .filter(Boolean) .some((segment) => IGNORED_DIRECTORIES.has(segment)); } function countLinesSafely(filePath) { try { const content = fs.readFileSync(filePath, "utf8"); if (!content) { return 0; } return content.split(/\r?\n/).length; } catch { return 0; } } function collectGitChurn({ gitCommand, repoRoot, sinceDays, trackedFiles }) { const fileChurn = new Map(); 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 { return fileChurn; } let currentDate = ""; for (const line of output.split(/\r?\n/)) { if (!line) { continue; } if (line.startsWith("@@@")) { currentDate = line.slice(3).trim(); continue; } const parts = line.split("\t"); if (parts.length !== 3 || !currentDate) { continue; } const [added, deleted, rawPath] = parts; if (added === "-" || deleted === "-") { continue; } const normalizedGitPath = normalizeGitPath(rawPath); if (!trackedFiles.has(normalizedGitPath)) { continue; } const churn = Number.parseInt(added, 10) + Number.parseInt(deleted, 10); if (!Number.isFinite(churn) || churn <= 0) { continue; } fileChurn.set( normalizedGitPath, (fileChurn.get(normalizedGitPath) || 0) + churn, ); } return fileChurn; } function normalizeGitPath(rawPath) { let normalized = rawPath.trim().replaceAll("\\", "/"); if (!normalized.includes("=>")) { return normalized; } normalized = normalized.replace( /\{([^{}]+)\s=>\s([^{}]+)\}/g, (_match, _before, after) => after, ); if (normalized.includes("=>")) { const parts = normalized.split("=>"); normalized = parts[parts.length - 1].trim(); } return normalized.replaceAll("//", "/"); } async function collectFrontendDependencies(repoRoot, fileIndex) { const cruiseResult = await cruise( ["src"], { includeOnly: "^src", exclude: "(^|/)(node_modules|dist|coverage)(/|$)", }, undefined, { tsConfig: extractTSConfig(path.join(repoRoot, "tsconfig.json")), }, ); const edges = []; for (const moduleRecord of cruiseResult.output.modules ?? []) { const sourcePath = normalizePath(moduleRecord.source || ""); if (!fileIndex.has(sourcePath)) { continue; } for (const dependency of moduleRecord.dependencies ?? []) { const targetPath = normalizePath(dependency.resolved || ""); if ( !targetPath || dependency.couldNotResolve || !fileIndex.has(targetPath) || targetPath === sourcePath ) { continue; } edges.push({ id: `import:${sourcePath}->${targetPath}`, source: sourcePath, target: targetPath, kind: "import", }); } } return { edges }; } function collectRustDependencies(repoRoot, fileInventory, fileIndex) { const rustFiles = fileInventory.filter( (fileRecord) => fileRecord.layer === "rust", ); const rustFileSet = new Set(rustFiles.map((fileRecord) => fileRecord.path)); const moduleIndex = buildRustModuleIndex([...rustFileSet]); const edges = []; for (const fileRecord of rustFiles) { const absolutePath = path.join(repoRoot, fileRecord.path); const sourceCode = fs.readFileSync(absolutePath, "utf8"); const currentModulePath = buildRustModulePathFromFile(fileRecord.path) || ""; for (const moduleName of extractRustModDeclarations(sourceCode)) { const targetPath = resolveRustSubmodulePath( repoRoot, fileRecord.path, moduleName, ); if (!targetPath || !fileIndex.has(targetPath)) { continue; } edges.push({ id: `rust_mod:${fileRecord.path}->${targetPath}`, source: fileRecord.path, target: targetPath, kind: "rust_mod", }); } for (const statement of extractRustUseStatements(sourceCode)) { for (const usePath of expandRustUseTree(statement)) { const targetPath = resolveRustUseToFile( moduleIndex, currentModulePath, usePath, ); if (!targetPath || targetPath === fileRecord.path) { continue; } edges.push({ id: `rust_use:${fileRecord.path}->${targetPath}:${usePath}`, source: fileRecord.path, target: targetPath, kind: "rust_use", }); } } } return { edges }; } function extractRustModDeclarations(sourceCode) { const matches = sourceCode.matchAll( /(?:^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g, ); return [...matches].map((match) => match[1]); } function extractRustUseStatements(sourceCode) { const matches = sourceCode.matchAll( /(?:^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([\s\S]*?);/g, ); return [...matches].map((match) => `use ${String(match[1]).trim()};`); } function resolveRustSubmodulePath(repoRoot, relativePath, moduleName) { const normalizedPath = normalizePath(relativePath); const fileName = path.posix.basename(normalizedPath); const dirName = path.posix.dirname(normalizedPath); const moduleFolder = fileName === "mod.rs" || fileName === "main.rs" || fileName === "lib.rs" ? dirName : path.posix.join(dirName, fileName.replace(/\.rs$/u, "")); const directCandidate = path.posix.join(moduleFolder, `${moduleName}.rs`); if (fs.existsSync(path.join(repoRoot, directCandidate))) { return normalizePath(directCandidate); } const nestedCandidate = path.posix.join(moduleFolder, moduleName, "mod.rs"); if (fs.existsSync(path.join(repoRoot, nestedCandidate))) { return normalizePath(nestedCandidate); } return null; } function loadGovernanceRules(rulesPath) { if (!fs.existsSync(rulesPath)) { return []; } const document = YAML.parse(fs.readFileSync(rulesPath, "utf8")) || {}; const rules = document.rules || []; validateGovernanceRules(rules); return rules; } function collectKnipSignals(repoRoot, knipConfigPath) { if (!fs.existsSync(knipConfigPath)) { return { unusedFiles: new Set(), unusedExportsByFile: new Map(), }; } const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx"; const output = execFileSync( npxCommand, [ "--no-install", "knip", "--config", knipConfigPath, "--reporter", "json", "--include", "files,exports", "--no-exit-code", "--directory", repoRoot, ], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }, ); const parsed = JSON.parse(output); const unusedFiles = new Set(); const unusedExportsByFile = new Map(); for (const issue of parsed.issues ?? []) { for (const fileIssue of issue.files ?? []) { const filePath = normalizePath(fileIssue.name); if (isFrontendCodePath(filePath)) { unusedFiles.add(filePath); } } if ((issue.exports ?? []).length > 0) { const filePath = normalizePath(issue.file); if (!isFrontendCodePath(filePath)) { continue; } const exportNames = (unusedExportsByFile.get(filePath) ?? []).concat( issue.exports.map((item) => item.name), ); unusedExportsByFile.set(filePath, exportNames); } } return { unusedFiles, unusedExportsByFile, }; } function buildLegacyOverlays(legacyReport) { const surfaceOverlays = new Map(); const callsiteOverlays = new Map(); for (const result of legacyReport.importResults) { for (const targetPath of result.existingTargets) { pushOverlay(surfaceOverlays, targetPath, { source: "legacy-report", overlayType: "surface", monitorId: result.id, classification: result.classification, description: result.description, statusHint: mapLegacyClassificationToStatus(result.classification), }); } } for (const result of legacyReport.commandResults) { for (const references of result.referencesByCommand.values()) { for (const callerPath of references) { pushOverlay(callsiteOverlays, callerPath, { source: "legacy-report", overlayType: "callsite", monitorId: result.id, classification: result.classification, description: result.description, }); } } } for (const result of [ ...legacyReport.frontendTextResults, ...legacyReport.rustTextResults, ]) { for (const callerPath of result.references) { pushOverlay(callsiteOverlays, callerPath, { source: "legacy-report", overlayType: "callsite", monitorId: result.id, classification: result.classification, description: result.description, }); } } for (const result of legacyReport.rustTextCountResults) { for (const callerPath of result.runtimeMatches.map( (item) => item.relativePath, )) { pushOverlay(callsiteOverlays, callerPath, { source: "legacy-report", overlayType: "callsite", monitorId: result.id, classification: result.classification, description: result.description, }); } } return { surfaceOverlays, callsiteOverlays, }; } function pushOverlay(bucket, targetPath, overlay) { const normalizedPath = normalizePath(targetPath); const existing = bucket.get(normalizedPath) ?? []; existing.push(overlay); bucket.set(normalizedPath, existing); } function mapLegacyClassificationToStatus(classification) { if (classification === "compat" || classification === "deprecated") { return classification; } return null; } function buildFileNodes({ repoRoot, fileInventory, fileChurn, governanceRules, knipSignals, legacyOverlays, reachableFrontendPaths, }) { const nodes = []; for (const fileRecord of fileInventory) { const matchingRule = resolveMatchingGovernanceRule( fileRecord.path, governanceRules, ); const surfaceOverlays = legacyOverlays.surfaceOverlays.get(fileRecord.path) ?? []; const callsiteOverlays = legacyOverlays.callsiteOverlays.get(fileRecord.path) ?? []; const explicitStatus = matchingRule?.status ?? "unclassified"; const overlayStatus = pickOverlayStatus(surfaceOverlays); const status = explicitStatus !== "unclassified" ? explicitStatus : overlayStatus; const ruleSource = matchingRule ? { reason: matchingRule.reason || "", sourceOfTruth: matchingRule.sourceOfTruth || "", exitCriteria: matchingRule.exitCriteria || "", ignoreSignals: matchingRule.ignoreSignals || [], } : null; const overlayReason = surfaceOverlays[0]?.description || ""; const signals = new Set(); if (knipSignals.unusedFiles.has(fileRecord.path)) { signals.add("unused-file"); } if ( (knipSignals.unusedExportsByFile.get(fileRecord.path) ?? []).length > 0 ) { signals.add("unused-export"); } if ( surfaceOverlays.some( (overlay) => overlay.classification === "dead-candidate", ) ) { signals.add("dead-candidate"); } if (callsiteOverlays.length > 0) { signals.add("legacy-callsite"); } const node = { id: fileRecord.path, label: path.posix.basename(fileRecord.path), path: fileRecord.path, kind: fileRecord.kind, layer: fileRecord.layer, language: fileRecord.language, status, loc: fileRecord.loc, churn: fileChurn.get(fileRecord.path) || 0, size: calculateNodeSize(fileRecord.loc), href: pathToFileURL(path.join(repoRoot, fileRecord.path)).href, parent: createParentDirectoryId(fileRecord.path), reason: ruleSource?.reason || overlayReason, sourceOfTruth: ruleSource?.sourceOfTruth || "", exitCriteria: ruleSource?.exitCriteria || "", ignoredSignals: ruleSource?.ignoreSignals || [], overlays: [...surfaceOverlays, ...callsiteOverlays], signals: [...signals].sort(), reachable: fileRecord.layer === "frontend" ? reachableFrontendPaths.has(fileRecord.path) : true, }; nodes.push(node); } return nodes; } function finalizeNodeSignals(nodes, edges, governanceRules) { const fileNodesByPath = new Map(nodes.map((node) => [node.path, node])); const indegree = new Map(); for (const edge of edges) { indegree.set(edge.target, (indegree.get(edge.target) || 0) + 1); } const inboundIndex = buildInboundIndex(edges); return nodes.map((node) => { const signals = new Set(node.signals); if (!node.reachable && node.kind === "page" && node.layer === "frontend") { signals.add("page-unreachable"); } if ( (indegree.get(node.path) || 0) === 0 && !isProtectedEntryNode(node, governanceRules) ) { signals.add("zero-inbound"); } const inbound = inboundIndex.get(node.path) ?? []; if ( inbound.length > 0 && inbound.every((sourcePath) => { const sourceNode = fileNodesByPath.get(sourcePath); return ( sourceNode && ["compat", "deprecated", "dead"].includes(sourceNode.status) ); }) ) { signals.add("legacy-only-incoming"); } for (const ignoredSignal of node.ignoredSignals || []) { signals.delete(ignoredSignal); } return { ...node, signals: [...signals].sort(), candidateScore: calculateCandidateScore({ ...node, signals: [...signals], }), }; }); } function pickOverlayStatus(surfaceOverlays) { const candidates = surfaceOverlays .map((overlay) => overlay.statusHint || "unclassified") .filter((status) => status !== "unclassified"); if (candidates.length === 0) { return "unclassified"; } return candidates.sort( (left, right) => (STATUS_PRIORITY.get(right) || 0) - (STATUS_PRIORITY.get(left) || 0), )[0]; } function calculateNodeSize(loc) { return Math.max( 26, Math.min(86, Math.round(18 + Math.sqrt(Math.max(loc, 1)))), ); } function createParentDirectoryId(relativePath) { const dirPath = path.posix.dirname(relativePath); if (!dirPath || dirPath === ".") { return ""; } return createDirNodeId(dirPath); } function buildDirectoryNodes(fileNodes) { const dirMap = new Map(); for (const node of fileNodes) { const dirPath = path.posix.dirname(node.path); if (!dirPath || dirPath === ".") { continue; } const segments = dirPath.split("/").filter(Boolean); for (let length = 1; length <= segments.length; length += 1) { const currentPath = segments.slice(0, length).join("/"); if (dirMap.has(currentPath)) { continue; } dirMap.set(currentPath, { id: createDirNodeId(currentPath), label: segments[length - 1], path: currentPath, kind: "dir", layer: currentPath.startsWith("src-tauri/") ? "rust" : "frontend", parent: length > 1 ? createDirNodeId(segments.slice(0, length - 1).join("/")) : "", }); } } return [...dirMap.values()].sort((left, right) => left.path.localeCompare(right.path), ); } function collectReachableFiles(entryPaths, edges) { const adjacency = new Map(); const queue = [...entryPaths]; const visited = new Set(entryPaths); for (const edge of edges) { const currentTargets = adjacency.get(edge.source) ?? []; currentTargets.push(edge.target); adjacency.set(edge.source, currentTargets); } while (queue.length > 0) { const currentPath = queue.shift(); for (const nextPath of adjacency.get(currentPath) ?? []) { if (visited.has(nextPath)) { continue; } visited.add(nextPath); queue.push(nextPath); } } return visited; } function dedupeEdges(edges) { const edgeMap = new Map(); for (const edge of edges) { const key = `${edge.kind}:${edge.source}->${edge.target}`; if (!edgeMap.has(key)) { edgeMap.set(key, { ...edge, id: key, }); } } return [...edgeMap.values()]; } function buildInboundIndex(edges) { const inboundIndex = new Map(); for (const edge of edges) { const entries = inboundIndex.get(edge.target) ?? []; entries.push(edge.source); inboundIndex.set(edge.target, entries); } return inboundIndex; } function isProtectedEntryNode(node, governanceRules) { if (node.status === "current") { return true; } if (FRONTEND_ENTRYPOINTS.includes(node.path)) { return true; } if (/(?:^|\/)(main|lib)\.rs$/u.test(node.path)) { return true; } return ( resolveMatchingGovernanceRule(node.path, governanceRules)?.status === "current" ); } function calculateCandidateScore(node) { let score = 0; for (const signal of node.signals) { if (signal === "unused-file") { score += 5; } else if (signal === "page-unreachable") { score += 5; } else if (signal === "dead-candidate") { score += 4; } else if (signal === "zero-inbound") { score += 2; } else if (signal === "legacy-only-incoming") { score += 2; } else if (signal === "unused-export") { score += 1; } else if (signal === "legacy-callsite") { score += 1; } } return score; } function summarizeSignals(nodes) { const counts = new Map(); for (const node of nodes) { for (const signal of node.signals) { counts.set(signal, (counts.get(signal) || 0) + 1); } } return [...counts.entries()] .map(([signal, count]) => ({ signal, count })) .sort( (left, right) => right.count - left.count || left.signal.localeCompare(right.signal), ); } function buildSummary(nodes, edges, signalsSummary) { const fileNodes = nodes.filter((node) => node.kind !== "dir"); const statusCounts = Object.fromEntries( ["current", "compat", "deprecated", "dead", "unclassified"].map( (status) => [ status, fileNodes.filter((node) => node.status === status).length, ], ), ); const layerCounts = Object.fromEntries( ["frontend", "rust"].map((layer) => [ layer, fileNodes.filter((node) => node.layer === layer).length, ]), ); const topCandidates = fileNodes .filter((node) => node.candidateScore > 0) .sort( (left, right) => right.candidateScore - left.candidateScore || right.churn - left.churn || left.path.localeCompare(right.path), ) .slice(0, 20) .map((node) => ({ path: node.path, status: node.status, candidateScore: node.candidateScore, signals: node.signals, churn: node.churn, })); return { nodeCount: fileNodes.length, edgeCount: edges.length, pageCount: fileNodes.filter((node) => node.kind === "page").length, statusCounts, layerCounts, topCandidates, signalsSummary, }; } function renderHtml(report) { const payload = serializeForHtml(report); return ` Lime 治理图谱

Lime 治理图谱

文件 / 页面级依赖图 + 治理标签 + 疑似失效信号。状态只来自 governance/surfaces.yml 与现有治理规则;自动脚本只补 signals 与 overlay。

ASCII 视图

默认先看治理总览与批量治理工作台;点击任意候选后,再看单节点的上游 / 当前 / 下游。


        
`; } function serializeForHtml(value) { return JSON.stringify(value) .replaceAll("<", "\\u003c") .replaceAll(">", "\\u003e") .replaceAll("&", "\\u0026"); } function escapeAttribute(value) { return String(value).replaceAll('"', """); }