@openclaw/plugin-inspector 0.3.24 → 0.3.26

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.
@@ -1,12 +1,12 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
4
+ import { readBoundedJsonArtifact, renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
5
5
  import { resolveFromRoot } from "./path-utils.js";
6
- import { runProfiledProcess } from "./process-profile.js";
6
+ import { resolveProcessLimits, runProfiledProcess } from "./process-profile.js";
7
7
  import { assertRunCount, percentile } from "./stats.js";
8
8
 
9
- const defaultCliPath = fileURLToPath(new URL("./cli.js", import.meta.url));
9
+ const defaultRunnerPath = fileURLToPath(new URL("./mock-sdk-capture-runner.js", import.meta.url));
10
10
 
11
11
  export const defaultImportLoopProfileOptions = {
12
12
  entrypoint: "test/fixtures/lazy-import-plugin.mjs",
@@ -255,18 +255,49 @@ async function runCaptureSample(options) {
255
255
  const outputPath = path.join(outputDir, `${options.sampleName ?? "capture"}-${options.index}.json`);
256
256
  await mkdir(path.dirname(outputPath), { recursive: true });
257
257
 
258
- const command = buildCaptureCommand({ ...options, outputPath });
258
+ const defaultCapture = typeof options.captureCommand !== "function" && !options.captureScript;
259
+ const maxOutputBytes = resolveProcessLimits({
260
+ ...options,
261
+ env: { ...process.env, ...options.env, ...options.captureEnv },
262
+ }, "CAPTURE").maxOutputBytes;
263
+ // Only the built-in route owns these sample files. An early process.exit(0)
264
+ // must not turn a previous capture into this run's successful result.
265
+ if (defaultCapture) await rm(outputPath, { force: true });
266
+ const command = buildCaptureCommand({ ...options, outputPath, maxOutputBytes });
259
267
  const profile = await runProfiledProcess({
260
268
  command: command.command,
261
269
  args: command.args,
262
270
  cwd: command.cwd ?? options.rootDir,
263
- env: { ...process.env, ...command.env },
271
+ env: { ...process.env, ...options.env, ...command.env },
272
+ timeoutMs: options.timeoutMs,
273
+ maxOutputBytes: options.maxOutputBytes,
274
+ killGraceMs: options.killGraceMs,
275
+ signal: options.signal,
264
276
  });
265
- const output = profile.exitCode === 0 ? await readCaptureOutput(outputPath) : null;
277
+ let output = null;
278
+ if (profile.exitCode === 0 && !profile.timedOut && !profile.cancelled) {
279
+ if (defaultCapture) {
280
+ try {
281
+ output = await readCaptureOutput(outputPath, maxOutputBytes);
282
+ } catch (error) {
283
+ profile.exitCode = 1;
284
+ profile.stderrPreview = `Invalid capture artifact: ${error.message}`;
285
+ }
286
+ } else {
287
+ output = await readCaptureOutput(outputPath);
288
+ }
289
+ }
290
+ if (options.signal?.aborted) {
291
+ profile.exitCode = 1;
292
+ profile.cancelled = true;
293
+ output = null;
294
+ }
266
295
 
267
296
  return {
268
297
  index: options.index,
269
298
  exitCode: profile.exitCode,
299
+ timedOut: profile.timedOut === true,
300
+ cancelled: profile.cancelled === true,
270
301
  status: output?.status ?? "failed",
271
302
  capturedCount: output?.captured?.length ?? 0,
272
303
  openClawLifecycle: output?.openClawLifecycle ?? null,
@@ -407,15 +438,29 @@ function buildCaptureCommand(options) {
407
438
  }
408
439
  return {
409
440
  command: process.execPath,
410
- args: [defaultCliPath, "capture", options.entrypoint, "--output", options.outputPath],
441
+ args: [
442
+ "--no-warnings",
443
+ "--preserve-symlinks",
444
+ defaultRunnerPath,
445
+ JSON.stringify({
446
+ entrypoint: options.entrypoint,
447
+ cwd: options.rootDir,
448
+ outputPath: options.outputPath,
449
+ maxOutputBytes: options.maxOutputBytes,
450
+ }),
451
+ ],
411
452
  cwd: options.rootDir,
412
453
  env: { PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1", ...options.captureEnv },
413
454
  };
414
455
  }
415
456
 
416
- async function readCaptureOutput(outputPath) {
417
- const { readFile } = await import("node:fs/promises");
418
- return JSON.parse(await readFile(outputPath, "utf8"));
457
+ async function readCaptureOutput(outputPath, maxOutputBytes) {
458
+ if (maxOutputBytes === undefined) return JSON.parse(await readFile(outputPath, "utf8"));
459
+ const result = await readBoundedJsonArtifact(outputPath, maxOutputBytes);
460
+ if (!result || typeof result.status !== "string" || !Array.isArray(result.captured)) {
461
+ throw new Error("expected a capture status and captured contracts");
462
+ }
463
+ return result;
419
464
  }
420
465
 
421
466
  function markdownTable(rows, headers) {
package/src/inspector.js CHANGED
@@ -1,20 +1,18 @@
1
1
  import { existsSync } from "node:fs";
2
- import { execFile } from "node:child_process";
3
2
  import { readdir, readFile } from "node:fs/promises";
4
- import * as nodeModule from "node:module";
5
3
  import path from "node:path";
6
4
  import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { promisify } from "node:util";
8
5
  import { createCaptureApi } from "./capture-api.js";
9
6
  import { captureApiOptionsForPlugin } from "./capture-config.js";
10
7
  import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js";
11
8
  import { buildCompatibilityFixtureReport } from "./fixture-summary.js";
12
9
  import { readOpenClawTargetSurface } from "./openclaw-target.js";
13
10
  import { prepareOpenClawTarget, resolveOpenClawTargetVersion } from "./openclaw-version.js";
11
+ import { resolveProcessLimits, startOwnedProcess } from "./process-profile.js";
14
12
  import { buildCompatibilityReport, buildReport } from "./report.js";
15
13
  import { inspectSdkDeprecations } from "./sdk-deprecation-rules.js";
14
+ import { collectRuntimeModuleImports } from "./runtime-imports.js";
16
15
 
17
- const execFileAsync = promisify(execFile);
18
16
  const pluginFactoryNames = "defineBundledChannelEntry|defineChannelPluginEntry|createChatChannelPlugin|definePluginEntry";
19
17
  // Bundlers emit unbound calls as (0, sdk.factory)(...), including inline require receivers.
20
18
  const compiledFactoryCall = new RegExp(String.raw`\(\s*0\s*,\s*(?:require\s*\(\s*(?:"[^"\r\n]*"|'[^'\r\n]*')\s*\)|[$A-Z_a-z][$\w]*)(?:\s*\.\s*[$A-Z_a-z][$\w]*)*\s*\.\s*(${pluginFactoryNames})\s*\)\s*\(`, "dg");
@@ -29,6 +27,13 @@ export async function inspectFixtureSet(config, options = {}) {
29
27
 
30
28
  export async function inspectCompatibilityFixtureSet(config, options = {}) {
31
29
  const { inspections, failures } = await inspectConfiguredFixtures(config, options);
30
+ const reportConfig = {
31
+ ...config,
32
+ fixtures: config.fixtures.map((fixture) => ({
33
+ ...fixture,
34
+ checkoutPath: normalizeFixtureCheckoutPath(config, fixture),
35
+ })),
36
+ };
32
37
  const targetOpenClaw =
33
38
  options.targetOpenClaw ??
34
39
  (options.openclawVersion
@@ -40,7 +45,7 @@ export async function inspectCompatibilityFixtureSet(config, options = {}) {
40
45
  }));
41
46
 
42
47
  return buildCompatibilityReport({
43
- config,
48
+ config: reportConfig,
44
49
  inspections,
45
50
  failures,
46
51
  authorFacing: options.authorFacing,
@@ -58,6 +63,11 @@ export async function inspectCompatibilityFixtureSet(config, options = {}) {
58
63
  });
59
64
  }
60
65
 
66
+ function normalizeFixtureCheckoutPath(config, fixture) {
67
+ const relative = path.relative(config.rootDir ?? process.cwd(), fixtureCheckoutPath(config, fixture));
68
+ return (relative || ".").replaceAll("\\", "/");
69
+ }
70
+
61
71
  async function inspectConfiguredFixtures(config, options = {}) {
62
72
  const inspections = [];
63
73
  const failures = [];
@@ -170,7 +180,7 @@ export function inspectSourceText(text, filePath = "source.js") {
170
180
  ...collectDetailedMatches(searchableText, new RegExp(String.raw`\b(${pluginFactoryNames})\s*\(`, "g"), filePath, "name"),
171
181
  ...collectDetailedMatches(searchableText, compiledFactoryCall, filePath, "name"),
172
182
  ];
173
- const sdkImports = collectSdkImports(searchableText, filePath);
183
+ const sdkImports = collectSdkImports(searchableText, filePath, text);
174
184
  const sdkDeprecations = inspectSdkDeprecations(searchableText, filePath);
175
185
 
176
186
  return {
@@ -185,14 +195,23 @@ export async function captureEntrypoint(entrypoint, options = {}) {
185
195
  if (options.mockSdk === true) {
186
196
  return captureEntrypointWithMockSdk(entrypoint, options);
187
197
  }
198
+ if (options.isolateCapture === true) {
199
+ return captureEntrypointInChild(entrypoint, { ...options, mockSdk: false });
200
+ }
201
+ return invokeWithTimeout((signal) => captureInProcess(entrypoint, options, signal), options);
202
+ }
188
203
 
204
+ async function captureInProcess(entrypoint, options, signal) {
205
+ signal.throwIfAborted();
189
206
  const resolvedEntrypoint = path.resolve(options.cwd ?? process.cwd(), entrypoint);
190
207
  let module;
191
208
  try {
192
209
  module = await import(pathToFileURL(resolvedEntrypoint).href);
193
210
  } catch (error) {
211
+ signal.throwIfAborted();
194
212
  throw classifyCapturePhaseError(error, "entrypoint-import-error");
195
213
  }
214
+ signal.throwIfAborted();
196
215
  const register = findRegisterExport(module);
197
216
 
198
217
  if (!register) {
@@ -207,13 +226,17 @@ export async function captureEntrypoint(entrypoint, options = {}) {
207
226
  pluginRoot: options.pluginRoot
208
227
  ? path.resolve(options.cwd ?? process.cwd(), options.pluginRoot)
209
228
  : path.dirname(resolvedEntrypoint),
229
+ signal,
210
230
  });
231
+ signal.throwIfAborted();
211
232
  const api = createCaptureApi(apiOptions);
212
233
  try {
213
234
  await register(api);
214
235
  } catch (error) {
236
+ signal.throwIfAborted();
215
237
  throw classifyCapturePhaseError(error, "registration-execution-error");
216
238
  }
239
+ signal.throwIfAborted();
217
240
  const result = {
218
241
  status: "captured",
219
242
  entrypoint: resolvedEntrypoint,
@@ -226,60 +249,68 @@ export async function captureEntrypoint(entrypoint, options = {}) {
226
249
  }
227
250
 
228
251
  export async function captureEntrypointWithMockSdk(entrypoint, options = {}) {
252
+ return captureEntrypointInChild(entrypoint, { ...options, mockSdk: true });
253
+ }
254
+
255
+ async function captureEntrypointInChild(entrypoint, options) {
256
+ const label = options.mockSdk ? "Mock SDK" : "Real SDK";
229
257
  const runnerPath = fileURLToPath(new URL("./mock-sdk-capture-runner.js", import.meta.url));
230
258
  const payload = {
231
259
  entrypoint,
260
+ mockSdk: options.mockSdk,
232
261
  cwd: options.cwd ?? process.cwd(),
233
262
  pluginRoot: options.pluginRoot,
234
263
  apiOptions: options.apiOptions,
264
+ maxOutputBytes: options.maxOutputBytes,
235
265
  };
266
+ const { result } = startOwnedProcess({
267
+ command: process.execPath,
268
+ args: ["--no-warnings", ...(options.mockSdk ? ["--preserve-symlinks"] : []), runnerPath, JSON.stringify(payload)],
269
+ cwd: options.cwd ?? process.cwd(),
270
+ env: { ...process.env, ...options.env },
271
+ timeoutMs: options.timeoutMs,
272
+ killGraceMs: options.killGraceMs,
273
+ maxOutputBytes: options.maxOutputBytes,
274
+ signal: options.signal,
275
+ }, "CAPTURE");
276
+ const outcome = await result;
277
+ if (outcome.exitCode !== 0 || outcome.outputTruncated) {
278
+ const message = outcome.cancelled ? `${label} capture cancelled`
279
+ : outcome.outputTruncated ? `${label} capture output exceeded its byte limit`
280
+ : `${label} capture child failed`;
281
+ const { error: childError, ...details } = outcome;
282
+ throw classifyChildCaptureError(Object.assign(childError ?? new Error(message), details), options.mockSdk);
283
+ }
236
284
  try {
237
- const { stdout } = await execFileAsync(
238
- process.execPath,
239
- ["--no-warnings", "--preserve-symlinks", runnerPath, JSON.stringify(payload)],
240
- {
241
- cwd: options.cwd ?? process.cwd(),
242
- env: {
243
- ...process.env,
244
- ...(options.env ?? {}),
245
- },
246
- maxBuffer: 1024 * 1024 * 10,
247
- },
248
- );
249
- return JSON.parse(stdout);
285
+ return JSON.parse(outcome.stdout);
250
286
  } catch (error) {
251
- const captured = parseCaptureResultFromStdout(error?.stdout);
252
- if (captured) {
253
- return captured;
254
- }
255
- throw classifyMockSdkCaptureError(error);
287
+ throw classifyChildCaptureError(error, options.mockSdk);
256
288
  }
257
289
  }
258
290
 
259
- function parseCaptureResultFromStdout(stdout) {
260
- if (!stdout) {
261
- return null;
291
+ export function classifyMockSdkCaptureError(error) {
292
+ return classifyChildCaptureError(error, true);
293
+ }
294
+
295
+ function classifyChildCaptureError(error, mockSdk) {
296
+ const label = mockSdk ? "Mock SDK" : "Real SDK";
297
+ const fallbackClass = mockSdk ? "mock-sdk-capture-error" : "capture-error";
298
+ if (error?.timedOut === true) {
299
+ return enrichCaptureError(error, {
300
+ message: `${label} capture timed out after ${error.timeoutMs}ms`,
301
+ failureClass: "capture-timeout",
302
+ });
262
303
  }
263
- try {
264
- const parsed = JSON.parse(stdout);
265
- if (
266
- parsed &&
267
- typeof parsed === "object" &&
268
- typeof parsed.status === "string" &&
269
- Array.isArray(parsed.captured)
270
- ) {
271
- return parsed;
272
- }
273
- } catch {
274
- return null;
304
+ if (error?.cancelled || error?.outputTruncated) {
305
+ return enrichCaptureError(error, {
306
+ message: error.message,
307
+ failureClass: fallbackClass,
308
+ });
275
309
  }
276
- return null;
277
- }
278
310
 
279
- export function classifyMockSdkCaptureError(error) {
280
311
  const rawMessage = [error?.stderr, error?.stdout, error?.message].filter(Boolean).join("\n");
281
312
  const missingExport = rawMessage.match(/does not provide an export named ['"]([^'"]+)['"]/)?.[1];
282
- if (missingExport) {
313
+ if (mockSdk && missingExport) {
283
314
  return enrichCaptureError(error, {
284
315
  message: `Mock SDK import failed: openclaw/plugin-sdk is missing export ${missingExport}`,
285
316
  failureClass: "missing-sdk-export",
@@ -290,9 +321,9 @@ export function classifyMockSdkCaptureError(error) {
290
321
  const missingModule =
291
322
  rawMessage.match(/Cannot find (?:package|module) ['"]([^'"]*openclaw\/plugin-sdk[^'"]*)['"]/)?.[1] ??
292
323
  rawMessage.match(/Package subpath ['"](\.\/plugin-sdk\/[^'"]+)['"]/)?.[1];
293
- if (missingModule || rawMessage.includes("openclaw/plugin-sdk")) {
324
+ if (mockSdk && missingModule) {
294
325
  return enrichCaptureError(error, {
295
- message: `Mock SDK import failed: ${missingModule ?? "openclaw/plugin-sdk module could not be resolved"}`,
326
+ message: `Mock SDK import failed: ${missingModule}`,
296
327
  failureClass: "missing-sdk-module",
297
328
  missingModule,
298
329
  });
@@ -301,15 +332,50 @@ export function classifyMockSdkCaptureError(error) {
301
332
  const failureClass = rawMessage.match(/\[plugin-inspector:([^\]]+)\]/)?.[1];
302
333
  if (failureClass) {
303
334
  return enrichCaptureError(error, {
304
- message: firstMeaningfulErrorLine(rawMessage.replace(/\[plugin-inspector:[^\]]+\]/, "")) ?? "Mock SDK capture failed",
335
+ message: firstMeaningfulErrorLine(rawMessage.replace(/\[plugin-inspector:[^\]]+\]/, "")) ?? `${label} capture failed`,
305
336
  failureClass,
306
337
  });
307
338
  }
339
+ if (mockSdk && rawMessage.includes("openclaw/plugin-sdk")) {
340
+ return enrichCaptureError(error, {
341
+ message: "Mock SDK import failed: openclaw/plugin-sdk module could not be resolved",
342
+ failureClass: "missing-sdk-module",
343
+ });
344
+ }
308
345
 
309
346
  return enrichCaptureError(error, {
310
- message: firstMeaningfulErrorLine(rawMessage) ?? "Mock SDK capture failed",
311
- failureClass: "mock-sdk-capture-error",
347
+ message: firstMeaningfulErrorLine(rawMessage) ?? `${label} capture failed`,
348
+ failureClass: fallbackClass,
349
+ });
350
+ }
351
+
352
+ function invokeWithTimeout(invoke, options) {
353
+ const { timeoutMs } = resolveProcessLimits(options, "CAPTURE");
354
+ const controller = new AbortController();
355
+ let rejectAborted;
356
+ const aborted = new Promise((_, reject) => { rejectAborted = reject; });
357
+ const onAbort = () => rejectAborted(controller.signal.reason);
358
+ const onCancel = () => controller.abort(classifyCapturePhaseError(
359
+ new Error("In-process capture cancelled"), "capture-error",
360
+ ));
361
+ controller.signal.addEventListener("abort", onAbort, { once: true });
362
+ const timeoutId = setTimeout(() => controller.abort(classifyCapturePhaseError(
363
+ new Error(`In-process capture timed out after ${timeoutMs}ms`), "capture-timeout",
364
+ )), timeoutMs);
365
+ // Observe late settlement, but stop inspector-owned phases after the deadline.
366
+ // Arbitrary plugin JavaScript is only preemptable in the supervised CLI child.
367
+ const run = Promise.resolve().then(() => {
368
+ controller.signal.throwIfAborted();
369
+ return invoke(controller.signal);
370
+ });
371
+ const result = Promise.race([run, aborted]).finally(() => {
372
+ clearTimeout(timeoutId);
373
+ controller.signal.removeEventListener("abort", onAbort);
374
+ options.signal?.removeEventListener("abort", onCancel);
312
375
  });
376
+ options.signal?.addEventListener("abort", onCancel, { once: true });
377
+ if (options.signal?.aborted) onCancel();
378
+ return result;
313
379
  }
314
380
 
315
381
  export function classifyCapturePhaseError(error, failureClass) {
@@ -385,7 +451,7 @@ function collectDetailedMatches(text, regex, filePath, key) {
385
451
  return details;
386
452
  }
387
453
 
388
- function collectSdkImports(text, filePath) {
454
+ function collectSdkImports(text, filePath, sourceText) {
389
455
  const details = [];
390
456
  for (const candidate of text.matchAll(/(?:^|;)[\t ]*(import|export)\b/gm)) {
391
457
  const keyword = candidate[1];
@@ -402,13 +468,12 @@ function collectSdkImports(text, filePath) {
402
468
  });
403
469
  }
404
470
 
405
- const dynamicMatches = [...text.matchAll(/\bimport\(\s*["'`]([^"'`]*openclaw\/plugin-sdk[^"'`]*)/g)];
406
- const runtimeDynamicImports = runtimeDynamicImportIndexes(text, dynamicMatches);
407
- for (const [index, match] of dynamicMatches.entries()) {
408
- if (!runtimeDynamicImports.has(index)) continue;
409
- const line = lineForOffset(text, match.index ?? 0);
471
+ // Comment masking can erase executable interpolations after URL-like template text.
472
+ for (const { specifier, index } of collectRuntimeModuleImports(sourceText)) {
473
+ if (specifier !== "openclaw/plugin-sdk" && !specifier.startsWith("openclaw/plugin-sdk/")) continue;
474
+ const line = lineForOffset(text, index);
410
475
  details.push({
411
- specifier: match[1],
476
+ specifier,
412
477
  file: filePath,
413
478
  line,
414
479
  ref: `${filePath}:${line}`,
@@ -475,50 +540,6 @@ function skipQuotedText(text, quoteIndex, quote) {
475
540
  return cursor;
476
541
  }
477
542
 
478
- function runtimeDynamicImportIndexes(text, matches) {
479
- if (matches.length === 0) return new Set();
480
- const markedImports = matches.map((match, index) => {
481
- const specifier = match[1];
482
- const specifierStart = (match.index ?? 0) + match[0].indexOf(specifier);
483
- return {
484
- index,
485
- specifierStart,
486
- specifierEnd: specifierStart + specifier.length,
487
- marker: `${specifier}/__plugin_inspector_runtime_import_${index}__`,
488
- };
489
- });
490
- let markedText = text;
491
- for (const markedImport of markedImports.toReversed()) {
492
- markedText =
493
- markedText.slice(0, markedImport.specifierStart) +
494
- markedImport.marker +
495
- markedText.slice(markedImport.specifierEnd);
496
- }
497
-
498
- try {
499
- const runtimeText = eraseTypeScript(markedText);
500
- if (runtimeText === null) {
501
- return new Set(markedImports.map((markedImport) => markedImport.index));
502
- }
503
- return new Set(
504
- markedImports.filter((markedImport) => runtimeText.includes(markedImport.marker)).map((markedImport) => markedImport.index),
505
- );
506
- } catch {
507
- return new Set(markedImports.map((markedImport) => markedImport.index));
508
- }
509
- }
510
-
511
- function eraseTypeScript(text) {
512
- if (typeof nodeModule.stripTypeScriptTypes === "function") {
513
- return nodeModule.stripTypeScriptTypes(text, { mode: "transform" });
514
- }
515
- if (typeof globalThis.Bun?.Transpiler === "function") {
516
- const transpiler = new globalThis.Bun.Transpiler({ loader: "ts", target: "bun" });
517
- return transpiler.transformSync(text);
518
- }
519
- return null;
520
- }
521
-
522
543
  function isTypeOnlyStaticImportClause(clause) {
523
544
  clause = clause.trim();
524
545
  if (/^type\b/.test(clause)) {
@@ -1,37 +1,56 @@
1
1
  #!/usr/bin/env node
2
2
  import { rmSync } from "node:fs";
3
3
  import { mkdtemp } from "node:fs/promises";
4
- import { register } from "node:module";
5
4
  import os from "node:os";
6
5
  import path from "node:path";
7
6
  import { pathToFileURL } from "node:url";
7
+ import { writeArtifacts } from "./artifacts.js";
8
8
  import { createCaptureApi } from "./capture-api.js";
9
9
  import { captureApiOptionsForPlugin } from "./capture-config.js";
10
- import { createMockSdkPackage } from "./sdk-mock.js";
10
+ import { createCappedCollector, resolveProcessLimits } from "./process-profile.js";
11
+ import { createMockSdkPackage, installMockSdkLoader } from "./sdk-mock.js";
11
12
 
12
13
  const options = JSON.parse(process.argv[2] ?? "{}");
13
14
  let activeOutputCapture = null;
14
15
 
15
16
  try {
16
17
  const result = await run(options);
17
- writeRunnerStdout(`${JSON.stringify(result, null, 2)}\n`);
18
+ const json = `${JSON.stringify(result, null, 2)}\n`;
19
+ const { maxOutputBytes } = resolveProcessLimits(options, options.syntheticProbes ? "PROBE" : "CAPTURE");
20
+ if (Buffer.byteLength(json) > maxOutputBytes) {
21
+ const label = options.syntheticProbes ? "Synthetic probe" : `${options.mockSdk === false ? "Real SDK" : "Mock SDK"} capture`;
22
+ throw new Error(`${label} result exceeded its ${maxOutputBytes}-byte limit`);
23
+ }
24
+ if (options.outputPath) {
25
+ await writeArtifacts([{ path: options.outputPath, content: json }]);
26
+ } else {
27
+ await writeRunnerStdout(json);
28
+ }
29
+ process.exit(0);
18
30
  } catch (error) {
19
31
  if (error.failureClass) {
20
- writeRunnerStderr(`[plugin-inspector:${error.failureClass}]\n`);
32
+ await writeRunnerStderr(`[plugin-inspector:${error.failureClass}]\n`);
21
33
  }
22
- writeRunnerStderr(`${error.stack ?? error.message}\n`);
23
- process.exitCode = 1;
34
+ await writeRunnerStderr(`${options.outputPath ? error.message : (error.stack ?? error.message)}\n`);
35
+ process.exit(1);
24
36
  }
25
37
 
26
38
  async function run(options) {
27
39
  const entrypoint = path.resolve(options.cwd ?? process.cwd(), options.entrypoint);
28
40
  const pluginRoot = path.resolve(options.cwd ?? process.cwd(), options.pluginRoot ?? path.dirname(entrypoint));
41
+ if (options.mockSdk === false) {
42
+ return await captureLinkedEntrypoint(entrypoint, { ...options, pluginRoot });
43
+ }
29
44
  const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-mock-sdk-"));
30
45
 
31
46
  cleanupTempDirOnExit(workspace);
32
- const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
33
- register(pathToFileURL(loaderPath));
34
- return await captureLinkedEntrypoint(entrypoint, { ...options, pluginRoot });
47
+ const mockPackage = await createMockSdkPackage(workspace, { pluginRoot });
48
+ const stopLoader = await installMockSdkLoader(mockPackage);
49
+ try {
50
+ return await captureLinkedEntrypoint(entrypoint, { ...options, pluginRoot });
51
+ } finally {
52
+ stopLoader();
53
+ }
35
54
  }
36
55
 
37
56
  function cleanupTempDirOnExit(dir) {
@@ -55,14 +74,15 @@ async function captureLinkedEntrypoint(entrypoint, options) {
55
74
 
56
75
  if (!register) {
57
76
  await drainAsyncOutput();
58
- return withProcessOutput(
77
+ return finishCapture(
59
78
  {
60
79
  status: "no-register-export",
61
- entrypoint: options.entrypoint,
62
- mockSdk: true,
80
+ entrypoint: options.mockSdk === false ? entrypoint : options.entrypoint,
81
+ mockSdk: options.mockSdk !== false,
63
82
  captured: [],
64
83
  },
65
84
  outputCapture,
85
+ options,
66
86
  );
67
87
  }
68
88
 
@@ -80,13 +100,22 @@ async function captureLinkedEntrypoint(entrypoint, options) {
80
100
 
81
101
  const result = {
82
102
  status: "captured",
83
- entrypoint: options.entrypoint,
84
- mockSdk: true,
103
+ entrypoint: options.mockSdk === false ? entrypoint : options.entrypoint,
104
+ mockSdk: options.mockSdk !== false,
85
105
  captured: api.getCapturedContracts(),
86
106
  };
87
107
  if (apiOptions?.retainHandlers === true) {
88
108
  result.retained = api.getRetainedContracts();
89
109
  }
110
+ return finishCapture(result, outputCapture, options);
111
+ }
112
+
113
+ async function finishCapture(result, outputCapture, options) {
114
+ if (options.syntheticProbes) {
115
+ const { runCapturedSyntheticProbes } = await import("./synthetic-probes.js");
116
+ result = await runCapturedSyntheticProbes(result, options);
117
+ await drainAsyncOutput();
118
+ }
90
119
  return withProcessOutput(result, outputCapture);
91
120
  }
92
121
 
@@ -124,18 +153,18 @@ function findRegisterExport(module) {
124
153
  }
125
154
 
126
155
  function installProcessOutputCapture() {
127
- const stdoutChunks = [];
128
- const stderrChunks = [];
156
+ const stdout = createCappedCollector(1024 * 1024);
157
+ const stderr = createCappedCollector(1024 * 1024);
129
158
  const originalStdoutWrite = process.stdout.write.bind(process.stdout);
130
159
  const originalStderrWrite = process.stderr.write.bind(process.stderr);
131
160
 
132
161
  process.stdout.write = (chunk, encoding, callback) => {
133
- stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
162
+ stdout.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), typeof encoding === "string" ? encoding : "utf8"));
134
163
  invokeWriteCallback(encoding, callback);
135
164
  return true;
136
165
  };
137
166
  process.stderr.write = (chunk, encoding, callback) => {
138
- stderrChunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
167
+ stderr.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), typeof encoding === "string" ? encoding : "utf8"));
139
168
  invokeWriteCallback(encoding, callback);
140
169
  return true;
141
170
  };
@@ -143,8 +172,8 @@ function installProcessOutputCapture() {
143
172
  return {
144
173
  originalStdoutWrite,
145
174
  originalStderrWrite,
146
- stdout: () => stdoutChunks.join(""),
147
- stderr: () => stderrChunks.join(""),
175
+ stdout: () => stdout.text(),
176
+ stderr: () => stderr.text(),
148
177
  };
149
178
  }
150
179
 
@@ -162,9 +191,19 @@ async function drainAsyncOutput() {
162
191
  }
163
192
 
164
193
  function writeRunnerStdout(text) {
165
- (activeOutputCapture?.originalStdoutWrite ?? process.stdout.write.bind(process.stdout))(text);
194
+ const write = activeOutputCapture?.originalStdoutWrite ?? process.stdout.write.bind(process.stdout);
195
+ return flushWrite(write, text);
166
196
  }
167
197
 
168
198
  function writeRunnerStderr(text) {
169
- (activeOutputCapture?.originalStderrWrite ?? process.stderr.write.bind(process.stderr))(text);
199
+ const write = activeOutputCapture?.originalStderrWrite ?? process.stderr.write.bind(process.stderr);
200
+ return flushWrite(write, text);
201
+ }
202
+
203
+ // The runner exits deliberately to shed plugin timers, but only after the
204
+ // complete protocol response has reached its pipe.
205
+ function flushWrite(write, text) {
206
+ return new Promise((resolve, reject) => {
207
+ write(text, (error) => error ? reject(error) : resolve());
208
+ });
170
209
  }