返回 DeepSeek-Reasonix
size-report.mjs
根目录 / desktop / packaging / size-report.mjs
1 #!/usr/bin/env node
2 import { createHash } from "node:crypto";
3 import { execFileSync } from "node:child_process";
4 import { lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, writeFileSync } from "node:fs";
5 import { basename, join, relative, resolve } from "node:path";
6 import { pathToFileURL } from "node:url";
7
8 function toolVersion(command, args) {
9 try {
10 return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split(/\r?\n/)[0];
11 } catch {
12 return "unavailable";
13 }
14 }
15
16 function category(name) {
17 const path = name.toLowerCase();
18 if (/(^|\/)(reasonix-desktop)(\.exe)?$/.test(path)) return "service";
19 if (/electron framework|\/macos\/reasonix$|(^|\/)reasonix\.exe$/.test(path)) return "electron";
20 if (/(^|\/)(reasonix|reasonix-cli)(\.exe)?$/.test(path)) return "cli";
21 if (/helper|launcher|migrator|guard|uninstall/.test(path)) return "helper";
22 if (/app\.asar|\/app\/|index\.html|\.cjs$/.test(path)) return "frontend";
23 return "resources";
24 }
25
26 function sha256(path) {
27 return createHash("sha256").update(readFileSync(path)).digest("hex");
28 }
29
30 function bundledBuildInfo(root) {
31 const candidates = [];
32 const walk = (directory) => {
33 for (const entry of readdirSync(directory, { withFileTypes: true })) {
34 const path = join(directory, entry.name);
35 if (entry.isDirectory()) walk(path);
36 else if (entry.isFile() && entry.name === "build.json") candidates.push(path);
37 }
38 };
39 walk(root);
40 for (const path of candidates.sort()) {
41 try {
42 const value = JSON.parse(readFileSync(path, "utf8"));
43 if (value && typeof value === "object" && value.schemaVersion === 1) return value;
44 } catch {
45 // A non-Reasonix build.json remains visible in the file report.
46 }
47 }
48 return null;
49 }
50
51 export function inspectTree(root) {
52 const files = [];
53 const symlinks = [];
54 const walk = (directory) => {
55 for (const entry of readdirSync(directory, { withFileTypes: true })) {
56 const path = join(directory, entry.name);
57 const name = relative(root, path).replaceAll("\\", "/");
58 const stat = lstatSync(path);
59 if (stat.isSymbolicLink()) {
60 symlinks.push({ path: name, target: readlinkSync(path) });
61 } else if (stat.isDirectory()) {
62 walk(path);
63 } else if (stat.isFile()) {
64 files.push({
65 path: name,
66 bytes: stat.size,
67 diskBytes: typeof stat.blocks === "number" ? stat.blocks * 512 : null,
68 sha256: sha256(path),
69 category: category(name),
70 });
71 }
72 }
73 };
74 walk(root);
75 const categories = {};
76 for (const file of files) categories[file.category] = (categories[file.category] ?? 0) + file.bytes;
77 const duplicates = Object.values(Object.groupBy(files, (file) => file.sha256))
78 .filter((group) => group.length > 1)
79 .map((group) => ({ sha256: group[0].sha256, bytesEach: group[0].bytes, paths: group.map((file) => file.path) }))
80 .sort((a, b) => (b.bytesEach * b.paths.length) - (a.bytesEach * a.paths.length));
81 return {
82 logicalBytes: files.reduce((sum, file) => sum + file.bytes, 0),
83 diskBytes: files.every((file) => file.diskBytes !== null) ? files.reduce((sum, file) => sum + file.diskBytes, 0) : null,
84 fileCount: files.length,
85 categories,
86 largestFiles: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
87 duplicates,
88 symlinks,
89 };
90 }
91
92 function parseArgs(argv) {
93 const result = {};
94 for (let i = 0; i < argv.length; i += 2) {
95 if (!argv[i]?.startsWith("--") || argv[i + 1] === undefined) throw new Error(`invalid argument ${argv[i] ?? ""}`);
96 result[argv[i].slice(2)] = argv[i + 1];
97 }
98 for (const key of ["platform", "version", "bundle", "dist", "output"]) {
99 if (!result[key]) throw new Error(`missing --${key}`);
100 }
101 return result;
102 }
103
104 function markdown(report) {
105 const comparison = report.comparison ? [
106 "## Comparison",
107 "",
108 `- Baseline: ${report.comparison.baseline}`,
109 `- Download bytes delta: ${report.comparison.downloadBytesDelta ?? "unavailable"}`,
110 `- Bundle logical bytes delta: ${report.comparison.bundleLogicalBytesDelta ?? "unavailable"}`,
111 `- Bundle disk bytes delta: ${report.comparison.bundleDiskBytesDelta ?? "unavailable"}`,
112 ...report.comparison.artifactDeltas.map((item) => `- ${item.name}: ${item.bytesDelta ?? "no matching baseline"} bytes`),
113 "",
114 ] : [];
115 const lines = [
116 `# Reasonix package size report: ${report.platform}`,
117 "",
118 `- Version: \`${report.version}\``,
119 `- Source: \`${report.sourceSHA}\``,
120 `- Bundle logical size: ${report.bundle.logicalBytes} bytes`,
121 `- Bundle disk usage: ${report.bundle.diskBytes ?? "unavailable"} bytes`,
122 `- Build duration: ${report.metrics.buildSeconds ?? "unavailable"} seconds`,
123 `- Install/extract duration: ${report.metrics.installSeconds ?? "unavailable"} seconds`,
124 `- Temporary disk peak: ${report.metrics.temporaryPeakBytes ?? "unavailable"} bytes`,
125 "",
126 "## Download artifacts",
127 "",
128 "| File | Bytes | SHA-256 |",
129 "| --- | ---: | --- |",
130 ...report.artifacts.map((item) => `| ${item.name} | ${item.bytes} | \`${item.sha256}\` |`),
131 "",
132 "## Bundle categories",
133 "",
134 "| Category | Bytes |",
135 "| --- | ---: |",
136 ...Object.entries(report.bundle.categories).sort().map(([name, bytes]) => `| ${name} | ${bytes} |`),
137 "",
138 "## Largest files",
139 "",
140 "| Path | Bytes | Category |",
141 "| --- | ---: | --- |",
142 ...report.bundle.largestFiles.map((item) => `| ${item.path} | ${item.bytes} | ${item.category} |`),
143 "",
144 "## Symbolic links",
145 "",
146 ...(report.bundle.symlinks.length ? report.bundle.symlinks.map((item) => `- \`${item.path}\` → \`${item.target}\``) : ["None."]),
147 "",
148 "## Duplicate content",
149 "",
150 ...(report.bundle.duplicates.length ? report.bundle.duplicates.map((item) => `- ${item.bytesEach} bytes × ${item.paths.length}: ${item.paths.map((path) => `\`${path}\``).join(", ")}`) : ["None."]),
151 "",
152 ...comparison,
153 ];
154 return lines.join("\n");
155 }
156
157 export function generateReport(options) {
158 const distEntries = readdirSync(options.dist, { withFileTypes: true });
159 const platformName = options.platform.replace("/", "-");
160 const artifacts = distEntries
161 .filter((entry) => entry.isFile() && !entry.name.endsWith(".minisig") && entry.name.startsWith(`Reasonix-${platformName}`))
162 .map((entry) => {
163 const path = join(options.dist, entry.name);
164 return { name: entry.name, bytes: lstatSync(path).size, sha256: sha256(path) };
165 })
166 .sort((a, b) => a.name.localeCompare(b.name));
167 const buildInfo = bundledBuildInfo(options.bundle);
168 const report = {
169 schemaVersion: 1,
170 version: options.version,
171 sourceSHA: options.sourceSHA || toolVersion("git", ["rev-parse", "HEAD"]),
172 platform: options.platform,
173 tools: {
174 electron: options.electronVersion || buildInfo?.electron || "unavailable",
175 go: toolVersion("go", ["version"]),
176 node: process.version,
177 },
178 artifacts,
179 bundle: inspectTree(options.bundle),
180 metrics: {
181 buildSeconds: options.buildSeconds ? Number(options.buildSeconds) : null,
182 installSeconds: options.installSeconds ? Number(options.installSeconds) : null,
183 temporaryPeakBytes: options.temporaryPeakBytes ? Number(options.temporaryPeakBytes) : null,
184 },
185 buildInfo,
186 };
187 if (options.baseline) {
188 const baseline = JSON.parse(readFileSync(options.baseline, "utf8"));
189 const baselineArtifacts = new Map((baseline.artifacts ?? []).map((artifact) => [artifact.name, artifact]));
190 const currentDownloadBytes = report.artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
191 const baselineDownloadBytes = (baseline.artifacts ?? []).reduce((sum, artifact) => sum + Number(artifact.bytes ?? 0), 0);
192 report.comparison = {
193 baseline: options.baselineLabel || `${baseline.version ?? "unknown"} ${baseline.platform ?? ""}`.trim(),
194 artifactDeltas: report.artifacts.map((artifact) => ({
195 name: artifact.name,
196 baselineBytes: baselineArtifacts.get(artifact.name)?.bytes ?? null,
197 bytesDelta: baselineArtifacts.has(artifact.name) ? artifact.bytes - Number(baselineArtifacts.get(artifact.name).bytes) : null,
198 })),
199 downloadBytesDelta: report.artifacts.length === (baseline.artifacts ?? []).length && report.artifacts.every((artifact) => baselineArtifacts.has(artifact.name))
200 ? currentDownloadBytes - baselineDownloadBytes
201 : null,
202 bundleLogicalBytesDelta: baseline.bundle?.logicalBytes !== undefined
203 ? report.bundle.logicalBytes - Number(baseline.bundle.logicalBytes)
204 : null,
205 bundleDiskBytesDelta: report.bundle.diskBytes !== null && baseline.bundle?.diskBytes !== null
206 ? report.bundle.diskBytes - Number(baseline.bundle?.diskBytes ?? 0)
207 : null,
208 };
209 }
210 mkdirSync(options.output, { recursive: true });
211 writeFileSync(join(options.output, "size-report.json"), JSON.stringify(report, null, 2) + "\n");
212 writeFileSync(join(options.output, "size-report.md"), markdown(report));
213 return report;
214 }
215
216 if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
217 const args = parseArgs(process.argv.slice(2));
218 generateReport({
219 ...args,
220 bundle: resolve(args.bundle),
221 dist: resolve(args.dist),
222 output: resolve(args.output),
223 sourceSHA: process.env.REASONIX_COMMIT,
224 buildSeconds: process.env.REASONIX_BUILD_SECONDS,
225 installSeconds: process.env.REASONIX_INSTALL_SECONDS,
226 temporaryPeakBytes: process.env.REASONIX_TEMPORARY_PEAK_BYTES,
227 baseline: args.baseline || process.env.REASONIX_SIZE_BASELINE_REPORT,
228 baselineLabel: args["baseline-label"] || process.env.REASONIX_SIZE_BASELINE_LABEL,
229 });
230 }
231
231 lines Plain Text