mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:51:33 +00:00
* feat(gateway): share the canonical browser-origin policy with plugins Export resolveAcceptedBrowserOrigin through openclaw/plugin-sdk/webhook-request-guards so browser-facing plugin routes reuse the Gateway's real origin contract instead of a narrow allowedOrigins array check. Private LAN/Tailnet Control UI loads and the Host-header fallback were previously rejected with 403 by plugin offer routes while the Control UI itself worked. Moves the loopback/forwarded-header helpers to net.ts (re-exported from auth.ts) so the guard can delegate without importing gateway auth, and migrates the Codex realtime broker onto the shared seam. * feat(talk): let providers own agent delegation for realtime voice Adds an optional runAgentConsult callback to the browser-session create request and injects the existing embedded consult runtime from talk-client, bound to the same agent and session key the GA tool path uses. Providers whose realtime protocol delegates work through their own control channel (rather than GA function calls) can now reach the OpenClaw agent without a client round-trip. Threads the effective per-session model into browser-session capability resolution so a request-level model override selects the right capability set, and propagates a caller abort signal into consultRealtimeVoiceAgent so a superseded delegation stops its run. * feat(openai): support GPT-Live realtime voice over ChatGPT OAuth Implements OpenAI's quicksilver/frameless session natively for Talk browser sessions. The Gateway creates the WebRTC call (multipart sdp+session to https://api.openai.com/v1/live) and owns the sideband control socket, so the browser never holds upstream credentials; delegation.created events run through the OpenClaw agent and stream back as speakable context appends. Verified end-to-end on 2026-07-28 against a ChatGPT Pro OAuth profile: call create 201 with an rtc_* id and answer SDP, sideband session.started, session.close teardown. ChatGPT OAuth is preferred over a Platform API key because /v1/live access for platform keys is waitlist-gated; the legacy chatgpt.com backend route returns 403 for every model and protocol version and is not used. Accepted models are gpt-live-1-codex and gpt-live-1-boulder-alpha; the voice allowlist is the ten values the route actually accepts, since an invalid voice is rejected at call creation and cannot be repaired afterwards (session.update reports immutable_field_update). * docs: document GPT-Live Talk support and its route gotchas Records the working route and auth, the accepted models and voices, the browser-only scope, and the two traps that cost the most time: the chatgpt.com backend route returns 403 Voice session access denied for every model, and that same 403 is also what an invalid voice returns, so it must not be read as an account entitlement block. * fix(openai): resolve GPT-Live CI failures * refactor(openai): own zod runtime dependency * fix(openai): satisfy lint and live-shard gates for GPT-Live Types the retry-delay finish callback as Error so the rejection reason is provably an Error at the call site; the abort path already normalized a non-Error AbortSignal reason, but the unknown parameter type hid that from static analysis. Registers the new GPT-Live live test in the native-live-extensions-openai shard expectation. The shard selector already picked the file up from the real tree; only the hardcoded list in the tooling test lagged. * fix(openai): clean up post-rebase capability resolver * fix(openai): preserve GPT-Live delegation fragments * fix(openai): close GPT-Live sideband handoff race * fix(openai): accept UUID GPT-Live call ids * style: apply oxfmt to GPT-Live sources and Talk docs * style: format Talk docs after rebase * fix(openai): keep GPT-Live transcript context across ignored delegations
610 lines
23 KiB
JavaScript
610 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Reports plugin SDK export surface metadata.
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import {
|
|
deprecatedBarrelPluginSdkEntrypoints,
|
|
deprecatedPublicPluginSdkEntrypoints,
|
|
packagedPrivatePluginSdkRuntimeEntrypoints,
|
|
pluginSdkEntrypoints,
|
|
privateLocalOnlyPluginSdkEntrypoints,
|
|
publicPluginSdkEntrypoints,
|
|
} from "./lib/plugin-sdk-entries.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const require = createRequire(import.meta.url);
|
|
let ts;
|
|
|
|
function usage() {
|
|
return `Usage: node scripts/plugin-sdk-surface-report.mjs [--check]
|
|
|
|
Reports plugin SDK export surface metadata.
|
|
|
|
Options:
|
|
--check Fail when SDK surface budgets are exceeded.
|
|
-h, --help Show this help.
|
|
`;
|
|
}
|
|
|
|
function parsePluginSdkSurfaceReportArgs(argv) {
|
|
const args = { check: false, help: false };
|
|
for (const arg of argv) {
|
|
if (arg === "--check") {
|
|
args.check = true;
|
|
continue;
|
|
}
|
|
if (arg === "--help" || arg === "-h") {
|
|
args.help = true;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown plugin SDK surface report option: ${arg}`);
|
|
}
|
|
return args;
|
|
}
|
|
const publicEntrypointSet = new Set(publicPluginSdkEntrypoints);
|
|
const localOnlyEntrypointSet = new Set(privateLocalOnlyPluginSdkEntrypoints);
|
|
const packagedPrivateRuntimeEntrypointSet = new Set(packagedPrivatePluginSdkRuntimeEntrypoints);
|
|
const deprecatedPublicEntrypointSet = new Set(deprecatedPublicPluginSdkEntrypoints);
|
|
const deprecatedBarrelEntrypointSet = new Set(deprecatedBarrelPluginSdkEntrypoints);
|
|
const forbiddenPublicSubpaths = new Set(["test-utils"]);
|
|
|
|
function readPluginSdkSurfaceBudgetEnv(name, fallback, env = process.env) {
|
|
const raw = env[name];
|
|
if (raw === undefined) {
|
|
return fallback;
|
|
}
|
|
const value = raw.trim();
|
|
if (!/^\d+$/u.test(value)) {
|
|
throw new Error(`${name} must be a non-negative integer`);
|
|
}
|
|
const parsed = Number(value);
|
|
if (!Number.isSafeInteger(parsed)) {
|
|
throw new Error(`${name} must be a safe non-negative integer`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function readPluginSdkEntrypointBudgetEnv(name, fallback, env = process.env) {
|
|
const raw = env[name];
|
|
if (raw === undefined) {
|
|
return fallback;
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error(`${name} must be a JSON object of entrypoint integer budgets`);
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error(`${name} must be a JSON object of entrypoint integer budgets`);
|
|
}
|
|
|
|
const overrides = {};
|
|
for (const [entrypoint, value] of Object.entries(parsed)) {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new Error(`${name}.${entrypoint} must be a safe non-negative integer`);
|
|
}
|
|
overrides[entrypoint] = value;
|
|
}
|
|
return Object.freeze({ ...fallback, ...overrides });
|
|
}
|
|
|
|
const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
|
|
core: 2,
|
|
routing: 1,
|
|
health: 0,
|
|
"channel-streaming": 54,
|
|
"approval-gateway-runtime": 1,
|
|
"approval-handler-runtime": 1,
|
|
"approval-reply-runtime": 0,
|
|
"config-runtime": 115,
|
|
"config-contracts": 0,
|
|
"inbound-reply-dispatch": 24,
|
|
"channel-reply-pipeline": 12,
|
|
"interactive-runtime": 11,
|
|
// +3: canonical incognito classifier projected through deprecated compatibility barrels.
|
|
"infra-runtime": 596,
|
|
"ssrf-policy": 1,
|
|
"ssrf-runtime": 1,
|
|
// +1: deprecated agent media projection re-export during the media migration window.
|
|
"media-runtime": 3,
|
|
// +3: deprecated media projection type, builder, and local-roots compatibility re-export.
|
|
"agent-media-payload": 3,
|
|
// +2: deprecated media projection type and builder.
|
|
"reply-payload": 2,
|
|
// +1: flushLogger projected through the deprecated text-runtime barrel.
|
|
"text-runtime": 192,
|
|
"agent-runtime": 2,
|
|
"channel-secret-runtime": 23,
|
|
"agent-harness-runtime": 4,
|
|
"agent-config-primitives": 2,
|
|
"command-auth": 78,
|
|
discord: 47,
|
|
matrix: 1,
|
|
// +4: deprecated media projection type, builder, and turn aliases.
|
|
"channel-inbound": 18,
|
|
"channel-logging": 4,
|
|
"channel-lifecycle": 23,
|
|
"channel-message": 129,
|
|
"channel-pairing": 0,
|
|
"channel-policy": 7,
|
|
"channel-send-result": 1,
|
|
"reply-runtime": 1,
|
|
"security-runtime": 1,
|
|
"session-store-runtime": 4,
|
|
// +2: shipped Slack and Discord setup helpers retained through their package migration window.
|
|
"setup-runtime": 2,
|
|
"group-access": 13,
|
|
"reply-history": 6,
|
|
"messaging-targets": 12,
|
|
"provider-auth": 19,
|
|
"telegram-account": 3,
|
|
zod: 282,
|
|
});
|
|
|
|
export function readPluginSdkSurfaceBudgets(env = process.env) {
|
|
const budgets = {
|
|
publicEntrypoints: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS",
|
|
// +1: session-discussion binds one external discussion provider to sessions.
|
|
// +1: focused media-local-roots replacement for the legacy agent-media facade.
|
|
// +1: account-aware channel DM policy setup descriptors.
|
|
// +1: dependency-light CLI argv parsing for machine-output metadata.
|
|
// +1: bounded archive extraction and single-entry reads.
|
|
// +1: budgeted root-bounded directory walking.
|
|
// +1: pinned secret reads and first-writer-wins creation.
|
|
146,
|
|
env,
|
|
),
|
|
publicExports: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
|
|
// +4: session discussion state, info, provider, and registration contracts.
|
|
// +2: structured media placeholder formatter and its text-fact contract.
|
|
// +2: narrow settled-turn finalization result and safe full-attempt projector.
|
|
// +1: channel-owned setup contract factory.
|
|
// +18: generic schema primitives needed by plugin-owned channel config schemas.
|
|
// +2: shared Teams reply-style and TTS schema leaves.
|
|
// +2: generic inbound-root and SCP-host schema validators.
|
|
// +2: attributed-range renderer and its options contract.
|
|
// +1: agent-harness transcript visibility projector.
|
|
// +1: outbound formatting capability profile.
|
|
// +3: plugin approval reviewer-detail cap/truncator and sanitize-with-status variant.
|
|
// +1: canonical incognito session classifier for storage-safe plugin behavior.
|
|
// +2: shipped Slack and Discord setup compatibility helpers.
|
|
// +3: typed channel partial-delivery error, creator, and structural guard.
|
|
// +1: closed attempt-terminal merge, normalization, and projection helper.
|
|
// +3: harness-native MCP App preview helper and its runtime/catalog contracts.
|
|
// +1: canonical unknown-value to Error coercion.
|
|
// +6: canonical session delivery normalization, access, and projection helpers.
|
|
// +5: focused media-local-roots helpers and typed hook media contracts.
|
|
// +1: model-independent agent-harness preflight failure contract.
|
|
// +3: channel DM policy factory and its account/patch callback contracts.
|
|
// +1: typed owner-required error for session store path resolution.
|
|
// +1: native approval messaging target resolver.
|
|
// +1: shared plugin SecretRef setup plan helper.
|
|
// +1: shared multi-claim ingress lifecycle fan-in.
|
|
// +3: channel prompt-context entry/compat types and channel metadata builder.
|
|
// +4: focused CLI root-option constants and parsers.
|
|
// +6: model-picker action/capability and authoritative session-apply contracts.
|
|
// +1: logger file-transport flush for graceful shutdown drains.
|
|
// +1: process-local sessions.changed plugin notification payload.
|
|
// +1: loopback-only host classifier for plugin local-machine boundaries.
|
|
// +7: bounded archive extraction, entry reads, errors, and policy types.
|
|
// +3: root-bounded walk iterator, options, and entry contract.
|
|
// +5: pinned secret create/read functions and their options contract.
|
|
// +1: canonical Gateway browser-origin acceptance for browser-facing plugin routes.
|
|
4756,
|
|
env,
|
|
),
|
|
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
|
|
// +1: session discussion provider registration.
|
|
// +1: structured media placeholder formatter for text-only channel carriers.
|
|
// +1: settled-turn full-attempt projector.
|
|
// +1: channel-owned setup contract factory.
|
|
// +4: generic channel schema shape builders.
|
|
// +1: plugin-owned sensitive-schema registration.
|
|
// +2: generic inbound-root and SCP-host schema validators.
|
|
// +1: attributed-range renderer.
|
|
// +1: agent-harness transcript visibility projector.
|
|
// +2: plugin approval detail truncator and sanitize-with-status variant.
|
|
// +1: canonical incognito session classifier for storage-safe plugin behavior.
|
|
// +2: shipped Slack and Discord setup compatibility helpers.
|
|
// +2: channel partial-delivery error creator and structural guard.
|
|
// +1: harness-native MCP App preview helper.
|
|
// +1: canonical unknown-value to Error coercion.
|
|
// +6: canonical session delivery normalization, access, and projection helpers.
|
|
// +2: focused media-local-roots helpers.
|
|
// +3: channel DM policy factory and its account/patch callbacks.
|
|
// +1: native approval messaging target resolver.
|
|
// +1: shared multi-claim ingress lifecycle fan-in.
|
|
// +1: channel metadata builder.
|
|
// +3: focused CLI root-option parsers.
|
|
// +1: authoritative model-picker session-apply operation.
|
|
// +1: logger file-transport flush for graceful shutdown drains.
|
|
// +1: loopback-only host classifier for plugin local-machine boundaries.
|
|
// +2: bounded archive extraction and single-entry reads.
|
|
// +1: root-bounded directory walk iterator.
|
|
// +4: pinned secret create and synchronous/asynchronous reads.
|
|
// +1: canonical Gateway browser-origin acceptance for browser-facing plugin routes.
|
|
2877,
|
|
env,
|
|
),
|
|
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
|
// +3: canonical incognito classifier projected through deprecated compatibility barrels.
|
|
// +2: shipped Slack and Discord setup compatibility helpers.
|
|
// +10: named media legacy projection deprecations across public compatibility barrels.
|
|
// +2: channel prompt-context type and metadata builder compatibility aliases.
|
|
// +1: flushLogger projected through the deprecated text-runtime barrel.
|
|
1701,
|
|
env,
|
|
),
|
|
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
|
|
82,
|
|
env,
|
|
),
|
|
};
|
|
const publicDeprecatedExportsByEntrypointBudget = readPluginSdkEntrypointBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT",
|
|
defaultPublicDeprecatedExportsByEntrypointBudget,
|
|
env,
|
|
);
|
|
return { budgets, publicDeprecatedExportsByEntrypointBudget };
|
|
}
|
|
|
|
function entrypointPath(entrypoint) {
|
|
return path.join(repoRoot, "src", "plugin-sdk", `${entrypoint}.ts`);
|
|
}
|
|
|
|
function readPackageExportedSubpaths() {
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
return Object.keys(packageJson.exports ?? {})
|
|
.filter((key) => key.startsWith("./plugin-sdk/"))
|
|
.map((key) => key.slice("./plugin-sdk/".length))
|
|
.toSorted();
|
|
}
|
|
|
|
function unwrapAlias(checker, symbol) {
|
|
return symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
|
|
}
|
|
|
|
function hasDeprecatedTag(symbol) {
|
|
return symbol.getJsDocTags().some((tag) => tag.name === "deprecated");
|
|
}
|
|
|
|
function isCallableExport(checker, symbol, sourceFile) {
|
|
const target = unwrapAlias(checker, symbol);
|
|
const declaration = target.valueDeclaration ?? target.declarations?.[0] ?? sourceFile;
|
|
const type = checker.getTypeOfSymbolAtLocation(target, declaration);
|
|
return checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0;
|
|
}
|
|
|
|
function countWildcardReexports(entrypoints) {
|
|
let count = 0;
|
|
const matches = [];
|
|
for (const entrypoint of entrypoints) {
|
|
const sourcePath = entrypointPath(entrypoint);
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
const lines = source.split(/\r?\n/u);
|
|
for (const [index, line] of lines.entries()) {
|
|
if (/^\s*export\s+(?:type\s+)?\*\s+from\s+["'][^"']+["']/u.test(line)) {
|
|
count += 1;
|
|
matches.push(`${path.relative(repoRoot, sourcePath)}:${index + 1}`);
|
|
}
|
|
}
|
|
}
|
|
return { count, matches };
|
|
}
|
|
|
|
// All three inventories overlap. Lazily reuse one module graph so --help and
|
|
// invalid options avoid compiler work without tripling report time and heap.
|
|
let exportStatsProgram;
|
|
|
|
function collectExportStats(entrypoints) {
|
|
// CLI validation and help do not need the compiler's startup cost.
|
|
ts ??= require("typescript");
|
|
const configPath = path.join(repoRoot, "tsconfig.json");
|
|
const config = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
if (config.error) {
|
|
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n"));
|
|
}
|
|
exportStatsProgram ??= ts.createProgram(pluginSdkEntrypoints.map(entrypointPath), {
|
|
allowJs: false,
|
|
baseUrl: repoRoot,
|
|
declaration: true,
|
|
emitDeclarationOnly: true,
|
|
module: ts.ModuleKind.ESNext,
|
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
noEmit: true,
|
|
paths: config.config.compilerOptions?.paths,
|
|
skipLibCheck: true,
|
|
strict: false,
|
|
target: ts.ScriptTarget.ES2022,
|
|
types: [],
|
|
});
|
|
const program = exportStatsProgram;
|
|
const checker = program.getTypeChecker();
|
|
const byEntrypoint = new Map();
|
|
const uniqueNames = new Set();
|
|
const uniqueCallableNames = new Set();
|
|
|
|
for (const entrypoint of entrypoints) {
|
|
const sourceFile = program.getSourceFile(entrypointPath(entrypoint));
|
|
if (!sourceFile) {
|
|
byEntrypoint.set(entrypoint, {
|
|
exports: 0,
|
|
callableExports: 0,
|
|
deprecatedExports: 0,
|
|
deprecatedCallableExports: 0,
|
|
});
|
|
continue;
|
|
}
|
|
const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
|
|
const symbols = moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : [];
|
|
let callableExports = 0;
|
|
let deprecatedExports = 0;
|
|
let deprecatedCallableExports = 0;
|
|
const deprecatedEntrypoint = deprecatedPublicEntrypointSet.has(entrypoint);
|
|
for (const symbol of symbols) {
|
|
const exportName = `${entrypoint}:${symbol.getName()}`;
|
|
uniqueNames.add(exportName);
|
|
const callable = isCallableExport(checker, symbol, sourceFile);
|
|
const deprecated =
|
|
deprecatedEntrypoint ||
|
|
hasDeprecatedTag(symbol) ||
|
|
hasDeprecatedTag(unwrapAlias(checker, symbol));
|
|
if (callable) {
|
|
callableExports += 1;
|
|
uniqueCallableNames.add(exportName);
|
|
}
|
|
if (deprecated) {
|
|
deprecatedExports += 1;
|
|
if (callable) {
|
|
deprecatedCallableExports += 1;
|
|
}
|
|
}
|
|
}
|
|
byEntrypoint.set(entrypoint, {
|
|
exports: symbols.length,
|
|
callableExports,
|
|
deprecatedExports,
|
|
deprecatedCallableExports,
|
|
});
|
|
}
|
|
|
|
const totals = {
|
|
entrypoints: entrypoints.length,
|
|
exports: 0,
|
|
callableExports: 0,
|
|
deprecatedExports: 0,
|
|
deprecatedCallableExports: 0,
|
|
uniqueExports: uniqueNames.size,
|
|
uniqueCallableExports: uniqueCallableNames.size,
|
|
};
|
|
for (const stats of byEntrypoint.values()) {
|
|
totals.exports += stats.exports;
|
|
totals.callableExports += stats.callableExports;
|
|
totals.deprecatedExports += stats.deprecatedExports;
|
|
totals.deprecatedCallableExports += stats.deprecatedCallableExports;
|
|
}
|
|
return { byEntrypoint, totals };
|
|
}
|
|
|
|
function selectExportStats(scannedStats, entrypoints) {
|
|
const byEntrypoint = new Map();
|
|
const totals = {
|
|
entrypoints: entrypoints.length,
|
|
exports: 0,
|
|
callableExports: 0,
|
|
deprecatedExports: 0,
|
|
deprecatedCallableExports: 0,
|
|
uniqueExports: 0,
|
|
uniqueCallableExports: 0,
|
|
};
|
|
for (const entrypoint of entrypoints) {
|
|
const stats = scannedStats.byEntrypoint.get(entrypoint) ?? {
|
|
exports: 0,
|
|
callableExports: 0,
|
|
deprecatedExports: 0,
|
|
deprecatedCallableExports: 0,
|
|
};
|
|
byEntrypoint.set(entrypoint, stats);
|
|
totals.exports += stats.exports;
|
|
totals.callableExports += stats.callableExports;
|
|
totals.deprecatedExports += stats.deprecatedExports;
|
|
totals.deprecatedCallableExports += stats.deprecatedCallableExports;
|
|
}
|
|
// Export identities are entrypoint-qualified, so the selected totals are unique.
|
|
totals.uniqueExports = totals.exports;
|
|
totals.uniqueCallableExports = totals.callableExports;
|
|
return { byEntrypoint, totals };
|
|
}
|
|
|
|
function formatStats(label, stats) {
|
|
return [
|
|
`${label}:`,
|
|
` entrypoints: ${stats.entrypoints}`,
|
|
` exports: ${stats.exports}`,
|
|
` callable exports: ${stats.callableExports}`,
|
|
` deprecated exports: ${stats.deprecatedExports}`,
|
|
` deprecated callable exports: ${stats.deprecatedCallableExports}`,
|
|
` unique entrypoint-qualified exports: ${stats.uniqueExports}`,
|
|
].join("\n");
|
|
}
|
|
|
|
function collectDeprecatedEntrypointBudgetFailures(byEntrypoint, entrypointBudgets) {
|
|
const failures = [];
|
|
for (const [entrypoint, stats] of byEntrypoint) {
|
|
const budget = entrypointBudgets[entrypoint] ?? 0;
|
|
if (stats.deprecatedExports > budget) {
|
|
failures.push(
|
|
`public deprecated exports in ${entrypoint} ${stats.deprecatedExports} > ${budget}`,
|
|
);
|
|
}
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
export function collectPluginSdkSurfaceReport() {
|
|
const scannedEntrypoints = [
|
|
...new Set([
|
|
...pluginSdkEntrypoints,
|
|
...publicPluginSdkEntrypoints,
|
|
...privateLocalOnlyPluginSdkEntrypoints,
|
|
]),
|
|
];
|
|
const scannedStats = collectExportStats(scannedEntrypoints);
|
|
const allStats = selectExportStats(scannedStats, pluginSdkEntrypoints);
|
|
const publicStats = selectExportStats(scannedStats, publicPluginSdkEntrypoints);
|
|
const localOnlyStats = selectExportStats(scannedStats, privateLocalOnlyPluginSdkEntrypoints);
|
|
const publicWildcards = countWildcardReexports(publicPluginSdkEntrypoints);
|
|
const leakedForbiddenExports = readPackageExportedSubpaths().filter((subpath) =>
|
|
forbiddenPublicSubpaths.has(subpath),
|
|
);
|
|
const localOnlyStillPublic = privateLocalOnlyPluginSdkEntrypoints.filter(
|
|
(entrypoint) =>
|
|
publicEntrypointSet.has(entrypoint) && !packagedPrivateRuntimeEntrypointSet.has(entrypoint),
|
|
);
|
|
const localOnlyMissingFromInventory = [...localOnlyEntrypointSet].filter(
|
|
(entrypoint) => !pluginSdkEntrypoints.includes(entrypoint),
|
|
);
|
|
const deprecatedMissingFromPublic = [...deprecatedPublicEntrypointSet].filter(
|
|
(entrypoint) => !publicEntrypointSet.has(entrypoint),
|
|
);
|
|
const deprecatedBarrelMissingFromInventory = [...deprecatedBarrelEntrypointSet].filter(
|
|
(entrypoint) => !pluginSdkEntrypoints.includes(entrypoint),
|
|
);
|
|
const deprecatedBarrelWithoutWildcard = [...deprecatedBarrelEntrypointSet].filter(
|
|
(entrypoint) => {
|
|
const source = fs.readFileSync(entrypointPath(entrypoint), "utf8");
|
|
return !/^\s*export\s+(?:type\s+)?\*\s+from\s+["'][^"']+["']/mu.test(source);
|
|
},
|
|
);
|
|
return {
|
|
allStats,
|
|
deprecatedBarrelMissingFromInventory,
|
|
deprecatedBarrelWithoutWildcard,
|
|
deprecatedMissingFromPublic,
|
|
leakedForbiddenExports,
|
|
localOnlyMissingFromInventory,
|
|
localOnlyStats,
|
|
localOnlyStillPublic,
|
|
publicStats,
|
|
publicWildcards,
|
|
};
|
|
}
|
|
|
|
export function evaluatePluginSdkSurfaceReport(
|
|
report,
|
|
{ budgets, publicDeprecatedExportsByEntrypointBudget },
|
|
) {
|
|
const failures = [];
|
|
if (publicPluginSdkEntrypoints.length > budgets.publicEntrypoints) {
|
|
failures.push(
|
|
`public entrypoints ${publicPluginSdkEntrypoints.length} > ${budgets.publicEntrypoints}`,
|
|
);
|
|
}
|
|
if (report.publicStats.totals.exports > budgets.publicExports) {
|
|
failures.push(`public exports ${report.publicStats.totals.exports} > ${budgets.publicExports}`);
|
|
}
|
|
if (report.publicStats.totals.callableExports > budgets.publicFunctionExports) {
|
|
failures.push(
|
|
`public callable exports ${report.publicStats.totals.callableExports} > ${budgets.publicFunctionExports}`,
|
|
);
|
|
}
|
|
if (report.publicStats.totals.deprecatedExports > budgets.publicDeprecatedExports) {
|
|
failures.push(
|
|
`public deprecated exports ${report.publicStats.totals.deprecatedExports} > ${budgets.publicDeprecatedExports}`,
|
|
);
|
|
}
|
|
failures.push(
|
|
...collectDeprecatedEntrypointBudgetFailures(
|
|
report.publicStats.byEntrypoint,
|
|
publicDeprecatedExportsByEntrypointBudget,
|
|
),
|
|
);
|
|
if (report.publicWildcards.count > budgets.publicWildcardReexports) {
|
|
failures.push(
|
|
`public wildcard reexports ${report.publicWildcards.count} > ${budgets.publicWildcardReexports}`,
|
|
);
|
|
}
|
|
if (report.leakedForbiddenExports.length > 0) {
|
|
failures.push(`forbidden public subpaths: ${report.leakedForbiddenExports.join(", ")}`);
|
|
}
|
|
if (report.localOnlyStillPublic.length > 0) {
|
|
failures.push(`local-only entrypoints still public: ${report.localOnlyStillPublic.join(", ")}`);
|
|
}
|
|
if (report.localOnlyMissingFromInventory.length > 0) {
|
|
failures.push(
|
|
`local-only entrypoints missing from inventory: ${report.localOnlyMissingFromInventory.join(", ")}`,
|
|
);
|
|
}
|
|
if (report.deprecatedMissingFromPublic.length > 0) {
|
|
failures.push(
|
|
`deprecated public entrypoints missing from package surface: ${report.deprecatedMissingFromPublic.join(", ")}`,
|
|
);
|
|
}
|
|
if (report.deprecatedBarrelMissingFromInventory.length > 0) {
|
|
failures.push(
|
|
`deprecated barrel entrypoints missing from inventory: ${report.deprecatedBarrelMissingFromInventory.join(", ")}`,
|
|
);
|
|
}
|
|
if (report.deprecatedBarrelWithoutWildcard.length > 0) {
|
|
failures.push(
|
|
`deprecated barrel entrypoints without wildcard exports: ${report.deprecatedBarrelWithoutWildcard.join(", ")}`,
|
|
);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
function renderPluginSdkSurfaceReport(report) {
|
|
return [
|
|
formatStats("all SDK entrypoints", report.allStats.totals),
|
|
formatStats("public package SDK entrypoints", report.publicStats.totals),
|
|
formatStats("local-only SDK entrypoints", report.localOnlyStats.totals),
|
|
`deprecated public subpaths: ${deprecatedPublicPluginSdkEntrypoints.length}`,
|
|
`deprecated barrel subpaths: ${deprecatedBarrelPluginSdkEntrypoints.length}`,
|
|
`public wildcard reexports: ${report.publicWildcards.count}`,
|
|
`package-exported forbidden subpaths: ${report.leakedForbiddenExports.length}`,
|
|
].join("\n");
|
|
}
|
|
|
|
function main(argv = process.argv.slice(2), env = process.env) {
|
|
const cliArgs = parsePluginSdkSurfaceReportArgs(argv);
|
|
if (cliArgs.help) {
|
|
process.stdout.write(usage());
|
|
return 0;
|
|
}
|
|
const budgetConfig = readPluginSdkSurfaceBudgets(env);
|
|
const report = collectPluginSdkSurfaceReport();
|
|
process.stdout.write(`${renderPluginSdkSurfaceReport(report)}\n`);
|
|
const failures = evaluatePluginSdkSurfaceReport(report, budgetConfig);
|
|
if (cliArgs.check && failures.length > 0) {
|
|
process.stderr.write(`plugin SDK surface budget failed:\n`);
|
|
for (const failure of failures) {
|
|
process.stderr.write(`- ${failure}\n`);
|
|
}
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
const isMain =
|
|
typeof process.argv[1] === "string" &&
|
|
process.argv[1].length > 0 &&
|
|
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
|
|
if (isMain) {
|
|
try {
|
|
process.exitCode = main();
|
|
} catch (error) {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|