Skip to content

Instantly share code, notes, and snippets.

@hSATAC
Last active March 27, 2026 19:30
Show Gist options
  • Select an option

  • Save hSATAC/ed6fd6f245ae443cbb8909bb3d84bada to your computer and use it in GitHub Desktop.

Select an option

Save hSATAC/ed6fd6f245ae443cbb8909bb3d84bada to your computer and use it in GitHub Desktop.
#!/usr/bin/env node
const fs = require("fs");
const COMPILE_DETAIL_TYPES = new Set([
"cCompilation",
"compileAssetsCatalog",
"compileStoryboard",
]);
const COMPILE_SIGNATURE_PREFIXES = ["SwiftCompile ", "SwiftGeneratePch "];
function isCompileStep(step) {
const detailStepType = step.detailStepType ?? "";
const signature = step.signature ?? "";
if (COMPILE_DETAIL_TYPES.has(detailStepType)) {
return true;
}
return COMPILE_SIGNATURE_PREFIXES.some((prefix) => signature.startsWith(prefix));
}
function collectTopLevelCompileSteps(step, results = []) {
for (const child of step.subSteps ?? []) {
if (isCompileStep(child)) {
results.push(child);
continue;
}
collectTopLevelCompileSteps(child, results);
}
return results;
}
function intervalFromStep(step) {
if (typeof step.startTimestamp !== "number" || typeof step.endTimestamp !== "number") {
return null;
}
return {
start: step.startTimestamp,
end: step.endTimestamp,
};
}
function computeActiveWallTime(intervals) {
if (intervals.length === 0) {
return 0;
}
const sortedIntervals = [...intervals].sort((a, b) => a.start - b.start);
let activeWallTime = 0;
let currentStart = sortedIntervals[0].start;
let currentEnd = sortedIntervals[0].end;
for (const interval of sortedIntervals.slice(1)) {
if (interval.start <= currentEnd) {
currentEnd = Math.max(currentEnd, interval.end);
continue;
}
activeWallTime += currentEnd - currentStart;
currentStart = interval.start;
currentEnd = interval.end;
}
activeWallTime += currentEnd - currentStart;
return activeWallTime;
}
function buildTargetMetrics(data, { minTaskSeconds = 0.5 } = {}) {
const results = [];
for (const step of data.subSteps ?? []) {
const title = step.title ?? "";
if (!title.startsWith("Build target ")) continue;
const moduleName = title.replace("Build target ", "");
const compileSteps = collectTopLevelCompileSteps(step);
const taskSeconds = compileSteps.reduce((sum, compileStep) => sum + (compileStep.duration ?? 0), 0);
if (taskSeconds < minTaskSeconds) continue;
const intervals = compileSteps.map(intervalFromStep).filter(Boolean);
const minStart = intervals.length > 0 ? Math.min(...intervals.map((interval) => interval.start)) : 0;
const maxEnd = intervals.length > 0 ? Math.max(...intervals.map((interval) => interval.end)) : 0;
const wallSpan = intervals.length > 0 ? maxEnd - minStart : 0;
const activeWallTime = computeActiveWallTime(intervals);
results.push({
moduleName,
taskSeconds,
wallSpan,
activeWallTime,
});
}
results.sort((a, b) => {
if (b.taskSeconds !== a.taskSeconds) return b.taskSeconds - a.taskSeconds;
if (b.wallSpan !== a.wallSpan) return b.wallSpan - a.wallSpan;
return a.moduleName.localeCompare(b.moduleName);
});
const totalTaskSeconds = results.reduce((sum, result) => sum + result.taskSeconds, 0);
return {
results,
totalTaskSeconds,
};
}
function formatSeconds(value) {
return `${value.toFixed(1)}s`;
}
function formatReport(data) {
const { results, totalTaskSeconds } = buildTargetMetrics(data);
const nameWidth = Math.max(6, ...results.map((result) => result.moduleName.length)) + 2;
const taskWidth = 10;
const pctWidth = 8;
const wallWidth = 10;
const barMaxWidth = 20;
const maxTaskSeconds = results[0]?.taskSeconds ?? 1;
const header =
"┌─" +
"─".repeat(nameWidth) +
"─┬────────────┬──────────┬────────────┬─" +
"─".repeat(barMaxWidth) +
"─┐";
const divider =
"├─" +
"─".repeat(nameWidth) +
"─┼────────────┼──────────┼────────────┼─" +
"─".repeat(barMaxWidth) +
"─┤";
const footer =
"└─" +
"─".repeat(nameWidth) +
"─┴────────────┴──────────┴────────────┴─" +
"─".repeat(barMaxWidth) +
"─┘";
const lines = [];
lines.push("");
lines.push("Xcode Build — Compile Metrics by Module");
lines.push(`Schema: ${data.schema ?? "unknown"}`);
lines.push("% Task = share of total Compile Task Seconds");
lines.push("Wall = first compile start to last compile end");
lines.push("");
lines.push(header);
lines.push(
"│ " +
"Module".padEnd(nameWidth) +
" │ " +
"Task".padStart(taskWidth) +
" │ " +
"% Task".padStart(pctWidth) +
" │ " +
"Wall".padStart(wallWidth) +
" │ " +
" ".repeat(barMaxWidth) +
" │"
);
lines.push(divider);
for (const result of results) {
const pct = totalTaskSeconds === 0 ? 0 : (result.taskSeconds / totalTaskSeconds) * 100;
const barLength = Math.round((result.taskSeconds / maxTaskSeconds) * barMaxWidth);
const bar = "█".repeat(barLength) + "░".repeat(barMaxWidth - barLength);
lines.push(
"│ " +
result.moduleName.padEnd(nameWidth) +
" │ " +
formatSeconds(result.taskSeconds).padStart(taskWidth) +
" │ " +
`${pct.toFixed(1)}%`.padStart(pctWidth) +
" │ " +
formatSeconds(result.wallSpan).padStart(wallWidth) +
" │ " +
bar +
" │"
);
}
lines.push(divider);
lines.push(
"│ " +
"TOTAL".padEnd(nameWidth) +
" │ " +
formatSeconds(totalTaskSeconds).padStart(taskWidth) +
" │ " +
"100.0%".padStart(pctWidth) +
" │ " +
"n/a".padStart(wallWidth) +
" │ " +
" ".repeat(barMaxWidth) +
" │"
);
lines.push(footer);
lines.push("");
return lines.join("\n");
}
function main() {
const filePath = process.argv[2];
if (!filePath) {
console.error("Usage: node parse-xcactivitylog.js <path-to-xcactivitylog.json>");
process.exit(1);
}
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
console.log(formatReport(data));
}
if (require.main === module) {
main();
}
module.exports = {
buildTargetMetrics,
collectTopLevelCompileSteps,
formatReport,
isCompileStep,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment