mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
build: add tsgo profiler
This commit is contained in:
@@ -133,6 +133,7 @@
|
||||
- `pnpm tsgo:extensions`: bundled extension production graph.
|
||||
- `pnpm tsgo:extensions:test`: bundled extension colocated tests.
|
||||
- `pnpm tsgo:all`: every TypeScript graph above; this is what `pnpm check` runs.
|
||||
- `pnpm tsgo:profile [core-test|extensions-test|--all]`: profile fresh graph cost into `.artifacts/tsgo-profile/`.
|
||||
- Narrow aliases remain for local loops: `pnpm tsgo:test:src`, `pnpm tsgo:test:ui`, `pnpm tsgo:test:packages`.
|
||||
- Do not add `tsc --noEmit`, `typecheck`, or `check:types` lanes for repo type checking. Use `tsgo` graphs. `tsc` is allowed only when emitting declaration/package-boundary compatibility artifacts that `tsgo` does not replace.
|
||||
- Boundary rule: core must not know extension implementation details. Extensions hook into core through manifests, registries, capabilities, and public `openclaw/plugin-sdk/*` contracts. If you find core production code naming a specific extension, or a core test that is really testing extension-owned behavior, call it out and prefer moving coverage/logic to the owning extension or a generic contract test.
|
||||
|
||||
@@ -1472,6 +1472,7 @@
|
||||
"tsgo:extensions": "node scripts/run-tsgo.mjs -p tsconfig.extensions.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions.tsbuildinfo",
|
||||
"tsgo:extensions:test": "node scripts/run-tsgo.mjs -p tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo",
|
||||
"tsgo:prod": "pnpm tsgo:core && pnpm tsgo:extensions",
|
||||
"tsgo:profile": "node scripts/profile-tsgo.mjs",
|
||||
"tsgo:test": "pnpm tsgo:core:test && pnpm tsgo:extensions:test",
|
||||
"tsgo:test:extensions": "pnpm tsgo:extensions:test",
|
||||
"tsgo:test:packages": "node scripts/run-tsgo.mjs -p tsconfig.test.packages.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-packages.tsbuildinfo",
|
||||
|
||||
454
scripts/profile-tsgo.mjs
Normal file
454
scripts/profile-tsgo.mjs
Normal file
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
acquireLocalHeavyCheckLockSync,
|
||||
applyLocalTsgoPolicy,
|
||||
shouldAcquireLocalHeavyCheckLockForTsgo,
|
||||
} from "./lib/local-heavy-check-runtime.mjs";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const artifactRoot = path.resolve(repoRoot, ".artifacts/tsgo-profile");
|
||||
const tsgoPath = path.resolve(repoRoot, "node_modules", ".bin", "tsgo");
|
||||
|
||||
const GRAPH_DEFINITIONS = {
|
||||
core: {
|
||||
config: "tsconfig.core.json",
|
||||
description: "core production graph",
|
||||
},
|
||||
"core-test": {
|
||||
config: "tsconfig.core.test.json",
|
||||
description: "core colocated test graph",
|
||||
},
|
||||
extensions: {
|
||||
config: "tsconfig.extensions.json",
|
||||
description: "bundled extension production graph",
|
||||
},
|
||||
"extensions-test": {
|
||||
config: "tsconfig.extensions.test.json",
|
||||
description: "bundled extension colocated test graph",
|
||||
},
|
||||
};
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage: pnpm tsgo:profile [graph...] [options]",
|
||||
"",
|
||||
"Graphs:",
|
||||
...Object.entries(GRAPH_DEFINITIONS).map(
|
||||
([name, graph]) => ` ${name.padEnd(16)} ${graph.description}`,
|
||||
),
|
||||
"",
|
||||
"Options:",
|
||||
" --all Profile all graphs",
|
||||
" --reuse Reuse profile tsbuildinfo files instead of forcing fresh checks",
|
||||
" --deep Also write --generateTrace and --generateCpuProfile artifacts",
|
||||
" --explain Also write --explainFiles artifacts",
|
||||
" --out=<dir> Output directory (default: .artifacts/tsgo-profile)",
|
||||
" --json Print JSON report to stdout",
|
||||
" --help Show this help",
|
||||
"",
|
||||
"Default graphs: core-test extensions-test",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const graphNames = [];
|
||||
const options = {
|
||||
all: false,
|
||||
deep: false,
|
||||
explain: false,
|
||||
json: false,
|
||||
reuse: false,
|
||||
outDir: artifactRoot,
|
||||
};
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
throw new Error(usage());
|
||||
}
|
||||
if (arg === "--all") {
|
||||
options.all = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--deep") {
|
||||
options.deep = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--explain") {
|
||||
options.explain = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
options.json = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--reuse") {
|
||||
options.reuse = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--out=")) {
|
||||
options.outDir = path.resolve(repoRoot, arg.slice("--out=".length));
|
||||
continue;
|
||||
}
|
||||
if (!GRAPH_DEFINITIONS[arg]) {
|
||||
throw new Error(`Unknown graph: ${arg}\n\n${usage()}`);
|
||||
}
|
||||
graphNames.push(arg);
|
||||
}
|
||||
|
||||
const selectedGraphs = options.all
|
||||
? Object.keys(GRAPH_DEFINITIONS)
|
||||
: graphNames.length > 0
|
||||
? graphNames
|
||||
: ["core-test", "extensions-test"];
|
||||
|
||||
return { options, selectedGraphs };
|
||||
}
|
||||
|
||||
function ensureDirs(outDir) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(outDir, "cache"), { recursive: true });
|
||||
}
|
||||
|
||||
function removeIfFreshMode(filePath, reuse) {
|
||||
if (!reuse) {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runTsgo(label, args, params = {}) {
|
||||
const { args: finalArgs, env } = applyLocalTsgoPolicy(args, process.env);
|
||||
const releaseLock = shouldAcquireLocalHeavyCheckLockForTsgo(finalArgs, env)
|
||||
? acquireLocalHeavyCheckLockSync({
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
toolName: "tsgo-profile",
|
||||
})
|
||||
: () => {};
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const result = spawnSync(tsgoPath, finalArgs, {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
maxBuffer: params.maxBuffer ?? 128 * 1024 * 1024,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const output = [stdout, stderr].filter(Boolean).join("\n");
|
||||
throw new Error(`${label} failed with exit code ${result.status ?? 1}\n${output}`);
|
||||
}
|
||||
return { elapsedMs, stdout, stderr };
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function parseDiagnostics(output) {
|
||||
const diagnostics = {};
|
||||
for (const line of output.split(/\r?\n/u)) {
|
||||
const match = /^(.+?):\s+([0-9.]+)(K|s)?\s*$/u.exec(line.trim());
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const [, rawKey, rawValue, unit] = match;
|
||||
const key = rawKey.trim().replaceAll(/\s+/gu, " ");
|
||||
const value = Number(rawValue);
|
||||
diagnostics[key] = unit === "K" ? value * 1024 : value;
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function normalizeFilePath(filePath) {
|
||||
const normalized = filePath.trim().replaceAll("\\", "/");
|
||||
const normalizedRoot = repoRoot.replaceAll("\\", "/");
|
||||
if (normalized.startsWith(`${normalizedRoot}/`)) {
|
||||
return normalized.slice(normalizedRoot.length + 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function packageNameFromNodeModule(parts, startIndex) {
|
||||
const first = parts[startIndex + 1];
|
||||
if (!first) {
|
||||
return "node_modules";
|
||||
}
|
||||
if (first.startsWith("@")) {
|
||||
return `${first}/${parts[startIndex + 2] ?? ""}`.replace(/\/$/u, "");
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
function classifyFile(relativePath) {
|
||||
const parts = relativePath.split("/");
|
||||
const first = parts[0];
|
||||
if (relativePath.includes("/node_modules/") || first === "node_modules") {
|
||||
const nodeModulesIndex = parts.indexOf("node_modules");
|
||||
return `node_modules/${packageNameFromNodeModule(parts, nodeModulesIndex)}`;
|
||||
}
|
||||
if (first === "extensions") {
|
||||
return `extensions/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first === "packages") {
|
||||
return `packages/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first === "src") {
|
||||
return `src/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first === "ui") {
|
||||
return `ui/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first === "test") {
|
||||
return `test/${parts[1] ?? "(root)"}`;
|
||||
}
|
||||
if (first.startsWith("/") || /^[A-Za-z]:/u.test(first)) {
|
||||
return "(external)";
|
||||
}
|
||||
return first || "(unknown)";
|
||||
}
|
||||
|
||||
function countBy(values, keyFn) {
|
||||
const counts = new Map();
|
||||
for (const value of values) {
|
||||
const key = keyFn(value);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
.toSorted((left, right) => right.count - left.count || left.key.localeCompare(right.key));
|
||||
}
|
||||
|
||||
function summarizeFiles(stdout) {
|
||||
const files = stdout
|
||||
.split(/\r?\n/u)
|
||||
.map(normalizeFilePath)
|
||||
.filter(Boolean)
|
||||
.filter((line) => !line.startsWith("Files:"));
|
||||
|
||||
const projectRelativeFiles = files.filter(
|
||||
(file) => !path.isAbsolute(file) && !/^[A-Za-z]:/u.test(file),
|
||||
);
|
||||
const testFiles = projectRelativeFiles.filter((file) => /\.test\.[cm]?[tj]sx?$/u.test(file));
|
||||
return {
|
||||
totalFiles: files.length,
|
||||
projectRelativeFiles: projectRelativeFiles.length,
|
||||
testFiles: testFiles.length,
|
||||
groups: countBy(projectRelativeFiles, classifyFile).slice(0, 40),
|
||||
};
|
||||
}
|
||||
|
||||
function diffDiagnostics(check, noCheck) {
|
||||
const totalDelta = (check["Total time"] ?? 0) - (noCheck["Total time"] ?? 0);
|
||||
const checkTime = check["Check time"] ?? 0;
|
||||
return {
|
||||
checkTimeSeconds: checkTime,
|
||||
totalDeltaSeconds: totalDelta,
|
||||
typeShareOfTotal:
|
||||
check["Total time"] && checkTime ? Number((checkTime / check["Total time"]).toFixed(3)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSeconds(value) {
|
||||
return `${value.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function renderTextReport(report) {
|
||||
const lines = [
|
||||
"# tsgo profile",
|
||||
"",
|
||||
`Generated: ${report.generatedAt}`,
|
||||
`Fresh profile caches: ${report.options.reuse ? "no" : "yes"}`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const graph of report.graphs) {
|
||||
const check = graph.check.diagnostics;
|
||||
const noCheck = graph.noCheck.diagnostics;
|
||||
lines.push(`## ${graph.name}`);
|
||||
lines.push(`Config: ${graph.config}`);
|
||||
lines.push(
|
||||
`Check: wall ${formatSeconds(graph.check.elapsedMs / 1000)}, compiler total ${formatSeconds(
|
||||
check["Total time"] ?? 0,
|
||||
)}, check ${formatSeconds(check["Check time"] ?? 0)}, memory ${Math.round(
|
||||
(check["Memory used"] ?? 0) / 1024 / 1024,
|
||||
)} MiB`,
|
||||
);
|
||||
lines.push(
|
||||
`NoCheck: wall ${formatSeconds(
|
||||
graph.noCheck.elapsedMs / 1000,
|
||||
)}, compiler total ${formatSeconds(noCheck["Total time"] ?? 0)}`,
|
||||
);
|
||||
lines.push(
|
||||
`Files: compiler ${check.Files ?? "?"}, listed ${graph.files.totalFiles}, project-relative ${graph.files.projectRelativeFiles}, tests ${graph.files.testFiles}`,
|
||||
);
|
||||
lines.push(`File list: ${graph.files.artifact}`);
|
||||
lines.push(
|
||||
`Type cost: check ${formatSeconds(graph.typeCost.checkTimeSeconds)}, total delta ${formatSeconds(
|
||||
graph.typeCost.totalDeltaSeconds,
|
||||
)}, share ${(graph.typeCost.typeShareOfTotal * 100).toFixed(1)}%`,
|
||||
);
|
||||
lines.push("Top file groups:");
|
||||
for (const group of graph.files.groups.slice(0, 15)) {
|
||||
lines.push(`- ${group.key}: ${group.count}`);
|
||||
}
|
||||
if (graph.deep) {
|
||||
lines.push(`Deep artifacts: ${graph.deep.traceDir}, ${graph.deep.cpuProfile}`);
|
||||
}
|
||||
if (graph.explain) {
|
||||
lines.push(`Explain: ${graph.explain.artifact}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`JSON: ${report.paths.json}`);
|
||||
lines.push("");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function profileGraph(name, options) {
|
||||
const graph = GRAPH_DEFINITIONS[name];
|
||||
const outDir = options.outDir;
|
||||
const graphCacheRoot = path.join(outDir, "cache");
|
||||
const checkBuildInfo = path.join(graphCacheRoot, `${name}-check.tsbuildinfo`);
|
||||
const noCheckBuildInfo = path.join(graphCacheRoot, `${name}-nocheck.tsbuildinfo`);
|
||||
const configPath = graph.config;
|
||||
|
||||
removeIfFreshMode(checkBuildInfo, options.reuse);
|
||||
removeIfFreshMode(noCheckBuildInfo, options.reuse);
|
||||
|
||||
const baseArgs = ["-p", configPath, "--pretty", "false"];
|
||||
const listFiles = runTsgo(`${name}:listFilesOnly`, [...baseArgs, "--listFilesOnly"], {
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
const filesArtifact = path.join(outDir, `${name}.files.txt`);
|
||||
fs.writeFileSync(filesArtifact, listFiles.stdout);
|
||||
const noCheck = runTsgo(`${name}:noCheck`, [
|
||||
...baseArgs,
|
||||
"--noCheck",
|
||||
"--incremental",
|
||||
"--tsBuildInfoFile",
|
||||
noCheckBuildInfo,
|
||||
"--extendedDiagnostics",
|
||||
]);
|
||||
|
||||
const checkArgs = [
|
||||
...baseArgs,
|
||||
"--incremental",
|
||||
"--tsBuildInfoFile",
|
||||
checkBuildInfo,
|
||||
"--extendedDiagnostics",
|
||||
];
|
||||
let deep;
|
||||
if (options.deep) {
|
||||
const traceDir = path.join(outDir, `${name}-trace`);
|
||||
const cpuProfile = path.join(outDir, `${name}.cpuprofile`);
|
||||
fs.rmSync(traceDir, { force: true, recursive: true });
|
||||
fs.rmSync(cpuProfile, { force: true });
|
||||
checkArgs.push("--generateTrace", traceDir, "--generateCpuProfile", cpuProfile);
|
||||
deep = {
|
||||
traceDir: path.relative(repoRoot, traceDir),
|
||||
cpuProfile: path.relative(repoRoot, cpuProfile),
|
||||
};
|
||||
}
|
||||
const check = runTsgo(`${name}:check`, checkArgs);
|
||||
let explain;
|
||||
if (options.explain) {
|
||||
const explainArtifact = path.join(outDir, `${name}.explain.txt`);
|
||||
const explainResult = runTsgo(`${name}:explainFiles`, [...baseArgs, "--explainFiles"], {
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
fs.writeFileSync(explainArtifact, `${explainResult.stdout}${explainResult.stderr}`);
|
||||
explain = {
|
||||
artifact: path.relative(repoRoot, explainArtifact),
|
||||
elapsedMs: explainResult.elapsedMs,
|
||||
};
|
||||
}
|
||||
|
||||
const checkDiagnostics = parseDiagnostics(`${check.stdout}\n${check.stderr}`);
|
||||
const noCheckDiagnostics = parseDiagnostics(`${noCheck.stdout}\n${noCheck.stderr}`);
|
||||
return {
|
||||
name,
|
||||
config: configPath,
|
||||
description: graph.description,
|
||||
files: {
|
||||
...summarizeFiles(listFiles.stdout),
|
||||
artifact: path.relative(repoRoot, filesArtifact),
|
||||
},
|
||||
noCheck: {
|
||||
elapsedMs: noCheck.elapsedMs,
|
||||
diagnostics: noCheckDiagnostics,
|
||||
},
|
||||
check: {
|
||||
elapsedMs: check.elapsedMs,
|
||||
diagnostics: checkDiagnostics,
|
||||
},
|
||||
typeCost: diffDiagnostics(checkDiagnostics, noCheckDiagnostics),
|
||||
...(deep ? { deep } : {}),
|
||||
...(explain ? { explain } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const { options, selectedGraphs } = parseArgs(argv);
|
||||
ensureDirs(options.outDir);
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
options: {
|
||||
graphs: selectedGraphs,
|
||||
deep: options.deep,
|
||||
explain: options.explain,
|
||||
reuse: options.reuse,
|
||||
},
|
||||
graphs: [],
|
||||
paths: {},
|
||||
};
|
||||
|
||||
for (const graphName of selectedGraphs) {
|
||||
process.stderr.write(`[tsgo-profile] profiling ${graphName}\n`);
|
||||
report.graphs.push(profileGraph(graphName, options));
|
||||
}
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replaceAll(":", "")
|
||||
.replaceAll(".", "")
|
||||
.replace("T", "-")
|
||||
.replace("Z", "");
|
||||
const jsonPath = path.join(options.outDir, `tsgo-profile-${timestamp}.json`);
|
||||
const textPath = path.join(options.outDir, `tsgo-profile-${timestamp}.md`);
|
||||
report.paths = {
|
||||
json: path.relative(repoRoot, jsonPath),
|
||||
text: path.relative(repoRoot, textPath),
|
||||
};
|
||||
|
||||
fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
fs.writeFileSync(textPath, renderTextReport(report));
|
||||
fs.writeFileSync(
|
||||
path.join(options.outDir, "latest.json"),
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
fs.writeFileSync(path.join(options.outDir, "latest.md"), renderTextReport(report));
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(renderTextReport(report));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user