diff --git a/AGENTS.md b/AGENTS.md index a9bee2eb312..e538115e730 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/package.json b/package.json index 613a553608c..1b2f4fad402 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/profile-tsgo.mjs b/scripts/profile-tsgo.mjs new file mode 100644 index 00000000000..f2a8d565edb --- /dev/null +++ b/scripts/profile-tsgo.mjs @@ -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= 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); +}