@openclaw/plugin-inspector 0.3.25 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.26 - 2026-09-20
6
+
7
+ ### Fixed
8
+
9
+ - Recognize OpenClaw's declared private-local Plugin SDK subpaths and reserved bundled-plugin imports when inspecting bundled `extensions/*` fixtures, while continuing to report those imports for external plugins and genuinely missing SDK aliases.
10
+ - Treat active OpenClaw conversation-access compat records with present contract tests as target-owned proof instead of repeatedly emitting a P1 Inspector probe backlog advisory.
11
+ - Record explicit method-scoped Gateway probe prerequisites before calling handlers that require unavailable host state or live credentials; preserve actual response failures for admitted probes.
12
+ - Preserve absent optional strings in SDK mocks and supply a stable runtime config snapshot to Gateway probes instead of inventing configured values or missing host accessors.
13
+ - Generate SDK mocks for literal dynamic imports in retained handlers, including their named exports, while excluding TypeScript import types and keeping source inspection aligned with runtime capture.
14
+ - Honor the OpenClaw lazy-runtime SDK contract in generated mocks, preserving deferred module loading, shared promise caches, explicit cache clearing, and rejected imports instead of returning callable placeholders.
15
+ - Reject invalid batch concurrency instead of reporting success without inspecting any plugins, and preserve relative plugin paths in retained reports so similar directory names cannot overwrite each other's results.
16
+ - Preserve error messages from the OpenClaw `error-runtime` SDK subpath in synthetic probes, including lazy CommonJS imports, so Gateway rejections report their actual cause and remain failures.
17
+ - Keep tool-hook and conversation-privacy contract gaps open when runtime artifacts only capture hook registration. Preserve registration coverage and semantic contract probes.
18
+ - Read OpenClaw compatibility records from the explicitly imported `registry-records.ts` data module, avoiding false missing-record findings after the registry split. Preserve inline registries and report missing delegated data as an error.
19
+
5
20
  ## 0.3.25 - 2026-09-09
6
21
 
7
22
  ### Fixed
package/README.md CHANGED
@@ -200,6 +200,13 @@ Common options:
200
200
  | `--junit [path]` | Write JUnit XML from `check` or `inspect`; `ci` enables this by default. |
201
201
  | `--no-sarif` / `--no-junit` | Disable default `ci` outputs. |
202
202
 
203
+ For `batch`, `--concurrency <n>` must be a finite number (default `4`, rounded
204
+ and clamped to `1`–`32` workers). Invalid or missing values fail before inspection
205
+ or report writes. `--keep-plugin-reports` retains individual reports under
206
+ `<out>/plugins/<relative-plugin-path>/`, preserving the corpus directory layout
207
+ and names so plugins such as `a/b`, `a-b`, and `a b` cannot overwrite each other's
208
+ reports. If the corpus root is itself a plugin, its reports go in `<out>/plugins/`.
209
+
203
210
  Run the built-in help for the exact CLI surface:
204
211
 
205
212
  ```bash
@@ -338,6 +345,15 @@ aliases do not create extra calls. The handler receives synthetic Gateway
338
345
  options and a void `respond(ok, payload, error, meta)` callback. Existing
339
346
  `registrationProbeInputs` overrides remain available.
340
347
 
348
+ For a method that needs unavailable host state or live credentials, pass
349
+ `gatewayMethodPrerequisites: { "fixture.account": "saved account required" }`
350
+ to `runCapturedSyntheticProbes` or `runEntrypointSyntheticProbes`. The named
351
+ method produces a `blocked` row with its method and reason before its handler
352
+ runs. Other methods retain normal response validation. Once a caller supplies
353
+ the required inputs and runtime, omit that method from the prerequisite map;
354
+ rejected or malformed responses still fail. This option never reports a
355
+ missing prerequisite as a passing runtime check.
356
+
341
357
  The first emitted response is authoritative, even when malformed. Probes check
342
358
  its JSON-serialized response/error shape: `ok: true` passes, `ok: false` fails,
343
359
  and later responses cannot overwrite the outcome. Logging `meta` is not a wire
@@ -567,6 +583,8 @@ npm run check
567
583
  ```
568
584
 
569
585
  `npm run check` runs the Node test suite and the package-contents guard. The
586
+ test runner limits parallel test files to four so process-supervision tests do
587
+ not contend with a machine-wide burst of child processes for their deadlines. The
570
588
  contents guard shells through `npm pack --dry-run --json` and verifies the npm
571
589
  tarball includes package entrypoints, examples, README assets, and no private
572
590
  `test/`, `scripts/`, or `.github/` paths.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/plugin-inspector",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "private": false,
5
5
  "description": "Offline compatibility inspector for OpenClaw plugins.",
6
6
  "type": "module",
@@ -57,7 +57,7 @@
57
57
  "release:readiness": "npm run release:local && npm run release:crabpot",
58
58
  "release:local": "npm run check",
59
59
  "release:notes": "node scripts/release-notes.mjs --unreleased",
60
- "test": "node --test test/*.test.js",
60
+ "test": "node --test --test-concurrency=4 test/*.test.js",
61
61
  "check:changed": "npm run check",
62
62
  "test:changed": "npm test",
63
63
  "crabbox:hydrate": "crabbox actions hydrate",
@@ -72,6 +72,8 @@
72
72
  "ci"
73
73
  ],
74
74
  "dependencies": {
75
+ "acorn": "^8.18.0",
76
+ "eslint-scope": "^8.4.0",
75
77
  "semver": "^7.8.5",
76
78
  "tar": "^7.5.22"
77
79
  }
package/src/batch.js CHANGED
@@ -21,7 +21,11 @@ export async function runBatchAnalysis(options = {}) {
21
21
  const rootDir = path.resolve(options.rootDir ?? options.inputDir ?? process.cwd());
22
22
  const outDir = options.outDir ?? "reports";
23
23
  const outRoot = path.resolve(rootDir, outDir);
24
- const concurrency = Math.max(1, Math.min(Math.round(options.concurrency ?? 4), 32));
24
+ const requestedConcurrency = options.concurrency ?? 4;
25
+ if (!Number.isFinite(requestedConcurrency)) {
26
+ throw new TypeError("batch concurrency must be a finite number");
27
+ }
28
+ const concurrency = Math.max(1, Math.min(Math.round(requestedConcurrency), 32));
25
29
  const keepPluginReports = options.keepPluginReports === true;
26
30
  const targetOpenClaw =
27
31
  options.targetOpenClaw ??
@@ -35,8 +39,8 @@ export async function runBatchAnalysis(options = {}) {
35
39
  try {
36
40
  await runWithConcurrency(pluginRoots, concurrency, async (pluginRoot) => {
37
41
  const reportsRoot = keepPluginReports
38
- ? path.join(outRoot, "plugins", slugForPath(path.relative(rootDir, pluginRoot)))
39
- : path.join(tempRoot, slugForPath(path.relative(rootDir, pluginRoot)));
42
+ ? path.join(outRoot, "plugins", path.relative(rootDir, pluginRoot))
43
+ : path.join(tempRoot, path.relative(rootDir, pluginRoot));
40
44
  entries.push(
41
45
  await inspectBatchPlugin(pluginRoot, {
42
46
  ...options,
@@ -306,12 +310,3 @@ function packageNameFromReport(report) {
306
310
  "plugin"
307
311
  );
308
312
  }
309
-
310
- function slugForPath(value) {
311
- return (
312
- String(value)
313
- .replaceAll(path.sep, "-")
314
- .replace(/[^a-zA-Z0-9._-]+/g, "-")
315
- .replace(/^-+|-+$/g, "") || "plugin"
316
- );
317
- }
package/src/cli.js CHANGED
@@ -61,7 +61,9 @@ async function runBatch(commandArgs) {
61
61
  const outDir = readFlag(commandArgs, "--out") ?? "reports";
62
62
  const openclawPath = commandArgs.includes("--no-openclaw") ? false : readFlag(commandArgs, "--openclaw");
63
63
  const openclawVersion = readOpenClawVersion(commandArgs);
64
- const concurrency = Number(readFlag(commandArgs, "--concurrency") ?? "4");
64
+ const concurrency = commandArgs.includes("--concurrency")
65
+ ? Number(readFlag(commandArgs, "--concurrency") ?? NaN)
66
+ : 4;
65
67
  const json = commandArgs.includes("--json");
66
68
  const check = commandArgs.includes("--check");
67
69
  const keepPluginReports = commandArgs.includes("--keep-plugin-reports");
@@ -550,14 +550,16 @@ export function classifyCompatibilityFixture({ fixture, inspection, fixtureRepor
550
550
 
551
551
  const conversationHooks = inspection.hooks.filter((hook) => conversationAccessHooks.has(hook));
552
552
  const conversationHookDetails = inspection.hookDetails.filter((hook) => conversationAccessHooks.has(hook.name));
553
- if (conversationHooks.length > 0) {
553
+ const conversationCompatRecord = compatRecordForIssueCode("conversation-access-hook");
554
+ const conversationContractCovered = hasActiveTargetContractTests(targetOpenClaw, conversationCompatRecord);
555
+ if (conversationHooks.length > 0 && !conversationContractCovered) {
554
556
  warnings.push({
555
557
  fixture: fixture.id,
556
558
  code: "conversation-access-hook",
557
559
  level: "warning",
558
560
  message: "fixture observes raw model or conversation content and needs privacy-boundary contract probes",
559
561
  evidence: detailEvidence(conversationHookDetails),
560
- compatRecord: compatRecordForIssueCode("conversation-access-hook"),
562
+ compatRecord: conversationCompatRecord,
561
563
  });
562
564
  decisions.push({
563
565
  fixture: fixture.id,
@@ -713,6 +715,15 @@ export function classifyCompatibilityFixture({ fixture, inspection, fixtureRepor
713
715
  return { breakages, warnings, suggestions, logs, decisions };
714
716
  }
715
717
 
718
+ function hasActiveTargetContractTests(targetOpenClaw, compatRecord) {
719
+ if (!compatRecord || !["active", "supported"].includes(targetOpenClaw.compatRecordStatuses?.[compatRecord])) {
720
+ return false;
721
+ }
722
+ const tests = targetOpenClaw.compatRecordTests?.[compatRecord] ?? [];
723
+ const missingTests = targetOpenClaw.compatRecordMissingTests?.[compatRecord] ?? [];
724
+ return tests.length > 0 && missingTests.length === 0;
725
+ }
726
+
716
727
  function classifySdkDeprecations({ fixture, inspection, fixtureReport, warnings, decisions }) {
717
728
  const grouped = new Map();
718
729
  for (const finding of fixtureReport.sdkDeprecations ?? inspection.sdkDeprecations ?? []) {
@@ -883,10 +894,22 @@ function classifySdkImportCoverage({ fixture, fixtureReport, targetOpenClaw, war
883
894
  }
884
895
 
885
896
  const sdkExports = new Set(targetOpenClaw.sdkExports);
886
- const unknownImports = fixtureReport.sdkImportDetails.filter((sdkImport) => !sdkExports.has(sdkImport.specifier));
897
+ const bundledPluginId = bundledOpenClawPluginId(fixture, targetOpenClaw);
898
+ const isBundledFixture = bundledPluginId !== null;
899
+ const privateLocalSdkExports = new Set(targetOpenClaw.privateLocalSdkExports ?? []);
900
+ const unknownImports = fixtureReport.sdkImportDetails.filter(
901
+ (sdkImport) =>
902
+ !sdkExports.has(sdkImport.specifier) &&
903
+ !(isBundledFixture && privateLocalSdkExports.has(sdkImport.specifier)),
904
+ );
887
905
  const reservedSdkExports = new Set(targetOpenClaw.reservedSdkExports ?? []);
888
- const reservedImports = fixtureReport.sdkImportDetails.filter((sdkImport) =>
889
- reservedSdkExports.has(sdkImport.specifier),
906
+ const reservedImports = fixtureReport.sdkImportDetails.filter(
907
+ (sdkImport) =>
908
+ reservedSdkExports.has(sdkImport.specifier) &&
909
+ !(
910
+ bundledPluginId !== null &&
911
+ targetOpenClaw.reservedSdkExportOwners?.[sdkImport.specifier] === bundledPluginId
912
+ ),
890
913
  );
891
914
 
892
915
  if (reservedImports.length === 0 && unknownImports.length === 0) {
@@ -894,7 +917,9 @@ function classifySdkImportCoverage({ fixture, fixtureReport, targetOpenClaw, war
894
917
  fixture: fixture.id,
895
918
  code: "sdk-exports-present",
896
919
  level: "log",
897
- message: "all observed plugin SDK imports exist in target OpenClaw package exports",
920
+ message: isBundledFixture
921
+ ? "all observed plugin SDK imports are exported or declared private-local for bundled plugins"
922
+ : "all observed plugin SDK imports exist in target OpenClaw package exports",
898
923
  evidence: fixtureReport.sdkImports,
899
924
  });
900
925
  return;
@@ -938,6 +963,18 @@ function classifySdkImportCoverage({ fixture, fixtureReport, targetOpenClaw, war
938
963
  }
939
964
  }
940
965
 
966
+ function bundledOpenClawPluginId(fixture, targetOpenClaw) {
967
+ if (fixture.repo !== "local" || !fixture.checkoutPath || !targetOpenClaw.checkoutPath) return null;
968
+ const fixturePath = normalizeRepoPath(fixture.checkoutPath);
969
+ const targetPath = normalizeRepoPath(targetOpenClaw.checkoutPath);
970
+ const relativePath = targetPath === "."
971
+ ? fixturePath
972
+ : fixturePath.startsWith(`${targetPath}/`)
973
+ ? fixturePath.slice(targetPath.length + 1)
974
+ : "";
975
+ return relativePath.match(/^extensions\/([^/]+)(?:\/|$)/)?.[1] ?? null;
976
+ }
977
+
941
978
  function addVersionDerivedFinding({ finding, fixtureReport, targetOpenClaw, breakages, warnings, suggestions }) {
942
979
  const compatibility = targetCompatibility(fixtureReport, targetOpenClaw);
943
980
  if (!compatibility) {
package/src/inspector.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readdir, readFile } from "node:fs/promises";
3
- import * as nodeModule from "node:module";
4
3
  import path from "node:path";
5
4
  import { fileURLToPath, pathToFileURL } from "node:url";
6
5
  import { createCaptureApi } from "./capture-api.js";
@@ -12,7 +11,7 @@ import { prepareOpenClawTarget, resolveOpenClawTargetVersion } from "./openclaw-
12
11
  import { resolveProcessLimits, startOwnedProcess } from "./process-profile.js";
13
12
  import { buildCompatibilityReport, buildReport } from "./report.js";
14
13
  import { inspectSdkDeprecations } from "./sdk-deprecation-rules.js";
15
- import { collectCommonJsRequires } from "./sdk-mock.js";
14
+ import { collectRuntimeModuleImports } from "./runtime-imports.js";
16
15
 
17
16
  const pluginFactoryNames = "defineBundledChannelEntry|defineChannelPluginEntry|createChatChannelPlugin|definePluginEntry";
18
17
  // Bundlers emit unbound calls as (0, sdk.factory)(...), including inline require receivers.
@@ -28,6 +27,13 @@ export async function inspectFixtureSet(config, options = {}) {
28
27
 
29
28
  export async function inspectCompatibilityFixtureSet(config, options = {}) {
30
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
+ };
31
37
  const targetOpenClaw =
32
38
  options.targetOpenClaw ??
33
39
  (options.openclawVersion
@@ -39,7 +45,7 @@ export async function inspectCompatibilityFixtureSet(config, options = {}) {
39
45
  }));
40
46
 
41
47
  return buildCompatibilityReport({
42
- config,
48
+ config: reportConfig,
43
49
  inspections,
44
50
  failures,
45
51
  authorFacing: options.authorFacing,
@@ -57,6 +63,11 @@ export async function inspectCompatibilityFixtureSet(config, options = {}) {
57
63
  });
58
64
  }
59
65
 
66
+ function normalizeFixtureCheckoutPath(config, fixture) {
67
+ const relative = path.relative(config.rootDir ?? process.cwd(), fixtureCheckoutPath(config, fixture));
68
+ return (relative || ".").replaceAll("\\", "/");
69
+ }
70
+
60
71
  async function inspectConfiguredFixtures(config, options = {}) {
61
72
  const inspections = [];
62
73
  const failures = [];
@@ -169,7 +180,7 @@ export function inspectSourceText(text, filePath = "source.js") {
169
180
  ...collectDetailedMatches(searchableText, new RegExp(String.raw`\b(${pluginFactoryNames})\s*\(`, "g"), filePath, "name"),
170
181
  ...collectDetailedMatches(searchableText, compiledFactoryCall, filePath, "name"),
171
182
  ];
172
- const sdkImports = collectSdkImports(searchableText, filePath);
183
+ const sdkImports = collectSdkImports(searchableText, filePath, text);
173
184
  const sdkDeprecations = inspectSdkDeprecations(searchableText, filePath);
174
185
 
175
186
  return {
@@ -440,7 +451,7 @@ function collectDetailedMatches(text, regex, filePath, key) {
440
451
  return details;
441
452
  }
442
453
 
443
- function collectSdkImports(text, filePath) {
454
+ function collectSdkImports(text, filePath, sourceText) {
444
455
  const details = [];
445
456
  for (const candidate of text.matchAll(/(?:^|;)[\t ]*(import|export)\b/gm)) {
446
457
  const keyword = candidate[1];
@@ -457,19 +468,8 @@ function collectSdkImports(text, filePath) {
457
468
  });
458
469
  }
459
470
 
460
- const dynamicMatches = [...text.matchAll(/\bimport\(\s*["'`]([^"'`]*openclaw\/plugin-sdk[^"'`]*)/g)];
461
- const runtimeDynamicImports = runtimeDynamicImportIndexes(text, dynamicMatches);
462
- for (const [index, match] of dynamicMatches.entries()) {
463
- if (!runtimeDynamicImports.has(index)) continue;
464
- const line = lineForOffset(text, match.index ?? 0);
465
- details.push({
466
- specifier: match[1],
467
- file: filePath,
468
- line,
469
- ref: `${filePath}:${line}`,
470
- });
471
- }
472
- for (const { specifier, index } of collectCommonJsRequires(text)) {
471
+ // Comment masking can erase executable interpolations after URL-like template text.
472
+ for (const { specifier, index } of collectRuntimeModuleImports(sourceText)) {
473
473
  if (specifier !== "openclaw/plugin-sdk" && !specifier.startsWith("openclaw/plugin-sdk/")) continue;
474
474
  const line = lineForOffset(text, index);
475
475
  details.push({
@@ -540,50 +540,6 @@ function skipQuotedText(text, quoteIndex, quote) {
540
540
  return cursor;
541
541
  }
542
542
 
543
- function runtimeDynamicImportIndexes(text, matches) {
544
- if (matches.length === 0) return new Set();
545
- const markedImports = matches.map((match, index) => {
546
- const specifier = match[1];
547
- const specifierStart = (match.index ?? 0) + match[0].indexOf(specifier);
548
- return {
549
- index,
550
- specifierStart,
551
- specifierEnd: specifierStart + specifier.length,
552
- marker: `${specifier}/__plugin_inspector_runtime_import_${index}__`,
553
- };
554
- });
555
- let markedText = text;
556
- for (const markedImport of markedImports.toReversed()) {
557
- markedText =
558
- markedText.slice(0, markedImport.specifierStart) +
559
- markedImport.marker +
560
- markedText.slice(markedImport.specifierEnd);
561
- }
562
-
563
- try {
564
- const runtimeText = eraseTypeScript(markedText);
565
- if (runtimeText === null) {
566
- return new Set(markedImports.map((markedImport) => markedImport.index));
567
- }
568
- return new Set(
569
- markedImports.filter((markedImport) => runtimeText.includes(markedImport.marker)).map((markedImport) => markedImport.index),
570
- );
571
- } catch {
572
- return new Set(markedImports.map((markedImport) => markedImport.index));
573
- }
574
- }
575
-
576
- function eraseTypeScript(text) {
577
- if (typeof nodeModule.stripTypeScriptTypes === "function") {
578
- return nodeModule.stripTypeScriptTypes(text, { mode: "transform" });
579
- }
580
- if (typeof globalThis.Bun?.Transpiler === "function") {
581
- const transpiler = new globalThis.Bun.Transpiler({ loader: "ts", target: "bun" });
582
- return transpiler.transformSync(text);
583
- }
584
- return null;
585
- }
586
-
587
543
  function isTypeOnlyStaticImportClause(clause) {
588
544
  clause = clause.trim();
589
545
  if (/^type\b/.test(clause)) {
@@ -30,17 +30,36 @@ export async function readOpenClawTargetSurface(options = {}) {
30
30
  return readPackedOpenClawTargetSurface({ rootDir, requestedPaths, ...match });
31
31
  }
32
32
 
33
- const { requestedPath, resolvedPath, registryPath } = match;
33
+ const { requestedPath, resolvedPath, registryPath: registryEntryPath } = match;
34
34
  const hookTypesPath = path.join(resolvedPath, "src/plugins/hook-types.ts");
35
35
  const apiBuilderPath = path.join(resolvedPath, "src/plugins/api-builder.ts");
36
36
  const capturedRegistrationPath = path.join(resolvedPath, "src/plugins/captured-registration.ts");
37
37
  const currentManifestTypesPath = path.join(resolvedPath, "src/plugins/manifest-types.ts");
38
38
  const legacyManifestTypesPath = path.join(resolvedPath, "src/plugins/manifest.ts");
39
39
  const pluginSdkEntrypointsPath = path.join(resolvedPath, "src/plugin-sdk/entrypoints.ts");
40
+ const privateLocalSdkSubpathsPath = path.join(
41
+ resolvedPath,
42
+ "scripts/lib/plugin-sdk-private-local-only-subpaths.json",
43
+ );
40
44
  const packagePath = path.join(resolvedPath, "package.json");
41
45
 
42
- const registrySource = await readFile(registryPath, "utf8");
46
+ const registryEntrySource = await readFile(registryEntryPath, "utf8");
47
+ const registryPath = importsCompatRecords(registryEntrySource)
48
+ ? path.join(path.dirname(registryEntryPath), "registry-records.ts")
49
+ : registryEntryPath;
50
+ const registrySource = registryPath === registryEntryPath
51
+ ? registryEntrySource
52
+ : await readFile(registryPath, "utf8");
43
53
  const compatRecordEntries = parseCompatRecordEntries(registrySource);
54
+ const compatRecordTests = Object.fromEntries(
55
+ compatRecordEntries.map((record) => [record.code, record.tests]),
56
+ );
57
+ const compatRecordMissingTests = Object.fromEntries(
58
+ compatRecordEntries.map((record) => [
59
+ record.code,
60
+ record.tests.filter((testPath) => !existsSync(path.join(resolvedPath, testPath))),
61
+ ]),
62
+ );
44
63
  const hookTypesSource = existsSync(hookTypesPath) ? await readFile(hookTypesPath, "utf8") : "";
45
64
  const hookNames = hookTypesSource ? parseConstStringArray(hookTypesSource, "PLUGIN_HOOK_NAMES") : [];
46
65
  const apiBuilderSource = existsSync(apiBuilderPath) ? await readFile(apiBuilderPath, "utf8") : "";
@@ -66,12 +85,21 @@ export async function readOpenClawTargetSurface(options = {}) {
66
85
  const sdkExports = existsSync(packagePath)
67
86
  ? parsePluginSdkExports(JSON.parse(await readFile(packagePath, "utf8")))
68
87
  : [];
88
+ const privateLocalSdkExports = existsSync(privateLocalSdkSubpathsPath)
89
+ ? parsePluginSdkSubpathSpecifiers(
90
+ JSON.parse(await readFile(privateLocalSdkSubpathsPath, "utf8")),
91
+ )
92
+ : [];
69
93
  const pluginSdkEntrypointsSource = existsSync(pluginSdkEntrypointsPath)
70
94
  ? await readFile(pluginSdkEntrypointsPath, "utf8")
71
95
  : "";
72
96
  const reservedSdkExports = pluginSdkEntrypointsSource
73
97
  ? parsePluginSdkEntrypointSpecifiers(pluginSdkEntrypointsSource, "reservedBundledPluginSdkEntrypoints")
74
98
  : [];
99
+ const bundledPluginIds = await readBundledPluginIds(resolvedPath);
100
+ const reservedSdkExportOwners = Object.fromEntries(
101
+ reservedSdkExports.map((specifier) => [specifier, resolveBundledSdkOwner(specifier, bundledPluginIds)]),
102
+ );
75
103
  const supportedFacadeSdkExports = pluginSdkEntrypointsSource
76
104
  ? parsePluginSdkEntrypointSpecifiers(pluginSdkEntrypointsSource, "supportedBundledFacadeSdkEntrypoints")
77
105
  : [];
@@ -81,12 +109,15 @@ export async function readOpenClawTargetSurface(options = {}) {
81
109
 
82
110
  return {
83
111
  configuredPath: requestedPath,
112
+ checkoutPath: relativePath(rootDir, resolvedPath) || ".",
84
113
  searchedPaths: requestedPaths,
85
114
  status: "ok",
86
115
  compatRegistryPath: relativePath(rootDir, registryPath),
87
116
  compatRecordCount: compatRecordEntries.length,
88
117
  compatRecords: compatRecordEntries.map((record) => record.code).sort(),
89
118
  compatRecordStatuses: Object.fromEntries(compatRecordEntries.map((record) => [record.code, record.status])),
119
+ compatRecordTests,
120
+ compatRecordMissingTests,
90
121
  hookTypesPath: existsSync(hookTypesPath) ? relativePath(rootDir, hookTypesPath) : null,
91
122
  hookNameCount: hookNames.length,
92
123
  hookNames,
@@ -99,11 +130,14 @@ export async function readOpenClawTargetSurface(options = {}) {
99
130
  packagePath: existsSync(packagePath) ? relativePath(rootDir, packagePath) : null,
100
131
  sdkExportCount: sdkExports.length,
101
132
  sdkExports,
133
+ privateLocalSdkExportCount: privateLocalSdkExports.length,
134
+ privateLocalSdkExports,
102
135
  pluginSdkEntrypointsPath: existsSync(pluginSdkEntrypointsPath)
103
136
  ? relativePath(rootDir, pluginSdkEntrypointsPath)
104
137
  : null,
105
138
  reservedSdkExportCount: reservedSdkExports.length,
106
139
  reservedSdkExports,
140
+ reservedSdkExportOwners,
107
141
  supportedFacadeSdkExports,
108
142
  publicPluginOwnedSdkExports,
109
143
  manifestTypesPath: existsSync(manifestTypesPath) ? relativePath(rootDir, manifestTypesPath) : null,
@@ -121,6 +155,23 @@ export function openClawTargetPathCandidates(manifest, configuredPath) {
121
155
  return unique([manifest?.openclaw?.defaultCheckoutPath, ...defaultOpenClawCheckoutPaths].filter(Boolean));
122
156
  }
123
157
 
158
+ function importsCompatRecords(source) {
159
+ // Keep quoted text atomic and discard comments before recognizing the fixed delegation.
160
+ const tokens = (source.match(/\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`|[$A-Z_a-z][$\w]*|[^\s]/g) ?? [])
161
+ .filter((token) => !token.startsWith("//") && !token.startsWith("/*"));
162
+ for (let index = 0; index < tokens.length; index += 1) {
163
+ if (tokens[index] !== "import" || tokens[index + 1] !== "{") continue;
164
+ const end = tokens.indexOf("}", index + 2);
165
+ if (end === -1 || tokens[end + 1] !== "from") continue;
166
+ if (!['"./registry-records.js"', "'./registry-records.js'"].includes(tokens[end + 2])) continue;
167
+ const bindings = tokens.slice(index + 2, end).join(" ").split(",");
168
+ if (bindings.some((binding) => /^PLUGIN_COMPAT_RECORDS(?:\s+as\s+[$A-Z_a-z][$\w]*)?$/.test(binding.trim()))) {
169
+ return true;
170
+ }
171
+ }
172
+ return false;
173
+ }
174
+
124
175
  export function parseCompatRecordEntries(source) {
125
176
  const entries = [];
126
177
  let cursor = 0;
@@ -132,8 +183,14 @@ export function parseCompatRecordEntries(source) {
132
183
 
133
184
  const statusProperty = readStringProperty(source, "status", codeProperty.end);
134
185
  if (statusProperty) {
135
- entries.push({ code: codeProperty.value, status: statusProperty.value });
136
- cursor = statusProperty.end;
186
+ const nextCodeProperty = readStringProperty(source, "code", statusProperty.end);
187
+ const recordEnd = nextCodeProperty?.propertyIndex ?? source.length;
188
+ entries.push({
189
+ code: codeProperty.value,
190
+ status: statusProperty.value,
191
+ tests: readStringArrayProperty(source, "tests", statusProperty.end, recordEnd),
192
+ });
193
+ cursor = recordEnd;
137
194
  } else {
138
195
  cursor = codeProperty.end;
139
196
  }
@@ -157,7 +214,21 @@ function readStringProperty(source, property, fromIndex) {
157
214
  if (!isQuote(source[quoteIndex])) {
158
215
  return null;
159
216
  }
160
- return readQuotedValue(source, quoteIndex);
217
+ const value = readQuotedValue(source, quoteIndex);
218
+ return value ? { ...value, propertyIndex } : null;
219
+ }
220
+
221
+ function readStringArrayProperty(source, property, fromIndex, toIndex) {
222
+ const propertyIndex = findProperty(source, property, fromIndex);
223
+ if (propertyIndex === -1 || propertyIndex >= toIndex) return [];
224
+ const colonIndex = source.indexOf(":", propertyIndex + property.length);
225
+ const openIndex = source.indexOf("[", colonIndex + 1);
226
+ if (colonIndex === -1 || openIndex === -1 || openIndex >= toIndex) return [];
227
+ const closeIndex = source.indexOf("]", openIndex + 1);
228
+ if (closeIndex === -1 || closeIndex >= toIndex) return [];
229
+ return unique(
230
+ [...source.slice(openIndex + 1, closeIndex).matchAll(/["']([^"']+)["']/g)].map((match) => match[1]),
231
+ ).sort();
161
232
  }
162
233
 
163
234
  function findProperty(source, property, fromIndex) {
@@ -298,6 +369,7 @@ async function readPackedOpenClawTargetSurface({ rootDir, requestedPaths, reques
298
369
 
299
370
  return {
300
371
  configuredPath: requestedPath,
372
+ checkoutPath: relativePath(rootDir, resolvedPath) || ".",
301
373
  searchedPaths: requestedPaths,
302
374
  status: "ok",
303
375
  version: packageJson.version ?? null,
@@ -305,6 +377,8 @@ async function readPackedOpenClawTargetSurface({ rootDir, requestedPaths, reques
305
377
  compatRecordCount: 0,
306
378
  compatRecords: [],
307
379
  compatRecordStatuses: {},
380
+ compatRecordTests: {},
381
+ compatRecordMissingTests: {},
308
382
  hookTypesPath: hookDeclaration ? relativePath(rootDir, hookDeclaration.filePath) : null,
309
383
  hookNameCount: hookNames.length,
310
384
  hookNames,
@@ -317,9 +391,12 @@ async function readPackedOpenClawTargetSurface({ rootDir, requestedPaths, reques
317
391
  packagePath: relativePath(rootDir, packagePath),
318
392
  sdkExportCount: sdkExports.length,
319
393
  sdkExports,
394
+ privateLocalSdkExportCount: 0,
395
+ privateLocalSdkExports: [],
320
396
  pluginSdkEntrypointsPath: null,
321
397
  reservedSdkExportCount: 0,
322
398
  reservedSdkExports: [],
399
+ reservedSdkExportOwners: {},
323
400
  supportedFacadeSdkExports: [],
324
401
  publicPluginOwnedSdkExports: [],
325
402
  manifestTypesPath: manifestDeclaration ? relativePath(rootDir, manifestDeclaration.filePath) : null,
@@ -443,15 +520,20 @@ function parseStringUnion(source, typeName) {
443
520
  function emptyTargetSurface({ configuredPath, searchedPaths = undefined, status }) {
444
521
  return {
445
522
  configuredPath,
523
+ checkoutPath: null,
446
524
  searchedPaths,
447
525
  status,
448
526
  compatRecords: [],
449
527
  compatRecordStatuses: {},
528
+ compatRecordTests: {},
529
+ compatRecordMissingTests: {},
450
530
  hookNames: [],
451
531
  apiRegistrars: [],
452
532
  capturedRegistrars: [],
453
533
  sdkExports: [],
534
+ privateLocalSdkExports: [],
454
535
  reservedSdkExports: [],
536
+ reservedSdkExportOwners: {},
455
537
  supportedFacadeSdkExports: [],
456
538
  publicPluginOwnedSdkExports: [],
457
539
  manifestFields: [],
@@ -459,10 +541,33 @@ function emptyTargetSurface({ configuredPath, searchedPaths = undefined, status
459
541
  };
460
542
  }
461
543
 
544
+ async function readBundledPluginIds(openClawRoot) {
545
+ const extensionsRoot = path.join(openClawRoot, "extensions");
546
+ if (!existsSync(extensionsRoot)) return [];
547
+ return (await readdir(extensionsRoot, { withFileTypes: true }))
548
+ .filter((entry) => entry.isDirectory())
549
+ .map((entry) => entry.name)
550
+ .sort((left, right) => right.length - left.length || left.localeCompare(right));
551
+ }
552
+
553
+ function resolveBundledSdkOwner(specifier, pluginIds) {
554
+ const entrypoint = specifier.slice("openclaw/plugin-sdk/".length);
555
+ return pluginIds.find((pluginId) => entrypoint === pluginId || entrypoint.startsWith(`${pluginId}-`)) ?? null;
556
+ }
557
+
462
558
  export function parsePluginSdkEntrypointSpecifiers(source, exportName) {
463
559
  return parseExportedStringArray(source, exportName).map((entrypoint) => `openclaw/plugin-sdk/${entrypoint}`).sort();
464
560
  }
465
561
 
562
+ function parsePluginSdkSubpathSpecifiers(value) {
563
+ if (!Array.isArray(value)) return [];
564
+ return unique(
565
+ value
566
+ .filter((entrypoint) => typeof entrypoint === "string" && !entrypoint.includes("/"))
567
+ .map((entrypoint) => `openclaw/plugin-sdk/${entrypoint}`),
568
+ ).sort();
569
+ }
570
+
466
571
  function parseCapturedRegistrars(source) {
467
572
  return unique([...source.matchAll(/^\s*(register[A-Za-z0-9]+)\s*\(/gm)].map((match) => match[1])).sort();
468
573
  }
@@ -0,0 +1,191 @@
1
+ import * as nodeModule from "node:module";
2
+ import { parse } from "acorn";
3
+ import { analyze } from "eslint-scope";
4
+
5
+ const literalModuleImport = /(?<![$\w.])(?:(?:const|let|var)\s+(?:\{[^{}]*\}|[$A-Z_a-z][$\w]*)\s*=\s*)?(?<kind>require|import)\s*\(\s*(?<quote>["'`])(?<specifier>[^"'`\\\r\n]+)\k<quote>/dg;
6
+
7
+ export function collectRuntimeModuleImports(text) {
8
+ // Mark literal occurrences without interpreting quotes or regexes; the AST owns real calls.
9
+ const entries = [...text.matchAll(literalModuleImport)].map(moduleImportEntry).filter(Boolean);
10
+ if (entries.length === 0) return entries;
11
+ const { runtimeText, imports } = classifyRuntimeImports(text, entries);
12
+ let ast;
13
+ let scopes;
14
+ try {
15
+ // Accept both source modules and CommonJS wrappers without changing their runtime.
16
+ ast = parse(runtimeText, {
17
+ ecmaVersion: "latest", ranges: true, sourceType: "script",
18
+ allowAwaitOutsideFunction: true, allowReturnOutsideFunction: true, allowImportExportEverywhere: true,
19
+ });
20
+ scopes = analyze(ast, {
21
+ ecmaVersion: 2026,
22
+ sourceType: ast.body.some((node) => /^(?:Import|Export).*Declaration$/.test(node.type)) ? "module" : "commonjs",
23
+ });
24
+ } catch {
25
+ // Keep known imports if syntax is incomplete/unsupported; never guess namespace exports.
26
+ // Type erasure has already classified each occurrence, even when AST analysis fails.
27
+ const retained = new Set(imports.map(({ entry }) => entry.specifierStart));
28
+ return [...scanLiteralModuleImports(text)].filter((entry) => retained.has(entry.specifierStart))
29
+ .map((entry) => ({ ...entry, names: new Set() }));
30
+ }
31
+
32
+ const parents = new Map();
33
+ const nodes = [];
34
+ visit(ast);
35
+ function visit(node, parent) {
36
+ if (!node || typeof node.type !== "string") return;
37
+ parents.set(node, parent);
38
+ nodes.push(node);
39
+ for (const value of Object.values(node)) {
40
+ if (Array.isArray(value)) value.forEach((child) => visit(child, node));
41
+ else if (value && typeof value === "object") visit(value, node);
42
+ }
43
+ }
44
+ const byMarker = new Map(imports.map(({ marker, entry }) => [marker, entry]));
45
+ const result = [];
46
+ for (const node of nodes) {
47
+ const source = node.type === "ImportExpression" ? node.source
48
+ : node.type === "CallExpression" && node.callee.type === "Identifier"
49
+ && node.callee.name === "require" && node.arguments.length === 1 ? node.arguments[0] : null;
50
+ const specifier = source?.type === "Literal" ? source.value
51
+ : source?.type === "TemplateLiteral" && source.expressions.length === 0 ? source.quasis[0].value.cooked : null;
52
+ const entry = byMarker.get(specifier);
53
+ if (!entry) continue;
54
+ const names = new Set();
55
+ result.push({ ...entry, names });
56
+ const access = parents.get(node);
57
+ const call = parents.get(access);
58
+ if (node.type === "ImportExpression" && access?.type === "MemberExpression" && access.object === node
59
+ && !access.computed && access.property.name === "then" && call?.type === "CallExpression" && call.callee === access) {
60
+ const callback = call.arguments[0];
61
+ if (callback?.type === "ArrowFunctionExpression" || callback?.type === "FunctionExpression") {
62
+ collectBindingNames(callback.params[0], callback, names);
63
+ }
64
+ }
65
+ // Promise methods belong to import(), not its awaited module namespace.
66
+ const module = entry.kind === "require" ? node
67
+ : parents.get(node)?.type === "AwaitExpression" ? parents.get(node) : null;
68
+ if (!module) continue;
69
+ const parent = parents.get(module);
70
+ if (parent?.type === "MemberExpression" && parent.object === module && !parent.computed && parent.property.type === "Identifier") {
71
+ names.add(parent.property.name);
72
+ }
73
+ if (parent?.type === "VariableDeclarator" && parent.init === module) collectBindingNames(parent.id, parent, names);
74
+ }
75
+ return result.sort((a, b) => a.index - b.index);
76
+
77
+ function collectBindingNames(binding, owner, names) {
78
+ if (binding?.type === "ObjectPattern") {
79
+ for (const property of binding.properties) {
80
+ if (property.type === "Property" && !property.computed && property.key.type === "Identifier") {
81
+ names.add(property.key.name);
82
+ }
83
+ }
84
+ } else if (binding?.type === "Identifier") {
85
+ // Resolve references to this declaration, including closures but excluding shadowed names.
86
+ for (const variable of scopes.getDeclaredVariables(owner)) {
87
+ if (!variable.identifiers.includes(binding)) continue;
88
+ for (const reference of variable.references) {
89
+ const access = parents.get(reference.identifier);
90
+ if (access?.type === "MemberExpression" && access.object === reference.identifier && !access.computed && access.property.type === "Identifier") {
91
+ names.add(access.property.name);
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ function* scanLiteralModuleImports(text) {
100
+ const quotedOrComment = /\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'/;
101
+ const code = new RegExp(quotedOrComment.source + "|" + literalModuleImport.source + "|[`{}]", "dg");
102
+ const template = /\\[\s\S]|`|\$\{/g;
103
+ const templateDepths = [];
104
+ let inTemplateText = false;
105
+ let cursor = 0;
106
+ // Skip quoted/comment text, but scan executable template interpolations.
107
+ while (cursor < text.length) {
108
+ const pattern = inTemplateText ? template : code;
109
+ pattern.lastIndex = cursor;
110
+ const match = pattern.exec(text);
111
+ if (!match) break;
112
+ cursor = pattern.lastIndex;
113
+ if (inTemplateText) {
114
+ if (match[0] === "`") {
115
+ templateDepths.pop();
116
+ inTemplateText = false;
117
+ } else if (match[0] === "${") {
118
+ templateDepths[templateDepths.length - 1] = 1;
119
+ inTemplateText = false;
120
+ }
121
+ continue;
122
+ }
123
+ if (match[0] === "`") {
124
+ templateDepths.push(0);
125
+ inTemplateText = true;
126
+ } else if (templateDepths.length && match[0] === "{") {
127
+ templateDepths[templateDepths.length - 1] += 1;
128
+ } else if (templateDepths.length && match[0] === "}") {
129
+ inTemplateText = --templateDepths[templateDepths.length - 1] === 0;
130
+ }
131
+ const entry = moduleImportEntry(match);
132
+ if (entry) yield entry;
133
+ }
134
+ }
135
+
136
+ function moduleImportEntry(match) {
137
+ const groups = match.groups;
138
+ if (!groups?.specifier || (groups.quote === "`" && groups.specifier.includes("${"))) return null;
139
+ return {
140
+ specifier: groups.specifier,
141
+ specifierStart: match.indices.groups.specifier[0],
142
+ kind: groups.kind,
143
+ index: groups.kind === "import" ? match.indices.groups.kind[0] : match.index,
144
+ };
145
+ }
146
+
147
+ function classifyRuntimeImports(text, entries) {
148
+ const markedImports = entries.map((entry, index) => {
149
+ const { specifier, specifierStart } = entry;
150
+ return {
151
+ entry,
152
+ specifierStart,
153
+ specifierEnd: specifierStart + specifier.length,
154
+ marker: `${specifier}__plugin_inspector_runtime_import_${index}__`,
155
+ };
156
+ });
157
+ let markedText = text;
158
+ for (const markedImport of markedImports.toReversed()) {
159
+ markedText =
160
+ markedText.slice(0, markedImport.specifierStart) +
161
+ markedImport.marker +
162
+ markedText.slice(markedImport.specifierEnd);
163
+ }
164
+
165
+ let runtimeText = null;
166
+ try {
167
+ runtimeText = eraseTypeScript(markedText);
168
+ } catch {
169
+ // Unsupported or incomplete TypeScript cannot prove an import is type-only.
170
+ }
171
+ return {
172
+ runtimeText: runtimeText ?? markedText,
173
+ imports: runtimeText === null ? markedImports : markedImports.filter(({ marker }) => runtimeText.includes(marker)),
174
+ };
175
+ }
176
+
177
+ function eraseTypeScript(text) {
178
+ if (typeof nodeModule.stripTypeScriptTypes === "function") {
179
+ try {
180
+ return nodeModule.stripTypeScriptTypes(text, { mode: "transform" });
181
+ } catch (error) {
182
+ if (error?.code !== "ERR_INVALID_ARG_VALUE") throw error;
183
+ return nodeModule.stripTypeScriptTypes(text, { mode: "strip" });
184
+ }
185
+ }
186
+ if (typeof globalThis.Bun?.Transpiler === "function") {
187
+ const transpiler = new globalThis.Bun.Transpiler({ loader: "ts", target: "bun" });
188
+ return transpiler.transformSync(text);
189
+ }
190
+ return null;
191
+ }
@@ -81,12 +81,6 @@ function expectedRuntimeCaptureKeys(finding) {
81
81
  if (finding.code === "runtime-tool-capture") {
82
82
  return ["registration:registerTool"];
83
83
  }
84
- if (finding.code === "conversation-access-hook") {
85
- return names.map((name) => `hook:${name}`);
86
- }
87
- if (finding.code === "before-tool-call-probe") {
88
- return ["hook:before_tool_call"];
89
- }
90
84
  return [];
91
85
  }
92
86
 
package/src/sdk-mock.js CHANGED
@@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises"
2
2
  import * as nodeModule from "node:module";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import { collectRuntimeModuleImports } from "./runtime-imports.js";
5
6
 
6
7
  const SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
7
8
  const SKIP_DIRS = new Set([".git", "coverage", "node_modules", "reports"]);
@@ -164,6 +165,14 @@ export const mockSdkSubpathExports = {
164
165
  "normalizeSecretInputString",
165
166
  ],
166
167
  "plugin-runtime": ["createLoggerBackedRuntime", "createSubsystemLogger"],
168
+ "lazy-runtime": [
169
+ "createLazyRuntimeModule",
170
+ "createLazyRuntimeMethod",
171
+ "createLazyRuntimeMethodBinder",
172
+ "createLazyRuntimeNamedExport",
173
+ "createLazyRuntimeSurface",
174
+ ],
175
+ "error-runtime": ["formatErrorMessage"],
167
176
  "secret-input": [
168
177
  "buildOptionalSecretInputSchema",
169
178
  "buildSecretInputArraySchema",
@@ -430,62 +439,12 @@ function parseModuleImports(text) {
430
439
  for (const match of text.matchAll(/\bimport\s+["']([^"']+)["']/g)) {
431
440
  entries.push({ specifier: match[1], names: new Set() });
432
441
  }
433
- for (const { specifier, binding, member } of collectCommonJsRequires(text)) {
434
- const names = new Set();
435
- if (binding?.startsWith("{")) {
436
- for (const part of binding.slice(1, -1).split(",")) {
437
- const name = part.split(/[:=]/)[0].trim();
438
- if (isValidExportName(name)) names.add(name);
439
- }
440
- } else if (binding) {
441
- const escaped = binding.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
442
- for (const access of text.matchAll(new RegExp(`(?<![$\\w])${escaped}\\s*\\.\\s*([$\\w]+)`, "g"))) {
443
- names.add(access[1]);
444
- }
445
- }
446
- if (member) names.add(member);
447
- entries.push({ specifier, names, require: true });
442
+ for (const { specifier, names, kind } of collectRuntimeModuleImports(text)) {
443
+ entries.push({ specifier, names, require: kind === "require" });
448
444
  }
449
445
  return entries;
450
446
  }
451
447
 
452
- export function* collectCommonJsRequires(text) {
453
- const code = /\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|(?<![$\w.])(?:(?:const|let|var)\s+(\{[^{}]*\}|[$A-Z_a-z][$\w]*)\s*=\s*)?require\s*\(\s*["']([^"']+)["']\s*\)(?:\s*\.\s*([$A-Z_a-z][$\w]*))?|[`{}]/g;
454
- const template = /\\[\s\S]|`|\$\{/g;
455
- const templateDepths = [];
456
- let inTemplateText = false;
457
- let cursor = 0;
458
- // Skip quoted/comment text, but scan executable template interpolations.
459
- while (cursor < text.length) {
460
- const pattern = inTemplateText ? template : code;
461
- pattern.lastIndex = cursor;
462
- const match = pattern.exec(text);
463
- if (!match) break;
464
- cursor = pattern.lastIndex;
465
- if (inTemplateText) {
466
- if (match[0] === "`") {
467
- templateDepths.pop();
468
- inTemplateText = false;
469
- } else if (match[0] === "${") {
470
- templateDepths[templateDepths.length - 1] = 1;
471
- inTemplateText = false;
472
- }
473
- continue;
474
- }
475
- if (match[0] === "`") {
476
- templateDepths.push(0);
477
- inTemplateText = true;
478
- } else if (templateDepths.length && match[0] === "{") {
479
- templateDepths[templateDepths.length - 1] += 1;
480
- } else if (templateDepths.length && match[0] === "}") {
481
- inTemplateText = --templateDepths[templateDepths.length - 1] === 0;
482
- }
483
- if (match[2]) {
484
- yield { specifier: match[2], binding: match[1], member: match[3], index: match.index };
485
- }
486
- }
487
- }
488
-
489
448
  function isTypeOnlyImportOrExport(statement, clause) {
490
449
  return /^\s*import\s+type\b/u.test(statement) || /^\s*export\s+type\b/u.test(statement) || /^\s*type\b/u.test(clause);
491
450
  }
@@ -741,6 +700,11 @@ function isValidExportName(name) {
741
700
  }
742
701
 
743
702
  function genericExportStatement(name) {
703
+ if (name === "normalizeOptionalString") {
704
+ // Optional values stay absent; an empty string invents explicit input in
705
+ // callers that distinguish undefined from a configured policy value.
706
+ return 'export function normalizeOptionalString(value) { return typeof value === "string" ? value.trim() || undefined : undefined; }';
707
+ }
744
708
  if (name === "isRecord") {
745
709
  return "export function isRecord(value) { return isPlainObject(value); }";
746
710
  }
@@ -1758,6 +1722,37 @@ export function resolveRuntimeEnv(env = {}) {
1758
1722
  return createRuntimeEnv(env);
1759
1723
  }
1760
1724
 
1725
+ export function createLazyRuntimeSurface(importer, select) {
1726
+ let promise;
1727
+ const load = () => {
1728
+ // SDK runtime imports retain the same promise, including rejection, until clear().
1729
+ promise ??= Promise.resolve().then(() => importer().then(select));
1730
+ return promise;
1731
+ };
1732
+ load.peek = () => promise;
1733
+ load.clear = () => { promise = undefined; };
1734
+ return load;
1735
+ }
1736
+
1737
+ export function createLazyRuntimeModule(importer) {
1738
+ return createLazyRuntimeSurface(importer, (module) => module);
1739
+ }
1740
+
1741
+ export function createLazyRuntimeNamedExport(importer, key) {
1742
+ return createLazyRuntimeSurface(importer, (module) => module[key]);
1743
+ }
1744
+
1745
+ export function createLazyRuntimeMethod(load, select) {
1746
+ return async (...args) => {
1747
+ const method = select(await load());
1748
+ return await method(...args);
1749
+ };
1750
+ }
1751
+
1752
+ export function createLazyRuntimeMethodBinder(load) {
1753
+ return (select) => createLazyRuntimeMethod(load, select);
1754
+ }
1755
+
1761
1756
  export function createLoggerBackedRuntime(logger = console) {
1762
1757
  return { logger };
1763
1758
  }
@@ -635,6 +635,7 @@ export async function runCapturedSyntheticProbes(capture, options = {}) {
635
635
  try {
636
636
  const result = await runCapturedProbes(capture, {
637
637
  ...options, hookEvents, hookContexts, timeoutMs, controller, signal: controller.signal,
638
+ gatewayConfig: options.apiOptions?.config ?? {},
638
639
  });
639
640
  if (options.signal?.aborted) throw controller.signal.reason;
640
641
  return result;
@@ -804,6 +805,16 @@ async function runRegistrationProbes(entry, retainedEntry, captureIndex, options
804
805
  if (!descriptor || typeof descriptor !== "object") {
805
806
  return [blockedResult(entry, captureIndex, "captured registration has no object descriptor")];
806
807
  }
808
+ const method = descriptor.method ?? descriptor.name;
809
+ if (entry.name === "registerGatewayMethod" && Object.hasOwn(options.gatewayMethodPrerequisites ?? {}, method)) {
810
+ const reason = options.gatewayMethodPrerequisites[method];
811
+ if (typeof reason !== "string" || reason.trim().length === 0) {
812
+ throw new TypeError(`Gateway probe prerequisite for ${method} must be a non-empty string`);
813
+ }
814
+ // A method needing host state or live credentials cannot be exercised with
815
+ // the default empty request. Record the missing prerequisite before calling it.
816
+ return [{ ...blockedResult(entry, captureIndex, reason), method }];
817
+ }
807
818
  if (profile.option && options[profile.option] !== true) {
808
819
  return [blockedResult(entry, captureIndex, `captured registration requires ${profile.option}=true`)];
809
820
  }
@@ -1022,7 +1033,7 @@ function gatewayProbeArgs(event, options = {}) {
1022
1033
  req: { type: "req", id: "fixture-request", method: event.method ?? "fixture.gateway.method", params: event.params },
1023
1034
  client: null,
1024
1035
  isWebchatConnect: () => false,
1025
- context: { source: event.source, logger: console },
1036
+ context: { source: event.source, logger: console, getRuntimeConfig: () => options.gatewayConfig },
1026
1037
  signal: options.signal,
1027
1038
  },
1028
1039
  ];