@openclaw/plugin-inspector 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
2
+
3
+ export const defaultPlatformTargets = ["linux", "macos", "windows", "container"];
4
+
5
+ export function buildPlatformProbes(options = {}) {
6
+ const plan = options.plan;
7
+ if (!plan) {
8
+ throw new TypeError("buildPlatformProbes requires an isolated workspace plan");
9
+ }
10
+
11
+ const targets = options.targets ?? defaultPlatformTargets;
12
+ const entrypoints = plan.fixtures.flatMap((fixture) =>
13
+ fixture.entrypoints.map((entrypoint) => summarizeEntrypoint(fixture.id, entrypoint)),
14
+ );
15
+ const portabilityFindings = plan.fixtures.flatMap((fixture) =>
16
+ fixture.entrypoints.flatMap((entrypoint) =>
17
+ entrypoint.steps
18
+ .map((step) => summarizeStep(fixture.id, entrypoint, step))
19
+ .filter((finding) => finding.riskCodes.length > 0),
20
+ ),
21
+ );
22
+
23
+ return {
24
+ generatedAt: plan.generatedAt,
25
+ mode: "plan-only",
26
+ targets,
27
+ summary: {
28
+ fixtureCount: plan.summary.fixtureCount,
29
+ entrypointCount: entrypoints.length,
30
+ tsLoaderEntrypointCount: entrypoints.filter((entrypoint) => entrypoint.loaderPrimary === "tsx").length,
31
+ jitiAlternativeCount: entrypoints.filter((entrypoint) => entrypoint.loaderAlternatives.includes("jiti")).length,
32
+ lazyImportProbeCount: entrypoints.filter((entrypoint) => entrypoint.capturePlanned && entrypoint.syntheticProbePlanned).length,
33
+ portabilityFindingCount: portabilityFindings.length,
34
+ windowsRiskStepCount: portabilityFindings.filter((finding) => finding.platforms.includes("windows")).length,
35
+ macosRiskStepCount: portabilityFindings.filter((finding) => finding.platforms.includes("macos")).length,
36
+ linuxRiskStepCount: portabilityFindings.filter((finding) => finding.platforms.includes("linux")).length,
37
+ containerRiskStepCount: portabilityFindings.filter((finding) => finding.platforms.includes("container")).length,
38
+ },
39
+ entrypoints,
40
+ portabilityFindings,
41
+ recommendations: buildRecommendations(portabilityFindings, entrypoints),
42
+ };
43
+ }
44
+
45
+ export function validatePlatformProbes(report, options = {}) {
46
+ const targets = options.targets ?? defaultPlatformTargets;
47
+ const errors = [];
48
+ if (report.mode !== "plan-only") {
49
+ errors.push("platform probes must stay plan-only in default checks");
50
+ }
51
+ if (!targets.every((target) => report.targets.includes(target))) {
52
+ errors.push(`platform probes must cover ${targets.join(", ")} targets`);
53
+ }
54
+ if (report.summary.tsLoaderEntrypointCount !== report.summary.jitiAlternativeCount) {
55
+ errors.push("all TypeScript loader entrypoints must track a Jiti fallback candidate");
56
+ }
57
+ for (const entrypoint of report.entrypoints) {
58
+ if (entrypoint.loaderPrimary === "tsx" && (!entrypoint.captureUsesTsx || !entrypoint.syntheticUsesTsx)) {
59
+ errors.push(`${entrypoint.id}: tsx loader strategy is not reflected in capture and synthetic commands`);
60
+ }
61
+ }
62
+ return errors;
63
+ }
64
+
65
+ export async function writePlatformProbes(report, options = {}) {
66
+ return writeJsonMarkdownArtifacts({
67
+ jsonPath: options.jsonPath,
68
+ markdownPath: options.markdownPath,
69
+ json: report,
70
+ markdown: renderPlatformProbesMarkdown(report, options),
71
+ check: options.check,
72
+ });
73
+ }
74
+
75
+ export function renderPlatformProbesMarkdown(report, options = {}) {
76
+ return [
77
+ `# ${options.title ?? "Plugin Inspector Platform And Loader Probes"}`,
78
+ "",
79
+ `Generated: ${report.generatedAt}`,
80
+ `Mode: ${report.mode}`,
81
+ `Targets: ${report.targets.join(", ")}`,
82
+ "",
83
+ "## Summary",
84
+ "",
85
+ markdownTable(Object.entries(report.summary).map(([key, value]) => [key, value]), ["Metric", "Value"]),
86
+ "",
87
+ "## Loader Probes",
88
+ "",
89
+ markdownTable(
90
+ report.entrypoints.map((entrypoint) => [
91
+ entrypoint.fixture,
92
+ entrypoint.status,
93
+ entrypoint.loaderPrimary,
94
+ entrypoint.loaderAlternatives.join(", ") || "-",
95
+ entrypoint.captureUsesTsx ? "yes" : "no",
96
+ entrypoint.syntheticUsesTsx ? "yes" : "no",
97
+ entrypoint.entrypoint,
98
+ ]),
99
+ ["Fixture", "Status", "Primary", "Alternatives", "Capture TSX", "Synthetic TSX", "Entrypoint"],
100
+ ),
101
+ "",
102
+ "## Portability Findings",
103
+ "",
104
+ markdownTable(
105
+ report.portabilityFindings.map((finding) => [
106
+ finding.fixture,
107
+ finding.kind,
108
+ finding.platforms.join(", ") || "-",
109
+ finding.riskCodes.join(", "),
110
+ finding.mitigation,
111
+ ]),
112
+ ["Fixture", "Step", "Platforms", "Risks", "Mitigation"],
113
+ ),
114
+ "",
115
+ "## Recommendations",
116
+ "",
117
+ markdownTable(
118
+ report.recommendations.map((recommendation) => [recommendation.area, recommendation.action]),
119
+ ["Area", "Action"],
120
+ ),
121
+ ].join("\n");
122
+ }
123
+
124
+ function summarizeEntrypoint(fixtureId, entrypoint) {
125
+ const captureStep = entrypoint.steps.find((step) => step.kind === "capture");
126
+ const syntheticStep = entrypoint.steps.find((step) => step.kind === "synthetic-probe");
127
+ return {
128
+ fixture: fixtureId,
129
+ id: entrypoint.id,
130
+ status: entrypoint.status,
131
+ entrypoint: entrypoint.entrypoint,
132
+ packageManager: entrypoint.packageManager,
133
+ loaderSource: entrypoint.loaderStrategy.source,
134
+ loaderPrimary: entrypoint.loaderStrategy.primary,
135
+ loaderAlternatives: entrypoint.loaderStrategy.alternatives,
136
+ capturePlanned: Boolean(captureStep),
137
+ syntheticProbePlanned: Boolean(syntheticStep),
138
+ captureUsesTsx: Boolean(captureStep?.command.includes("--import tsx")),
139
+ syntheticUsesTsx: Boolean(syntheticStep?.command.includes("--import tsx")),
140
+ };
141
+ }
142
+
143
+ function summarizeStep(fixtureId, entrypoint, step) {
144
+ const riskCodes = stepRiskCodes(step);
145
+ return {
146
+ fixture: fixtureId,
147
+ entrypoint: entrypoint.id,
148
+ kind: step.kind,
149
+ platforms: platformsForRiskCodes(riskCodes),
150
+ riskCodes,
151
+ command: step.command,
152
+ mitigation: mitigationForRiskCodes(riskCodes),
153
+ };
154
+ }
155
+
156
+ function stepRiskCodes(step) {
157
+ const risks = new Set();
158
+ if (/\bmkdir -p\b/.test(step.command)) {
159
+ risks.add("posix-mkdir");
160
+ }
161
+ if (/\brsync\b/.test(step.command)) {
162
+ risks.add("rsync-required");
163
+ }
164
+ if (/^[A-Z0-9_]+=/.test(step.command)) {
165
+ risks.add("posix-env-prefix");
166
+ }
167
+ if (/\|\|\s*true/.test(step.command)) {
168
+ risks.add("posix-null-failure");
169
+ }
170
+ if (/\s>\s/.test(step.command)) {
171
+ risks.add("shell-redirection");
172
+ }
173
+ if (step.command.includes("--import tsx")) {
174
+ risks.add("tsx-loader-runtime");
175
+ }
176
+ if (/^(pnpm|yarn|bun)\b/.test(step.command)) {
177
+ risks.add("package-manager-availability");
178
+ }
179
+ return [...risks].sort();
180
+ }
181
+
182
+ function platformsForRiskCodes(riskCodes) {
183
+ const platforms = new Set();
184
+ for (const code of riskCodes) {
185
+ if (["posix-mkdir", "rsync-required", "posix-env-prefix", "posix-null-failure"].includes(code)) {
186
+ platforms.add("windows");
187
+ }
188
+ if (["rsync-required", "package-manager-availability"].includes(code)) {
189
+ platforms.add("container");
190
+ }
191
+ if (code === "package-manager-availability") {
192
+ platforms.add("linux");
193
+ platforms.add("macos");
194
+ platforms.add("windows");
195
+ }
196
+ }
197
+ return [...platforms].sort();
198
+ }
199
+
200
+ function mitigationForRiskCodes(riskCodes) {
201
+ const mitigations = {
202
+ "package-manager-availability": "install the declared package manager before isolated execution",
203
+ "posix-env-prefix": "run isolated commands through a Node wrapper or set env via the runner API",
204
+ "posix-mkdir": "replace shell mkdir with fs.mkdir({ recursive: true }) in the executor",
205
+ "posix-null-failure": "capture audit failures in the executor instead of relying on shell fallthrough",
206
+ "rsync-required": "copy workspaces with a Node fs.cp fallback before Windows/container lanes",
207
+ "shell-redirection": "write audit JSON from the executor instead of shell redirection",
208
+ "tsx-loader-runtime": "verify TS source entrypoints with tsx and Jiti loader lanes",
209
+ };
210
+ return riskCodes.map((code) => mitigations[code]).filter(Boolean).join("; ");
211
+ }
212
+
213
+ function buildRecommendations(portabilityFindings, entrypoints) {
214
+ const recommendations = [];
215
+ if (entrypoints.some((entrypoint) => entrypoint.loaderPrimary === "tsx")) {
216
+ recommendations.push({
217
+ area: "loader",
218
+ action: "keep tsx as the source-entrypoint smoke path, add a Jiti execution lane before treating TS plugin source compatibility as covered",
219
+ });
220
+ }
221
+ if (portabilityFindings.some((finding) => finding.riskCodes.includes("rsync-required"))) {
222
+ recommendations.push({
223
+ area: "workspace-copy",
224
+ action: "move isolated workspace copy into Node fs.cp so Windows and slim containers do not depend on rsync",
225
+ });
226
+ }
227
+ if (portabilityFindings.some((finding) => finding.riskCodes.includes("posix-env-prefix"))) {
228
+ recommendations.push({
229
+ area: "executor",
230
+ action: "replace shell env-prefix commands with structured spawn env for Windows parity",
231
+ });
232
+ }
233
+ return recommendations;
234
+ }
235
+
236
+ function markdownTable(rows, headers) {
237
+ return renderPaddedMarkdownTable(rows, headers);
238
+ }
@@ -0,0 +1,117 @@
1
+ import { spawn } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ export async function runProfiledProcess(options) {
5
+ const start = performance.now();
6
+ const heapStartMb = heapUsedMb();
7
+ let firstRssKb = 0;
8
+ let peakRssKb = 0;
9
+ let peakCpuPercent = 0;
10
+ const cpuSamples = [];
11
+ let pollInFlight = false;
12
+
13
+ const child = spawn(options.command, options.args ?? [], {
14
+ cwd: options.cwd,
15
+ env: options.env,
16
+ stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
17
+ });
18
+ const stdout = [];
19
+ const stderr = [];
20
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
21
+ child.stderr?.on("data", (chunk) => stderr.push(chunk));
22
+
23
+ const recordStats = (stats) => {
24
+ if (stats.rssKb > 0 && firstRssKb === 0) {
25
+ firstRssKb = stats.rssKb;
26
+ }
27
+ peakRssKb = Math.max(peakRssKb, stats.rssKb);
28
+ peakCpuPercent = Math.max(peakCpuPercent, stats.cpuPercent);
29
+ if (stats.cpuPercent > 0) {
30
+ cpuSamples.push(stats.cpuPercent);
31
+ }
32
+ };
33
+
34
+ const poll = setInterval(() => {
35
+ if (pollInFlight) {
36
+ return;
37
+ }
38
+ pollInFlight = true;
39
+ readProcessStats(child.pid)
40
+ .then(recordStats)
41
+ .finally(() => {
42
+ pollInFlight = false;
43
+ });
44
+ }, options.pollMs ?? 100);
45
+
46
+ const exitCode = await new Promise((resolve, reject) => {
47
+ child.on("error", (error) => {
48
+ clearInterval(poll);
49
+ reject(error);
50
+ });
51
+ child.on("exit", (code) => resolve(code ?? 1));
52
+ });
53
+ clearInterval(poll);
54
+
55
+ const finalStats = await readProcessStats(child.pid);
56
+ if (finalStats.rssKb > 0 && firstRssKb === 0) {
57
+ firstRssKb = finalStats.rssKb;
58
+ }
59
+ peakRssKb = Math.max(peakRssKb, finalStats.rssKb);
60
+ peakCpuPercent = Math.max(peakCpuPercent, finalStats.cpuPercent);
61
+ if (finalStats.cpuPercent > 0) {
62
+ cpuSamples.push(finalStats.cpuPercent);
63
+ }
64
+
65
+ const wallMs = Math.round(performance.now() - start);
66
+ const averageCpuPercent =
67
+ cpuSamples.length > 0
68
+ ? cpuSamples.reduce((sum, value) => sum + value, 0) / cpuSamples.length
69
+ : 0;
70
+ const cpuPercentForEstimate =
71
+ options.roundAverageCpuPercent === true
72
+ ? Math.round(averageCpuPercent * 10) / 10
73
+ : averageCpuPercent;
74
+
75
+ return {
76
+ wallMs,
77
+ peakRssMb: Math.round((peakRssKb / 1024) * 10) / 10,
78
+ rssDeltaMb: Math.round(((peakRssKb - firstRssKb) / 1024) * 10) / 10,
79
+ peakCpuPercent: Math.round(peakCpuPercent * 10) / 10,
80
+ cpuMsEstimate: Math.round((wallMs * cpuPercentForEstimate) / 100),
81
+ harnessHeapDeltaMb: Math.round((heapUsedMb() - heapStartMb) * 10) / 10,
82
+ exitCode,
83
+ stdoutPreview: previewLines(stdout),
84
+ stderrPreview: previewLines(stderr),
85
+ };
86
+ }
87
+
88
+ async function readProcessStats(pid) {
89
+ if (!pid || process.platform === "win32") {
90
+ return { rssKb: 0, cpuPercent: 0 };
91
+ }
92
+ return new Promise((resolve) => {
93
+ const ps = spawn("ps", ["-o", "rss=", "-o", "%cpu=", "-p", String(pid)], {
94
+ stdio: ["ignore", "pipe", "ignore"],
95
+ });
96
+ const chunks = [];
97
+ ps.stdout.on("data", (chunk) => chunks.push(chunk));
98
+ ps.on("error", () => resolve({ rssKb: 0, cpuPercent: 0 }));
99
+ ps.on("exit", () => {
100
+ const [rssRaw, cpuRaw] = Buffer.concat(chunks).toString("utf8").trim().split(/\s+/);
101
+ const rssKb = Number.parseInt(rssRaw, 10);
102
+ const cpuPercent = Number.parseFloat(cpuRaw);
103
+ resolve({
104
+ rssKb: Number.isFinite(rssKb) ? rssKb : 0,
105
+ cpuPercent: Number.isFinite(cpuPercent) ? cpuPercent : 0,
106
+ });
107
+ });
108
+ });
109
+ }
110
+
111
+ function heapUsedMb() {
112
+ return Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 10) / 10;
113
+ }
114
+
115
+ function previewLines(chunks) {
116
+ return Buffer.concat(chunks).toString("utf8").trim().split("\n").slice(-2).join("\n");
117
+ }
@@ -0,0 +1,222 @@
1
+ import path from "node:path";
2
+ import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
3
+ import { readJsonFile, readOptionalJsonFile } from "./json-file.js";
4
+ import { resolveRequiredFromRoot } from "./path-utils.js";
5
+
6
+ export const defaultProfileDiffOptions = {
7
+ baselinePath: "baselines/runtime/main.json",
8
+ generatedAt: "deterministic",
9
+ jsonPath: "reports/plugin-runtime-profile-diff.json",
10
+ markdownPath: "reports/plugin-runtime-profile-diff.md",
11
+ policyPath: "plugin-inspector.policy.json",
12
+ reportTitle: "Plugin Runtime Profile Diff",
13
+ };
14
+
15
+ export async function buildProfileDiff(options = {}) {
16
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
17
+ const policy =
18
+ options.policy ?? (await readJsonFile(profileDiffPath(rootDir, options.policyPath ?? defaultProfileDiffOptions.policyPath)));
19
+ const current = options.current ?? (await readJsonFile(profileDiffPath(rootDir, options.currentPath)));
20
+ const baseline =
21
+ options.baseline ??
22
+ (await readOptionalJsonFile(profileDiffPath(rootDir, options.baselinePath ?? defaultProfileDiffOptions.baselinePath)));
23
+ const checks = baseline ? compareProfiles({ baseline, current, policy, strict: options.strict }) : [];
24
+
25
+ if (!baseline) {
26
+ checks.push({
27
+ id: "profile.baseline.missing",
28
+ action: "warn",
29
+ metric: "baseline",
30
+ message: "runtime profile baseline is missing",
31
+ baseline: null,
32
+ current: null,
33
+ delta: null,
34
+ });
35
+ }
36
+
37
+ return {
38
+ generatedAt: options.generatedAt ?? defaultProfileDiffOptions.generatedAt,
39
+ status: checks.some((check) => check.action === "fail") ? "fail" : "pass",
40
+ strict: Boolean(options.strict),
41
+ baseline: baseline ? profileSummary(baseline) : null,
42
+ current: profileSummary(current),
43
+ thresholds: policy.thresholds,
44
+ summary: {
45
+ checkCount: checks.length,
46
+ failCount: checks.filter((check) => check.action === "fail").length,
47
+ warnCount: checks.filter((check) => check.action === "warn").length,
48
+ passCount: checks.filter((check) => check.action === "pass").length,
49
+ },
50
+ checks,
51
+ };
52
+ }
53
+
54
+ export function validateProfileDiff(diff) {
55
+ return diff.checks
56
+ .filter((check) => check.action === "fail")
57
+ .map((check) => `${check.id}: ${check.message}: baseline=${check.baseline}, current=${check.current}`);
58
+ }
59
+
60
+ export async function writeProfileDiff(diff, options = {}) {
61
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
62
+ const jsonPath = profileDiffPath(rootDir, options.jsonPath ?? defaultProfileDiffOptions.jsonPath);
63
+ const markdownPath = profileDiffPath(rootDir, options.markdownPath ?? defaultProfileDiffOptions.markdownPath);
64
+ return writeJsonMarkdownArtifacts({
65
+ jsonPath,
66
+ markdownPath,
67
+ json: diff,
68
+ markdown: renderProfileDiffMarkdown(diff, options),
69
+ check: options.check,
70
+ });
71
+ }
72
+
73
+ export function renderProfileDiffMarkdown(diff, options = {}) {
74
+ const title = options.title ?? options.reportTitle ?? defaultProfileDiffOptions.reportTitle;
75
+ return [
76
+ `# ${title}`,
77
+ "",
78
+ `Generated: ${diff.generatedAt}`,
79
+ `Status: ${diff.status.toUpperCase()}`,
80
+ `Strict: ${diff.strict}`,
81
+ "",
82
+ "## Summary",
83
+ "",
84
+ markdownTable(
85
+ [
86
+ ["Checks", diff.summary.checkCount],
87
+ ["Fail", diff.summary.failCount],
88
+ ["Warn", diff.summary.warnCount],
89
+ ["Pass", diff.summary.passCount],
90
+ ["Current runs", diff.current.runs],
91
+ ["Baseline runs", diff.baseline?.runs ?? "-"],
92
+ ],
93
+ ["Metric", "Value"],
94
+ ),
95
+ "",
96
+ "## Checks",
97
+ "",
98
+ markdownTable(
99
+ diff.checks.map((check) => [
100
+ check.action,
101
+ check.id,
102
+ check.metric,
103
+ check.baseline ?? "-",
104
+ check.current ?? "-",
105
+ check.delta ?? "-",
106
+ check.percent === undefined ? "-" : `${check.percent}%`,
107
+ check.message,
108
+ ]),
109
+ ["Action", "ID", "Metric", "Baseline", "Current", "Delta", "Percent", "Message"],
110
+ ),
111
+ ].join("\n");
112
+ }
113
+
114
+ function compareProfiles({ baseline, current, policy, strict }) {
115
+ const thresholds = policy.thresholds;
116
+ const strictEligible = current.runs >= thresholds.strictMinimumSamples;
117
+ return [
118
+ percentCheck({
119
+ id: "profile.wall-p95",
120
+ metric: "p95WallMs",
121
+ baseline: baseline.summary.p95WallMs,
122
+ current: current.summary.p95WallMs,
123
+ threshold: thresholds.wallP95RegressionPercent,
124
+ strict,
125
+ strictEligible,
126
+ }),
127
+ absoluteCheck({
128
+ id: "profile.peak-rss",
129
+ metric: "maxPeakRssMb",
130
+ baseline: baseline.summary.maxPeakRssMb,
131
+ current: current.summary.maxPeakRssMb,
132
+ threshold: thresholds.peakRssRegressionMb,
133
+ strict,
134
+ strictEligible,
135
+ }),
136
+ absoluteCheck({
137
+ id: "profile.node-boot",
138
+ metric: "nodeBootWallMs",
139
+ baseline: commandWall(baseline, "node-boot"),
140
+ current: commandWall(current, "node-boot"),
141
+ threshold: thresholds.bootRegressionMs,
142
+ strict,
143
+ strictEligible,
144
+ }),
145
+ ...registrySurfaceChecks(baseline, current),
146
+ ];
147
+ }
148
+
149
+ function percentCheck({ id, metric, baseline, current, threshold, strict, strictEligible }) {
150
+ const delta = current - baseline;
151
+ const percent = baseline > 0 ? Math.round((delta / baseline) * 1000) / 10 : 0;
152
+ const exceeded = delta > 0 && percent > threshold;
153
+ return {
154
+ id,
155
+ action: exceeded ? (strict && strictEligible ? "fail" : "warn") : "pass",
156
+ metric,
157
+ message: exceeded
158
+ ? `${metric} regressed ${percent}% over baseline`
159
+ : `${metric} stayed within ${threshold}% regression threshold`,
160
+ baseline,
161
+ current,
162
+ delta,
163
+ percent,
164
+ };
165
+ }
166
+
167
+ function absoluteCheck({ id, metric, baseline, current, threshold, strict, strictEligible }) {
168
+ const delta = current - baseline;
169
+ const exceeded = delta > threshold;
170
+ return {
171
+ id,
172
+ action: exceeded ? (strict && strictEligible ? "fail" : "warn") : "pass",
173
+ metric,
174
+ message: exceeded
175
+ ? `${metric} regressed ${delta} over baseline`
176
+ : `${metric} stayed within ${threshold} absolute regression threshold`,
177
+ baseline,
178
+ current,
179
+ delta,
180
+ };
181
+ }
182
+
183
+ function registrySurfaceChecks(baseline, current) {
184
+ return [
185
+ "compatRecords",
186
+ "hookNames",
187
+ "apiRegistrars",
188
+ "capturedRegistrars",
189
+ "sdkExports",
190
+ "manifestFields",
191
+ "manifestContractFields",
192
+ ].map((metric) => ({
193
+ id: `registry.${metric}`,
194
+ action: "pass",
195
+ metric,
196
+ message: "registry surface delta is tracked as context",
197
+ baseline: baseline.targetOpenClaw[metric],
198
+ current: current.targetOpenClaw[metric],
199
+ delta: current.targetOpenClaw[metric] - baseline.targetOpenClaw[metric],
200
+ }));
201
+ }
202
+
203
+ function commandWall(profile, commandId) {
204
+ return profile.commands.find((command) => command.id === commandId)?.wallMs?.median ?? 0;
205
+ }
206
+
207
+ function profileSummary(profile) {
208
+ return {
209
+ runs: profile.runs,
210
+ summary: profile.summary,
211
+ targetOpenClaw: profile.targetOpenClaw,
212
+ fixtureInventory: profile.fixtureInventory,
213
+ };
214
+ }
215
+
216
+ function profileDiffPath(rootDir, candidatePath) {
217
+ return resolveRequiredFromRoot(rootDir, candidatePath, "profile diff");
218
+ }
219
+
220
+ function markdownTable(rows, headers) {
221
+ return renderPaddedMarkdownTable(rows, headers);
222
+ }