@openclaw/plugin-inspector 0.3.15 → 0.3.17

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,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.17 - 2026-06-29
6
+
7
+ ### Fixed
8
+
9
+ - Detect deprecated session SDK read/write, file-path, and transcript helpers across source files, packaged `dist`/`build` artifacts, runtime session APIs, and dynamic SDK imports.
10
+
11
+ ## 0.3.16 - 2026-06-23
12
+
13
+ ### Fixed
14
+
15
+ - Write `package.json` updates atomically for `init --scripts` and `prune-workspace-dev-deps`, including symlink-preserving target rewrites and temp-file cleanup on staging failures. Thanks @KrasimirKralev.
16
+ - Mock the Lark SDK HTTP interceptor surface during runtime capture so Feishu plugin bundles load successfully.
17
+
5
18
  ## 0.3.15 - 2026-06-12
6
19
 
7
20
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/plugin-inspector",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "description": "Offline compatibility inspector for OpenClaw plugins.",
6
6
  "type": "module",
@@ -151,7 +151,7 @@ export function createCaptureContext(options = {}) {
151
151
  resolvePath: options.resolvePath ?? ((value) => value),
152
152
  runtime: options.runtime ?? createRuntimeContext(options),
153
153
  secrets: options.secrets ?? createSecretContext(options),
154
- store: options.store ?? createStoreContext(options),
154
+ store: options.store ?? createStoreContext(),
155
155
  paths: options.paths ?? {
156
156
  cacheDir: ".plugin-inspector/cache",
157
157
  configDir: ".plugin-inspector/config",
package/src/cli.js CHANGED
@@ -38,7 +38,7 @@ try {
38
38
  await runConfig(commandArgs);
39
39
  } else if (command === "inspect" || command === "report") {
40
40
  if (command === "inspect" && !commandArgs.includes("--config")) {
41
- await runCheck(commandArgs);
41
+ await runCheck(commandArgs, { check: commandArgs.includes("--check") });
42
42
  } else {
43
43
  await runReport(command, commandArgs);
44
44
  }
@@ -100,7 +100,7 @@ async function runConfig(commandArgs) {
100
100
  }
101
101
  }
102
102
 
103
- async function runCheck(commandArgs) {
103
+ async function runCheck(commandArgs, options = {}) {
104
104
  const configPath = readFlag(commandArgs, "--config");
105
105
  const pluginRoot = readFlag(commandArgs, "--plugin-root") ?? readFlag(commandArgs, "--root");
106
106
  const outDir = readFlag(commandArgs, "--out") ?? "reports";
@@ -133,7 +133,7 @@ async function runCheck(commandArgs) {
133
133
  console.log(renderTextSummary(report, { artifacts: paths }));
134
134
  }
135
135
 
136
- if (report.status !== "pass") {
136
+ if ((options.check ?? true) && report.status !== "pass") {
137
137
  throw new Error(`plugin-inspector found ${report.summary.breakageCount} breakages`);
138
138
  }
139
139
  }
@@ -288,7 +288,7 @@ async function runCapture(commandArgs) {
288
288
  const entrypoint = findCaptureEntrypoint(commandArgs);
289
289
  const outputPath = readFlag(commandArgs, "--output");
290
290
  const pluginRoot = readFlag(commandArgs, "--plugin-root");
291
- const mockSdk = readMockSdkFlag(commandArgs) ?? commandArgs.includes("--mock-sdk");
291
+ const mockSdk = readMockSdkFlag(commandArgs) ?? true;
292
292
  const allowExecution = readAllowExecutionFlag(commandArgs);
293
293
  if (!entrypoint) {
294
294
  throw new Error("capture requires an entrypoint path");
@@ -724,14 +724,39 @@ function classifySdkDeprecations({ fixture, inspection, fixtureReport, warnings,
724
724
  decisions.push({
725
725
  fixture: fixture.id,
726
726
  decision: "core-compat-adapter",
727
- seam: "session-store",
728
- action:
729
- "Keep loadSessionStore compatibility active while plugin authors migrate to row-scoped session helpers.",
727
+ seam: sdkDeprecationSeamForCode(code),
728
+ action: sdkDeprecationActionForCode(code),
730
729
  evidence: findings.map((finding) => finding.ref).join(", "),
731
730
  });
732
731
  }
733
732
  }
734
733
 
734
+ function sdkDeprecationSeamForCode(code) {
735
+ if (code === "sdk-session-file-helper") {
736
+ return "session-file";
737
+ }
738
+ if (code === "sdk-session-transcript-file-target" || code === "sdk-session-transcript-low-level") {
739
+ return "session-transcript";
740
+ }
741
+ return "session-store";
742
+ }
743
+
744
+ function sdkDeprecationActionForCode(code) {
745
+ if (code === "sdk-session-store-write") {
746
+ return "Keep whole-store session write compatibility active while plugin authors migrate to row-scoped session write helpers.";
747
+ }
748
+ if (code === "sdk-session-file-helper") {
749
+ return "Keep session file-path compatibility active while plugin authors migrate to session entry and transcript identity helpers.";
750
+ }
751
+ if (code === "sdk-session-transcript-file-target") {
752
+ return "Keep legacy transcript file target compatibility active while plugin authors migrate to structured transcript targets.";
753
+ }
754
+ if (code === "sdk-session-transcript-low-level") {
755
+ return "Keep low-level transcript write compatibility active while plugin authors migrate to structured transcript runtime helpers.";
756
+ }
757
+ return "Keep loadSessionStore compatibility active while plugin authors migrate to row-scoped session helpers.";
758
+ }
759
+
735
760
  function classifySecurityManifestCoverage({ fixture, fixtureReport, warnings, decisions }) {
736
761
  for (const securityManifest of fixtureReport.securityManifests ?? []) {
737
762
  warnings.push({
package/src/init.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { inferPluginSeams, packageId } from "./config.js";
5
+ import { writeJsonFileAtomic } from "./json-file.js";
5
6
 
6
7
  export const defaultInitConfigPath = "plugin-inspector.config.json";
7
8
  export const defaultInitWorkflowPath = ".github/workflows/plugin-inspector.yml";
@@ -59,7 +60,7 @@ export async function writePluginInspectorInit(options = {}) {
59
60
  ...defaultInitPackageScripts,
60
61
  };
61
62
  if (!dryRun) {
62
- await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
63
+ await writeJsonFileAtomic(packageJsonPath, packageJson);
63
64
  }
64
65
  written.push(packageJsonPath);
65
66
  }
package/src/inspector.js CHANGED
@@ -95,10 +95,17 @@ export async function inspectPlugin(fixture, options = {}) {
95
95
  return emptyInspection(fixture, "missing");
96
96
  }
97
97
 
98
- const files = await listSourceFiles(sourceRoot, { includeDist: Boolean(fixture.package) });
98
+ const packageInspection = await readPackageMetadata(config, checkoutPath, sourceRoot);
99
+ const includeBuildArtifacts = shouldScanBuildArtifacts(fixture, packageInspection);
100
+ const files = await listSourceFiles(sourceRoot, {
101
+ includeBuild: includeBuildArtifacts,
102
+ includeDist: includeBuildArtifacts,
103
+ });
99
104
  if (sourceRoot !== checkoutPath) {
100
105
  files.push(...(await listSourceFiles(checkoutPath, { shallowRootOnly: true })));
101
106
  }
107
+ files.push(...packageInspection.entrypointFiles);
108
+ const sourceFiles = uniquePaths(files);
102
109
 
103
110
  const hooks = new Set();
104
111
  const registrations = new Set();
@@ -107,7 +114,7 @@ export async function inspectPlugin(fixture, options = {}) {
107
114
  const sdkImportDetails = [];
108
115
  const sdkDeprecationDetails = [];
109
116
 
110
- for (const filePath of files) {
117
+ for (const filePath of sourceFiles) {
111
118
  const text = await readFile(filePath, "utf8");
112
119
  const relativePath = path.relative(config.rootDir ?? process.cwd(), filePath);
113
120
  const sourceInspection = inspectSourceText(text, relativePath);
@@ -129,8 +136,6 @@ export async function inspectPlugin(fixture, options = {}) {
129
136
  }
130
137
 
131
138
  const manifestInspection = await readManifestContracts(config, checkoutPath, sourceRoot);
132
- const packageInspection = await readPackageMetadata(config, checkoutPath, sourceRoot);
133
-
134
139
  return {
135
140
  id: fixture.id,
136
141
  status: "ok",
@@ -146,7 +151,7 @@ export async function inspectPlugin(fixture, options = {}) {
146
151
  packageEntrypoints: packageInspection.entrypoints,
147
152
  sdkImports: uniqueDetails(sdkImportDetails),
148
153
  sdkDeprecations: uniqueSdkDeprecations(sdkDeprecationDetails),
149
- sourceFiles: files.map((filePath) => path.relative(config.rootDir ?? process.cwd(), filePath)).sort(),
154
+ sourceFiles: sourceFiles.map((filePath) => path.relative(config.rootDir ?? process.cwd(), filePath)).sort(),
150
155
  };
151
156
  }
152
157
 
@@ -418,18 +423,23 @@ async function readPackageMetadata(config, checkoutPath, sourceRoot) {
418
423
  const files = [];
419
424
  const errors = [];
420
425
  const entrypoints = new Set();
426
+ const entrypointFiles = new Set();
421
427
 
422
428
  for (const packageFile of packageFiles) {
423
429
  const relativePath = path.relative(config.rootDir ?? process.cwd(), packageFile);
424
430
  files.push(relativePath);
425
431
  try {
426
432
  const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
427
- collectEntrypoint(entrypoints, packageJson.main);
428
- collectEntrypoint(entrypoints, packageJson.module);
429
- collectEntrypoint(entrypoints, packageJson.openclaw?.entry);
430
- collectEntrypoint(entrypoints, packageJson.openclaw?.entrypoint);
431
- collectEntrypoint(entrypoints, packageJson.exports?.["."]?.import);
432
- collectEntrypoint(entrypoints, packageJson.exports?.["."]?.default);
433
+ const packageDir = path.dirname(packageFile);
434
+ collectEntrypoint(entrypoints, entrypointFiles, packageDir, packageJson.main);
435
+ collectEntrypoint(entrypoints, entrypointFiles, packageDir, packageJson.module);
436
+ collectEntrypoint(entrypoints, entrypointFiles, packageDir, packageJson.openclaw?.entry);
437
+ collectEntrypoint(entrypoints, entrypointFiles, packageDir, packageJson.openclaw?.entrypoint);
438
+ collectEntrypoint(entrypoints, entrypointFiles, packageDir, packageJson.openclaw?.setupEntry);
439
+ collectEntrypoint(entrypoints, entrypointFiles, packageDir, packageJson.exports?.["."]?.import);
440
+ collectEntrypoint(entrypoints, entrypointFiles, packageDir, packageJson.exports?.["."]?.default);
441
+ collectEntrypoints(entrypoints, entrypointFiles, packageDir, packageJson.openclaw?.extensions);
442
+ collectEntrypoints(entrypoints, entrypointFiles, packageDir, packageJson.openclaw?.runtimeExtensions);
433
443
  } catch {
434
444
  errors.push(`${relativePath}: invalid JSON`);
435
445
  }
@@ -439,13 +449,58 @@ async function readPackageMetadata(config, checkoutPath, sourceRoot) {
439
449
  files: files.sort(),
440
450
  errors,
441
451
  entrypoints: [...entrypoints].sort(),
452
+ entrypointFiles: [...entrypointFiles].sort(),
442
453
  };
443
454
  }
444
455
 
445
- function collectEntrypoint(entrypoints, value) {
456
+ function collectEntrypoints(entrypoints, entrypointFiles, packageDir, values) {
457
+ if (!Array.isArray(values)) {
458
+ return;
459
+ }
460
+ for (const value of values) {
461
+ collectEntrypoint(entrypoints, entrypointFiles, packageDir, value);
462
+ }
463
+ }
464
+
465
+ function collectEntrypoint(entrypoints, entrypointFiles, packageDir, value) {
446
466
  if (typeof value === "string" && value.length > 0) {
447
467
  entrypoints.add(value);
468
+ for (const candidate of entrypointCandidates(packageDir, value)) {
469
+ if (existsSync(candidate) && isSourceFile(path.basename(candidate), candidate.split(path.sep).join("/"))) {
470
+ entrypointFiles.add(candidate);
471
+ return;
472
+ }
473
+ }
474
+ }
475
+ }
476
+
477
+ function entrypointCandidates(packageDir, specifier) {
478
+ const resolved = path.resolve(packageDir, specifier);
479
+ if (path.extname(resolved)) {
480
+ return [resolved];
481
+ }
482
+ return [
483
+ resolved,
484
+ `${resolved}.js`,
485
+ `${resolved}.mjs`,
486
+ `${resolved}.cjs`,
487
+ `${resolved}.ts`,
488
+ path.join(resolved, "index.js"),
489
+ path.join(resolved, "index.mjs"),
490
+ path.join(resolved, "index.cjs"),
491
+ path.join(resolved, "index.ts"),
492
+ ];
493
+ }
494
+
495
+ function uniquePaths(paths) {
496
+ return [...new Set(paths)];
497
+ }
498
+
499
+ function shouldScanBuildArtifacts(fixture, packageInspection) {
500
+ if (fixture.package) {
501
+ return true;
448
502
  }
503
+ return packageInspection.entrypoints.some((entrypoint) => /(^|\/)(?:dist|build)\//.test(entrypoint));
449
504
  }
450
505
 
451
506
  async function listSourceFiles(root, options = {}) {
@@ -484,7 +539,7 @@ function shouldSkipDir(name, normalizedPath, options = {}) {
484
539
  return (
485
540
  name === "node_modules" ||
486
541
  (!options.includeDist && name === "dist") ||
487
- name === "build" ||
542
+ (!options.includeBuild && name === "build") ||
488
543
  name === "coverage" ||
489
544
  name === ".git" ||
490
545
  name === "test" ||
package/src/issues.js CHANGED
@@ -42,13 +42,17 @@ export const knownIssueCodes = new Set([
42
42
  "reserved-sdk-import",
43
43
  "security-manifest-schema-unavailable",
44
44
  "sdk-load-session-store",
45
+ "sdk-session-file-helper",
46
+ "sdk-session-store-write",
47
+ "sdk-session-transcript-file-target",
48
+ "sdk-session-transcript-low-level",
45
49
  "sdk-export-missing",
46
50
  "unrecognized-security-manifest",
47
51
  ]);
48
52
 
49
53
  const authorRemediationDocsUrl = (code) => `https://docs.openclaw.ai/clawhub/plugin-validation-fixes#${code}`;
50
54
 
51
- const authorRemediation = (summary) => ({ summary });
55
+ const authorRemediation = (summary, ..._details) => ({ summary });
52
56
 
53
57
  const migrationRemediation = authorRemediation;
54
58
 
@@ -124,6 +128,58 @@ export const issueMetadataByCode = {
124
128
  ],
125
129
  ),
126
130
  },
131
+ "sdk-session-store-write": {
132
+ severity: "P2",
133
+ owner: "core",
134
+ decision: "core-compat-adapter",
135
+ title: "deprecated whole-store session write helper is still used",
136
+ authorRemediation: migrationRemediation(
137
+ "Replace deprecated whole-store session writes with row-scoped session helpers.",
138
+ [
139
+ "Use patchSessionEntry(...) when updating fields on an existing session entry.",
140
+ "Use upsertSessionEntry(...) when replacing or creating a session entry.",
141
+ ],
142
+ ),
143
+ },
144
+ "sdk-session-file-helper": {
145
+ severity: "P2",
146
+ owner: "core",
147
+ decision: "core-compat-adapter",
148
+ title: "deprecated session file-path helper is still used",
149
+ authorRemediation: migrationRemediation(
150
+ "Replace deprecated session file-path helpers with session entry and transcript identity APIs.",
151
+ [
152
+ "Use getSessionEntry(...) to read session metadata by agent/session identity.",
153
+ "Use patchSessionEntry(...) or upsertSessionEntry(...) to persist session metadata.",
154
+ ],
155
+ ),
156
+ },
157
+ "sdk-session-transcript-file-target": {
158
+ severity: "P2",
159
+ owner: "core",
160
+ decision: "core-compat-adapter",
161
+ title: "deprecated transcript file target helper is still used",
162
+ authorRemediation: migrationRemediation(
163
+ "Replace legacy transcript file targets with public transcript identity or target helpers.",
164
+ [
165
+ "Use resolveSessionTranscriptIdentity(...) when you only need public session identity.",
166
+ "Use resolveSessionTranscriptTarget(...) when you need a structured transcript operation target.",
167
+ ],
168
+ ),
169
+ },
170
+ "sdk-session-transcript-low-level": {
171
+ severity: "P2",
172
+ owner: "core",
173
+ decision: "core-compat-adapter",
174
+ title: "deprecated low-level transcript helper is still used",
175
+ authorRemediation: migrationRemediation(
176
+ "Replace low-level transcript writes with the structured transcript runtime helpers.",
177
+ [
178
+ "Use appendSessionTranscriptMessageByIdentity(...) for transcript appends.",
179
+ "Use publishSessionTranscriptUpdateByIdentity(...) for transcript update notifications.",
180
+ ],
181
+ ),
182
+ },
127
183
  "sdk-export-missing": {
128
184
  severity: "P1",
129
185
  owner: "core",
@@ -462,10 +518,7 @@ export function buildIssues({ breakages = [], warnings = [], suggestions = [], t
462
518
  runtimeCoverage: finding.runtimeCoverage ?? null,
463
519
  ...(finding.authorRemediation
464
520
  ? {
465
- authorRemediation: {
466
- summary: finding.authorRemediation.summary,
467
- docsUrl: authorRemediationDocsUrl(finding.code),
468
- },
521
+ authorRemediation: withAuthorRemediationDocs(finding.code, finding.authorRemediation),
469
522
  }
470
523
  : {}),
471
524
  }));
@@ -491,10 +544,7 @@ export function issueMetadata(finding, targetOpenClaw) {
491
544
  };
492
545
  const authorMetadata = metadata.authorRemediation
493
546
  ? {
494
- authorRemediation: {
495
- summary: metadata.authorRemediation.summary,
496
- docsUrl: authorRemediationDocsUrl(finding.code),
497
- },
547
+ authorRemediation: withAuthorRemediationDocs(finding.code, metadata.authorRemediation),
498
548
  }
499
549
  : {};
500
550
  return {
@@ -505,6 +555,13 @@ export function issueMetadata(finding, targetOpenClaw) {
505
555
  };
506
556
  }
507
557
 
558
+ function withAuthorRemediationDocs(code, remediation) {
559
+ return {
560
+ ...remediation,
561
+ docsUrl: authorRemediationDocsUrl(code),
562
+ };
563
+ }
564
+
508
565
  export function classifyIssueFinding(finding, targetOpenClaw, metadata = {}) {
509
566
  const compatStatus = compatStatusFor(finding, targetOpenClaw);
510
567
  const deprecated = compatStatus === "deprecated";
@@ -567,6 +624,10 @@ function issueClassFor(code, options) {
567
624
  "legacy-root-sdk-import",
568
625
  "provider-auth-env-vars",
569
626
  "sdk-load-session-store",
627
+ "sdk-session-file-helper",
628
+ "sdk-session-store-write",
629
+ "sdk-session-transcript-file-target",
630
+ "sdk-session-transcript-low-level",
570
631
  ].includes(code)
571
632
  ) {
572
633
  return "deprecation-warning";
package/src/json-file.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
+ import { lstat, open, readFile, readlink, realpath, rename, stat, unlink } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve } from "node:path";
3
5
 
4
6
  export async function readJsonFile(jsonPath) {
5
7
  return JSON.parse(await readFile(jsonPath, "utf8"));
@@ -8,3 +10,98 @@ export async function readJsonFile(jsonPath) {
8
10
  export async function readOptionalJsonFile(jsonPath) {
9
11
  return existsSync(jsonPath) ? readJsonFile(jsonPath) : null;
10
12
  }
13
+
14
+ // Writes JSON.stringify(value, null, 2) + newline atomically: stage to a sibling
15
+ // temp file, fsync, then rename over the destination. A crash mid-write can only
16
+ // truncate the temp file, so an interrupted run never corrupts an existing
17
+ // manifest. The destination file mode is preserved exactly (including any
18
+ // special bits) so the atomic replace stays permission-neutral. Existing
19
+ // symlinked manifests resolve to their target before staging so the link itself
20
+ // is never replaced.
21
+ export async function writeJsonFileAtomic(jsonPath, value) {
22
+ const writePath = await resolveJsonWritePath(jsonPath);
23
+ const data = `${JSON.stringify(value, null, 2)}\n`;
24
+ const tempPath = join(dirname(writePath), `.${basename(writePath)}.${process.pid}.${randomUUID()}.tmp`);
25
+
26
+ let existingMode;
27
+ try {
28
+ existingMode = (await stat(writePath)).mode & 0o7777;
29
+ } catch (error) {
30
+ if (error.code !== "ENOENT") {
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ const handle = await open(tempPath, "wx");
36
+ let stagingError = null;
37
+ try {
38
+ await handle.writeFile(data, "utf8");
39
+ await handle.sync();
40
+ if (existingMode !== undefined) {
41
+ await handle.chmod(existingMode);
42
+ }
43
+ } catch (error) {
44
+ stagingError = error;
45
+ } finally {
46
+ try {
47
+ await handle.close();
48
+ } catch (error) {
49
+ stagingError ??= error;
50
+ }
51
+ }
52
+
53
+ if (stagingError) {
54
+ await unlink(tempPath).catch(() => {});
55
+ throw stagingError;
56
+ }
57
+
58
+ try {
59
+ await rename(tempPath, writePath);
60
+ } catch (error) {
61
+ await unlink(tempPath).catch(() => {});
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ async function resolveJsonWritePath(jsonPath) {
67
+ try {
68
+ return await realpath(jsonPath);
69
+ } catch (error) {
70
+ if (error.code !== "ENOENT") {
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ let writePath = jsonPath;
76
+ const seen = new Set();
77
+ for (let hops = 0; hops < 40; hops += 1) {
78
+ const normalizedPath = resolve(writePath);
79
+ if (seen.has(normalizedPath)) {
80
+ const error = new Error(`too many symbolic links resolving ${jsonPath}`);
81
+ error.code = "ELOOP";
82
+ throw error;
83
+ }
84
+ seen.add(normalizedPath);
85
+
86
+ let entry;
87
+ try {
88
+ entry = await lstat(writePath);
89
+ } catch (error) {
90
+ if (error.code === "ENOENT") {
91
+ return writePath;
92
+ }
93
+ throw error;
94
+ }
95
+
96
+ if (!entry.isSymbolicLink()) {
97
+ return writePath;
98
+ }
99
+
100
+ const target = await readlink(writePath);
101
+ writePath = resolve(dirname(writePath), target);
102
+ }
103
+
104
+ const error = new Error(`too many symbolic links resolving ${jsonPath}`);
105
+ error.code = "ELOOP";
106
+ throw error;
107
+ }
@@ -194,12 +194,16 @@ function registrySurfaceChecks(baseline, current) {
194
194
  action: "pass",
195
195
  metric,
196
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],
197
+ baseline: registrySurfaceCount(baseline.targetOpenClaw[metric]),
198
+ current: registrySurfaceCount(current.targetOpenClaw[metric]),
199
+ delta: registrySurfaceCount(current.targetOpenClaw[metric]) - registrySurfaceCount(baseline.targetOpenClaw[metric]),
200
200
  }));
201
201
  }
202
202
 
203
+ function registrySurfaceCount(value) {
204
+ return Array.isArray(value) ? value.length : Number(value ?? 0);
205
+ }
206
+
203
207
  function commandWall(profile, commandId) {
204
208
  return profile.commands.find((command) => command.id === commandId)?.wallMs?.median ?? 0;
205
209
  }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readFile, writeFile } from "node:fs/promises";
2
+ import { readFile } from "node:fs/promises";
3
+ import { writeJsonFileAtomic } from "./json-file.js";
3
4
  import path from "node:path";
4
5
 
5
6
  const packageJsonPath = path.resolve(process.cwd(), "package.json");
@@ -19,5 +20,5 @@ if (packageJson.devDependencies && Object.keys(packageJson.devDependencies).leng
19
20
  }
20
21
 
21
22
  if (changed) {
22
- await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
23
+ await writeJsonFileAtomic(packageJsonPath, packageJson);
23
24
  }
package/src/report.js CHANGED
@@ -450,6 +450,7 @@ function emptyFixtureReport(fixture) {
450
450
  registrations: [],
451
451
  manifestContracts: [],
452
452
  sdkImports: [],
453
+ sdkDeprecations: [],
453
454
  sourceFiles: [],
454
455
  manifestFiles: [],
455
456
  packageFiles: [],
@@ -1,16 +1,53 @@
1
- const loadSessionStoreReplacement =
1
+ const sessionStoreReadReplacement =
2
2
  "getSessionEntry(...) / listSessionEntries(...) for reads and patchSessionEntry(...) / upsertSessionEntry(...) for writes";
3
+ const sessionStoreWriteReplacement = "patchSessionEntry(...) / upsertSessionEntry(...) for row-scoped writes";
4
+ const sessionFileReplacement = "session entries and transcript identity helpers instead of persisted file paths";
5
+ const sessionTranscriptReplacement =
6
+ "resolveSessionTranscriptTarget(...), appendSessionTranscriptMessageByIdentity(...), and publishSessionTranscriptUpdateByIdentity(...)";
3
7
 
4
- const loadSessionStoreSpecifiers = new Set([
8
+ const sdkSessionSpecifiers = new Set([
5
9
  "openclaw/plugin-sdk/config-runtime",
10
+ "openclaw/plugin-sdk/mattermost",
11
+ "openclaw/plugin-sdk/agent-harness-runtime",
6
12
  "openclaw/plugin-sdk/session-store-runtime",
13
+ "openclaw/plugin-sdk/session-transcript-runtime",
7
14
  ]);
8
15
 
9
16
  export const pluginSdkDeprecationRules = [
10
17
  {
11
18
  code: "sdk-load-session-store",
19
+ symbols: new Set(["loadSessionStore"]),
12
20
  title: "deprecated whole-store session helper is still used",
13
- replacement: loadSessionStoreReplacement,
21
+ replacement: sessionStoreReadReplacement,
22
+ message: (symbol, replacement) => `${symbol} keeps the legacy whole-store session shape; use ${replacement}.`,
23
+ },
24
+ {
25
+ code: "sdk-session-store-write",
26
+ symbols: new Set(["saveSessionStore", "updateSessionStore"]),
27
+ title: "deprecated whole-store session write helper is still used",
28
+ replacement: sessionStoreWriteReplacement,
29
+ message: (symbol, replacement) => `${symbol} writes the legacy whole-store session shape; use ${replacement}.`,
30
+ },
31
+ {
32
+ code: "sdk-session-file-helper",
33
+ symbols: new Set(["resolveSessionFilePath", "resolveAndPersistSessionFile"]),
34
+ title: "deprecated session file-path helper is still used",
35
+ replacement: sessionFileReplacement,
36
+ message: (symbol, replacement) => `${symbol} depends on legacy session transcript file paths; use ${replacement}.`,
37
+ },
38
+ {
39
+ code: "sdk-session-transcript-file-target",
40
+ symbols: new Set(["resolveSessionTranscriptLegacyFileTarget"]),
41
+ title: "deprecated transcript file target helper is still used",
42
+ replacement: "resolveSessionTranscriptTarget(...) or resolveSessionTranscriptIdentity(...)",
43
+ message: (symbol, replacement) => `${symbol} exposes legacy transcript file targets; use ${replacement}.`,
44
+ },
45
+ {
46
+ code: "sdk-session-transcript-low-level",
47
+ symbols: new Set(["appendSessionTranscriptMessage", "emitSessionTranscriptUpdate"]),
48
+ title: "deprecated low-level transcript helper is still used",
49
+ replacement: sessionTranscriptReplacement,
50
+ message: (symbol, replacement) => `${symbol} bypasses the structured transcript runtime surface; use ${replacement}.`,
14
51
  },
15
52
  ];
16
53
 
@@ -18,9 +55,7 @@ export function inspectSdkDeprecations(text, filePath = "source.js", rules = plu
18
55
  const findings = [];
19
56
 
20
57
  for (const rule of rules) {
21
- if (rule.code === "sdk-load-session-store") {
22
- collectLoadSessionStoreDeprecations(findings, { text, filePath, rule });
23
- }
58
+ collectSdkHelperDeprecations(findings, { text, filePath, rule });
24
59
  }
25
60
 
26
61
  return uniqueFindings(findings)
@@ -28,12 +63,13 @@ export function inspectSdkDeprecations(text, filePath = "source.js", rules = plu
28
63
  .map(({ offset, ...finding }) => finding);
29
64
  }
30
65
 
31
- function collectLoadSessionStoreDeprecations(findings, context) {
66
+ function collectSdkHelperDeprecations(findings, context) {
32
67
  collectNamedImportDeprecations(findings, context);
33
68
  collectNamedReexportDeprecations(findings, context);
34
69
  collectNamedRequireDeprecations(findings, context);
35
70
  collectNamespaceUsageDeprecations(findings, context);
36
71
  collectNamespaceRequireDeprecations(findings, context);
72
+ collectDynamicImportNamespaceDeprecations(findings, context);
37
73
  collectRuntimeUsageDeprecations(findings, context);
38
74
  collectRuntimeAliasUsageDeprecations(findings, context);
39
75
  }
@@ -43,16 +79,17 @@ function collectNamedImportDeprecations(findings, context) {
43
79
  /\bimport\s+(?:type\s+)?(?:[A-Za-z_$][\w$]*\s*,\s*)?{([^}]+)}\s*from\s*["'`]([^"'`]+)["'`]/g;
44
80
  for (const match of context.text.matchAll(regex)) {
45
81
  const specifier = match[2];
46
- if (!loadSessionStoreSpecifiers.has(specifier)) {
82
+ if (!sdkSessionSpecifiers.has(specifier)) {
47
83
  continue;
48
84
  }
49
85
  for (const binding of parseNamedBindings(match[1])) {
50
- if (binding.exported !== "loadSessionStore") {
86
+ if (!context.rule.symbols.has(binding.exported)) {
51
87
  continue;
52
88
  }
53
89
  findings.push(
54
90
  buildFinding(context.rule, {
55
- surface: `${specifier} import`,
91
+ surface: `${specifier} ${binding.exported} import`,
92
+ symbol: binding.exported,
56
93
  sourceText: context.text,
57
94
  filePath: context.filePath,
58
95
  offset: (match.index ?? 0) + match[0].lastIndexOf(binding.local),
@@ -66,16 +103,17 @@ function collectNamedReexportDeprecations(findings, context) {
66
103
  const regex = /\bexport\s*{([^}]+)}\s*from\s*["'`]([^"'`]+)["'`]/g;
67
104
  for (const match of context.text.matchAll(regex)) {
68
105
  const specifier = match[2];
69
- if (!loadSessionStoreSpecifiers.has(specifier)) {
106
+ if (!sdkSessionSpecifiers.has(specifier)) {
70
107
  continue;
71
108
  }
72
109
  for (const binding of parseNamedBindings(match[1])) {
73
- if (binding.exported !== "loadSessionStore") {
110
+ if (!context.rule.symbols.has(binding.exported)) {
74
111
  continue;
75
112
  }
76
113
  findings.push(
77
114
  buildFinding(context.rule, {
78
- surface: `${specifier} re-export`,
115
+ surface: `${specifier} ${binding.exported} re-export`,
116
+ symbol: binding.exported,
79
117
  sourceText: context.text,
80
118
  filePath: context.filePath,
81
119
  offset: (match.index ?? 0) + match[0].lastIndexOf(binding.local),
@@ -89,16 +127,17 @@ function collectNamedRequireDeprecations(findings, context) {
89
127
  const regex = /\b(?:const|let|var)\s+{([^}]+)}\s*=\s*require\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
90
128
  for (const match of context.text.matchAll(regex)) {
91
129
  const specifier = match[2];
92
- if (!loadSessionStoreSpecifiers.has(specifier)) {
130
+ if (!sdkSessionSpecifiers.has(specifier)) {
93
131
  continue;
94
132
  }
95
133
  for (const binding of parseNamedBindings(match[1], { aliasSeparator: ":" })) {
96
- if (binding.exported !== "loadSessionStore") {
134
+ if (!context.rule.symbols.has(binding.exported)) {
97
135
  continue;
98
136
  }
99
137
  findings.push(
100
138
  buildFinding(context.rule, {
101
- surface: `${specifier} require`,
139
+ surface: `${specifier} ${binding.exported} require`,
140
+ symbol: binding.exported,
102
141
  sourceText: context.text,
103
142
  filePath: context.filePath,
104
143
  offset: (match.index ?? 0) + match[0].lastIndexOf(binding.local),
@@ -109,21 +148,24 @@ function collectNamedRequireDeprecations(findings, context) {
109
148
  }
110
149
 
111
150
  function collectMemberCallDeprecations(findings, context, options) {
112
- forEachMethodCall(context.text, "loadSessionStore", (offset) => {
113
- // Normalize transparent parentheses and optional-chained member links before matching.
114
- const receiver = readNormalizedCallReceiver(context.text, offset);
115
- if (!receiver || !options.receiverMatcher(receiver)) {
116
- return;
117
- }
118
- findings.push(
119
- buildFinding(context.rule, {
120
- surface: options.surface,
121
- sourceText: context.text,
122
- filePath: context.filePath,
123
- offset,
124
- }),
125
- );
126
- });
151
+ for (const symbol of context.rule.symbols) {
152
+ forEachMethodCall(context.text, symbol, (offset) => {
153
+ // Normalize transparent parentheses and optional-chained member links before matching.
154
+ const receiver = readNormalizedCallReceiver(context.text, offset);
155
+ if (!receiver || !options.receiverMatcher(receiver)) {
156
+ return;
157
+ }
158
+ findings.push(
159
+ buildFinding(context.rule, {
160
+ surface: `${options.surface} ${symbol}`,
161
+ symbol,
162
+ sourceText: context.text,
163
+ filePath: context.filePath,
164
+ offset,
165
+ }),
166
+ );
167
+ });
168
+ }
127
169
  }
128
170
 
129
171
  function forEachMethodCall(text, methodName, visit) {
@@ -259,7 +301,11 @@ function isIdentifierBoundary(text, offset) {
259
301
  }
260
302
 
261
303
  function isRuntimeSessionReceiver(receiver) {
262
- return /^(?:[A-Za-z_$][A-Za-z0-9_$]*|this)\.runtime\.agent\.session$/.test(receiver);
304
+ return (
305
+ /(?:^|\.)(?:[A-Za-z_$][A-Za-z0-9_$]*|this)\.runtime\.agent\.session$/.test(receiver) ||
306
+ /^(?:runtime|[A-Za-z_$][A-Za-z0-9_$]*Runtime)\.agent\.session$/.test(receiver) ||
307
+ /^(?:agentRuntime|[A-Za-z_$][A-Za-z0-9_$]*AgentRuntime)\.session$/.test(receiver)
308
+ );
263
309
  }
264
310
 
265
311
  function collectNamespaceUsageDeprecations(findings, context) {
@@ -267,7 +313,7 @@ function collectNamespaceUsageDeprecations(findings, context) {
267
313
  for (const match of context.text.matchAll(regex)) {
268
314
  const local = match[1];
269
315
  const specifier = match[2];
270
- if (!loadSessionStoreSpecifiers.has(specifier)) {
316
+ if (!sdkSessionSpecifiers.has(specifier)) {
271
317
  continue;
272
318
  }
273
319
  collectMemberCallDeprecations(findings, context, {
@@ -282,7 +328,7 @@ function collectNamespaceRequireDeprecations(findings, context) {
282
328
  for (const match of context.text.matchAll(regex)) {
283
329
  const local = match[1];
284
330
  const specifier = match[2];
285
- if (!loadSessionStoreSpecifiers.has(specifier)) {
331
+ if (!sdkSessionSpecifiers.has(specifier)) {
286
332
  continue;
287
333
  }
288
334
  collectMemberCallDeprecations(findings, context, {
@@ -292,6 +338,22 @@ function collectNamespaceRequireDeprecations(findings, context) {
292
338
  }
293
339
  }
294
340
 
341
+ function collectDynamicImportNamespaceDeprecations(findings, context) {
342
+ const regex =
343
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?import\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
344
+ for (const match of context.text.matchAll(regex)) {
345
+ const local = match[1];
346
+ const specifier = match[2];
347
+ if (!sdkSessionSpecifiers.has(specifier)) {
348
+ continue;
349
+ }
350
+ collectMemberCallDeprecations(findings, context, {
351
+ receiverMatcher: (receiver) => receiver === local,
352
+ surface: `${specifier} dynamic import namespace access`,
353
+ });
354
+ }
355
+ }
356
+
295
357
  function collectRuntimeUsageDeprecations(findings, context) {
296
358
  collectMemberCallDeprecations(findings, context, {
297
359
  receiverMatcher: isRuntimeSessionReceiver,
@@ -509,10 +571,11 @@ function buildFinding(rule, details) {
509
571
  const refLine = lineForOffset(details.sourceText, details.offset);
510
572
  return {
511
573
  code: rule.code,
574
+ symbol: details.symbol,
512
575
  surface: details.surface,
513
576
  replacement: rule.replacement,
514
577
  ref: `${details.filePath}:${refLine}`,
515
- message: `loadSessionStore keeps the legacy whole-store session shape; use ${rule.replacement}.`,
578
+ message: rule.message(details.symbol, rule.replacement),
516
579
  offset: details.offset,
517
580
  };
518
581
  }
@@ -520,7 +583,7 @@ function buildFinding(rule, details) {
520
583
  function uniqueFindings(findings) {
521
584
  const byKey = new Map();
522
585
  for (const finding of findings) {
523
- byKey.set(`${finding.code}:${finding.surface}:${finding.ref}`, finding);
586
+ byKey.set(`${finding.code}:${finding.symbol}:${finding.surface}:${finding.ref}`, finding);
524
587
  }
525
588
  return [...byKey.values()];
526
589
  }
package/src/sdk-mock.js CHANGED
@@ -273,7 +273,10 @@ export async function createMockSdkPackage(rootDir, options = {}) {
273
273
  if (specifier === "openclaw/plugin-sdk") {
274
274
  continue;
275
275
  }
276
- const relative = specifier.slice("openclaw/plugin-sdk/".length);
276
+ const relative = safePluginSdkSubpath(specifier.slice("openclaw/plugin-sdk/".length));
277
+ if (!relative) {
278
+ continue;
279
+ }
277
280
  if (mockSdkSubpathExports[relative]) {
278
281
  continue;
279
282
  }
@@ -443,7 +446,12 @@ export async function resolve(specifier, context, nextResolve) {
443
446
  return moduleUrl(path.join(pluginSdkDir, "index.js"));
444
447
  }
445
448
  if (specifier.startsWith("openclaw/plugin-sdk/")) {
446
- const subpath = specifier.slice("openclaw/plugin-sdk/".length);
449
+ const subpath = safePluginSdkSubpath(specifier.slice("openclaw/plugin-sdk/".length));
450
+ if (!subpath) {
451
+ throw Object.assign(new Error(\`invalid OpenClaw plugin SDK subpath: \${specifier}\`), {
452
+ code: "ERR_INVALID_MODULE_SPECIFIER",
453
+ });
454
+ }
447
455
  return moduleUrl(path.join(pluginSdkDir, \`\${subpath}.js\`));
448
456
  }
449
457
  if (externalMap.has(specifier)) {
@@ -514,9 +522,25 @@ function isMockableBareSpecifier(specifier) {
514
522
  !specifier.startsWith("data:") &&
515
523
  !specifier.startsWith("file:");
516
524
  }
525
+
526
+ function safePluginSdkSubpath(value) {
527
+ const normalized = path.posix.normalize(String(value).replaceAll("\\\\", "/"));
528
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) {
529
+ return null;
530
+ }
531
+ return normalized;
532
+ }
517
533
  `;
518
534
  }
519
535
 
536
+ function safePluginSdkSubpath(value) {
537
+ const normalized = path.posix.normalize(String(value).replaceAll("\\", "/"));
538
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) {
539
+ return null;
540
+ }
541
+ return normalized;
542
+ }
543
+
520
544
  function dynamicMockModuleSource(exportNames, options = {}) {
521
545
  const names = new Set([...exportNames].filter(isValidExportName));
522
546
  if (options.zod) {
@@ -530,6 +554,9 @@ export default ${options.zod ? "createZNamespace()" : 'createMockValue("default"
530
554
  }
531
555
 
532
556
  function externalMockModuleSource(specifier, exportNames) {
557
+ if (specifier === "@larksuiteoapi/node-sdk") {
558
+ return larkSdkMockModuleSource(exportNames);
559
+ }
533
560
  const names = new Set([...exportNames].filter(isValidExportName));
534
561
  if (specifier === "zod") {
535
562
  addZodExports(names);
@@ -537,6 +564,52 @@ function externalMockModuleSource(specifier, exportNames) {
537
564
  return dynamicMockModuleSource(names, { zod: specifier === "zod" });
538
565
  }
539
566
 
567
+ function larkSdkMockModuleSource(exportNames) {
568
+ const larkExports = new Set([
569
+ "AppType",
570
+ "Client",
571
+ "Domain",
572
+ "EventDispatcher",
573
+ "LoggerLevel",
574
+ "WSClient",
575
+ ...exportNames,
576
+ ]);
577
+ larkExports.delete("defaultHttpInstance");
578
+ return `${genericMockRuntimeSource()}
579
+ const requestInterceptors = {
580
+ handlers: [],
581
+ use(handler) {
582
+ this.handlers.push(handler);
583
+ return this.handlers.length - 1;
584
+ },
585
+ };
586
+
587
+ export const defaultHttpInstance = {
588
+ interceptors: {
589
+ request: requestInterceptors,
590
+ response: {
591
+ handlers: [],
592
+ use(handler) {
593
+ this.handlers.push(handler);
594
+ return this.handlers.length - 1;
595
+ },
596
+ },
597
+ },
598
+ request: createMockValue("defaultHttpInstance.request"),
599
+ get: createMockValue("defaultHttpInstance.get"),
600
+ post: createMockValue("defaultHttpInstance.post"),
601
+ put: createMockValue("defaultHttpInstance.put"),
602
+ patch: createMockValue("defaultHttpInstance.patch"),
603
+ delete: createMockValue("defaultHttpInstance.delete"),
604
+ head: createMockValue("defaultHttpInstance.head"),
605
+ options: createMockValue("defaultHttpInstance.options"),
606
+ };
607
+ ${[...larkExports].filter(isValidExportName).map(genericExportStatement).join("\n")}
608
+
609
+ export default createMockValue("default");
610
+ `;
611
+ }
612
+
540
613
  function addZodExports(names) {
541
614
  for (const name of ["z", "any", "array", "boolean", "enum", "literal", "number", "object", "record", "string", "unknown"]) {
542
615
  names.add(name);
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile } from "node:fs/promises";
2
+ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
@@ -229,13 +229,13 @@ async function buildEntrypointPlan({ fixtureId, entrypoint, packageSummary, pack
229
229
 
230
230
  steps.push({
231
231
  kind: "prepare",
232
- command: `mkdir -p ${workspacePath} && rsync -a --delete ${packageDir}/ ${workspacePath}/`,
232
+ command: `mkdir -p ${shellQuote(workspacePath)} && rsync -a --delete ${shellQuote(`${packageDir}/`)} ${shellQuote(`${workspacePath}/`)}`,
233
233
  cwd: repoRelative("."),
234
234
  reason: "copy fixture package into an isolated mutable workspace",
235
235
  });
236
236
  steps.push({
237
237
  kind: "prepare-artifacts",
238
- command: `mkdir -p ${resultPath}`,
238
+ command: `mkdir -p ${shellQuote(resultPath)}`,
239
239
  cwd: repoRelative("."),
240
240
  reason: "create a stable result directory for capture and synthetic probe artifacts",
241
241
  });
@@ -243,7 +243,7 @@ async function buildEntrypointPlan({ fixtureId, entrypoint, packageSummary, pack
243
243
  if (requiredCapabilities.includes("target-openclaw-link")) {
244
244
  steps.push({
245
245
  kind: "link-openclaw",
246
- command: `${packageManager} pkg set dependencies.openclaw="file:${targetOpenClawWorkspacePath(settings, fixtureId, targetOpenClawPath)}"`,
246
+ command: `${shellQuote(packageManager)} pkg set ${shellQuote(`dependencies.openclaw=file:${targetOpenClawWorkspacePath(settings, fixtureId, targetOpenClawPath)}`)}`,
247
247
  cwd: workspacePath,
248
248
  reason: "link the plugin's openclaw peer dependency to the target checkout under test",
249
249
  });
@@ -253,7 +253,7 @@ async function buildEntrypointPlan({ fixtureId, entrypoint, packageSummary, pack
253
253
  if (hasWorkspaceProtocolDevDependencies(packageJson)) {
254
254
  steps.push({
255
255
  kind: "prune-dev-workspace-deps",
256
- command: `node ${helperScript(settings, workspacePath, settings.pruneWorkspaceDevDepsScript, "prune-workspace-dev-deps-cli.js")}`,
256
+ command: `node ${shellQuote(helperScript(settings, workspacePath, settings.pruneWorkspaceDevDepsScript, "prune-workspace-dev-deps-cli.js"))}`,
257
257
  cwd: workspacePath,
258
258
  reason: "remove workspace: devDependencies from the isolated runtime install; the mock SDK supplies OpenClaw host imports",
259
259
  });
@@ -361,6 +361,7 @@ function requiredCapabilitiesFor(entrypoint, packageSummary = {}) {
361
361
  }
362
362
  if (blocker.code === "build-required") {
363
363
  capabilities.add("build");
364
+ capabilities.add("dependency-install");
364
365
  }
365
366
  if (blocker.code === "ts-loader-required") {
366
367
  capabilities.add("ts-loader");
@@ -453,35 +454,35 @@ function installCommand(packageManager) {
453
454
  function auditCommand(settings, packageManager, fixtureId, workspacePath) {
454
455
  const output = workspaceRelativeArtifactPath(settings, fixtureId, workspacePath, "package-audit.json");
455
456
  if (packageManager === "npm") {
456
- return `npm audit --json > ${output} || true`;
457
+ return `npm audit --json > ${shellQuote(output)} || true`;
457
458
  }
458
459
  if (packageManager === "pnpm") {
459
- return `pnpm audit --json > ${output} || true`;
460
+ return `pnpm audit --json > ${shellQuote(output)} || true`;
460
461
  }
461
462
  if (packageManager === "yarn") {
462
- return `yarn npm audit --json > ${output} || true`;
463
+ return `yarn npm audit --json > ${shellQuote(output)} || true`;
463
464
  }
464
465
  if (packageManager === "bun") {
465
- return `bun audit --json > ${output} || true`;
466
+ return `bun audit --json > ${shellQuote(output)} || true`;
466
467
  }
467
- return `${packageManager} audit --json > ${output} || true`;
468
+ return `${shellQuote(packageManager)} audit --json > ${shellQuote(output)} || true`;
468
469
  }
469
470
 
470
471
  function runCommand(packageManager, script) {
471
472
  if (packageManager === "npm") {
472
- return `npm run ${script}`;
473
+ return `npm run ${shellQuote(script)}`;
473
474
  }
474
- return `${packageManager} run ${script}`;
475
+ return `${shellQuote(packageManager)} run ${shellQuote(script)}`;
475
476
  }
476
477
 
477
478
  function captureCommand(settings, fixtureId, entrypoint, workspacePath) {
478
479
  const script = helperScript(settings, workspacePath, settings.captureScript, "capture-cli.js");
479
- return `${settings.optInEnv} node ${script} ${entrypoint.specifier} --mock-sdk --output ${workspaceArtifactPath(settings, fixtureId, entrypoint, workspacePath, "capture")}`;
480
+ return `${settings.optInEnv} node ${shellQuote(script)} ${shellQuote(entrypoint.specifier)} --mock-sdk --output ${shellQuote(workspaceArtifactPath(settings, fixtureId, entrypoint, workspacePath, "capture"))}`;
480
481
  }
481
482
 
482
483
  function syntheticProbeCommand(settings, fixtureId, entrypoint, workspacePath) {
483
484
  const script = helperScript(settings, workspacePath, settings.syntheticProbeScript, "synthetic-probes-cli.js");
484
- return `${settings.optInEnv} node ${script} --entrypoint ${entrypoint.specifier} --mock-sdk --output ${workspaceArtifactPath(settings, fixtureId, entrypoint, workspacePath, "synthetic")}`;
485
+ return `${settings.optInEnv} node ${shellQuote(script)} --entrypoint ${shellQuote(entrypoint.specifier)} --mock-sdk --output ${shellQuote(workspaceArtifactPath(settings, fixtureId, entrypoint, workspacePath, "synthetic"))}`;
485
486
  }
486
487
 
487
488
  function helperScript(settings, workspacePath, configuredScript, helperFileName) {
@@ -526,6 +527,14 @@ function auditArtifactPath(settings, fixtureId) {
526
527
  return posixJoin(settings.resultsRoot, fixtureId, "package-audit.json");
527
528
  }
528
529
 
530
+ function shellQuote(value) {
531
+ const text = String(value);
532
+ if (/^[A-Za-z0-9_./:=@%+-]+$/u.test(text)) {
533
+ return text;
534
+ }
535
+ return `'${text.replaceAll("'", "'\\''")}'`;
536
+ }
537
+
529
538
  function markdownTable(rows, headers) {
530
539
  return renderPaddedMarkdownTable(rows, headers);
531
540
  }