mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 11:31:11 +00:00
* feat(browser): send pages to the main session from the Chrome extension One-click page share in the OpenClaw Chrome extension: toolbar popup with an optional note, page/selection context menu, and Alt+Shift+S. Capture is selection-first with a readability heuristic, X/Twitter thread extraction, and Google Docs plain-text export via the user's session cookies. Payloads ride the existing paired relay WebSocket as a new pageShare message; the gateway-only page-share sink wraps page text in the external-content safety boundary, then enqueues a main-session system event and requests an immediate heartbeat (hooks/wake semantics). Node-hosted relays report a clear unsupported error. Capture heuristics adapted from Nat Eliason's MIT-licensed send-to-openclaw. Co-authored-by: Codex <codex@openai.com> * fix(browser): keep page-controlled metadata inside the share safety boundary Review findings: move title/URL inside wrapExternalContent (a hostile <title> must not become trusted header text), prefer the user's selection over the full Google Docs export, and pass the context-menu selectionText through so iframe selections and selections cleared during relay reconnect still win. * fix(browser): bind context-menu shares to the click-time document Selection shares from the context menu now send the click snapshot directly (no recapture), so navigations during relay reconnect cannot mislabel the source and iframe selections are preserved. The Google Docs selection probe scans all accessible frames before falling back to the full-document export. * test(browser): expect the page-share handler in relay server args * fix(browser): probe only the main frame for Google Docs selections All-frame injection rejects wholesale when one frame is inaccessible and returns child frames in nondeterministic order, so the probe now reads the main frame only. Child-frame selections still share correctly through the context menu's click-time selectionText; toolbar/shortcut entry sends the full page for that case (named tradeoff in the code comment). * fix(browser): satisfy page-share CI gates --------- Co-authored-by: Codex <codex@openai.com>
728 lines
30 KiB
JavaScript
728 lines
30 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,
|
|
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 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,
|
|
health: 1,
|
|
"command-gating": 5,
|
|
lmstudio: 37,
|
|
"lmstudio-runtime": 27,
|
|
"provider-setup": 1,
|
|
"self-hosted-provider-setup": 14,
|
|
routing: 1,
|
|
runtime: 3,
|
|
// Deprecated Telegram-named alias retained for plugin SDK compatibility.
|
|
"retry-runtime": 1,
|
|
"runtime-logger": 3,
|
|
"runtime-secret-resolution": 5,
|
|
"secret-provider-integration": 4,
|
|
"setup-adapter-runtime": 1,
|
|
"skills-runtime": 5,
|
|
"channel-streaming": 55,
|
|
"approval-gateway-runtime": 1,
|
|
"approval-handler-runtime": 1,
|
|
"approval-reply-runtime": 3,
|
|
"approval-runtime": 1,
|
|
"config-runtime": 123,
|
|
"config-contracts": 1,
|
|
// +1 each: unified implicit-mention config and AgentThinkingLevel types.
|
|
// +1: SwarmConfig mirrors the public tools.swarm config contract.
|
|
"config-types": 428,
|
|
"config-schema": 3,
|
|
"reply-dedupe": 1,
|
|
"inbound-reply-dispatch": 26,
|
|
"channel-reply-pipeline": 12,
|
|
"channel-reply-options-runtime": 2,
|
|
"channel-runtime": 144,
|
|
"interactive-runtime": 13,
|
|
"outbound-send-deps": 4,
|
|
"outbound-runtime": 16,
|
|
"file-access-runtime": 2,
|
|
"infra-runtime": 595,
|
|
"ssrf-policy": 1,
|
|
"ssrf-runtime": 1,
|
|
"media-runtime": 2,
|
|
"text-runtime": 191,
|
|
"agent-core": 1,
|
|
"agent-runtime": 7,
|
|
"plugin-runtime": 13,
|
|
"channel-secret-runtime": 23,
|
|
"secret-file-runtime": 1,
|
|
"security-runtime": 7,
|
|
"agent-harness": 7,
|
|
"agent-harness-runtime": 11,
|
|
types: 6,
|
|
"agent-config-primitives": 2,
|
|
"command-auth": 81,
|
|
// +2: group scope encoder/key builder mirrored by deprecated compat.
|
|
// +5: shared channel setup, policy, and config schema helpers.
|
|
compat: 167,
|
|
"direct-dm": 9,
|
|
"direct-dm-access": 5,
|
|
discord: 48,
|
|
mattermost: 7,
|
|
matrix: 1,
|
|
// +3: shared multi-account and group-entry schema builders.
|
|
"channel-config-schema-legacy": 25,
|
|
"channel-actions": 2,
|
|
"channel-envelope": 3,
|
|
"channel-inbound": 21,
|
|
"channel-inbound-roots": 1,
|
|
"channel-logging": 4,
|
|
"channel-location": 4,
|
|
"channel-mention-gating": 7,
|
|
"channel-lifecycle": 23,
|
|
// Registry sweep: 77 packages, zero fetch failures; channel-ingress and dead aliases
|
|
// had zero consumers.
|
|
// +11 each: durable channel-ingress drain seam (drain/lifecycle/claim/retry) mirrored by compat (#108656).
|
|
// +3 each: shared ingress monitor factory and lifecycle/result contracts.
|
|
"channel-message": 244,
|
|
"channel-message-runtime": 241,
|
|
"channel-pairing-paths": 1,
|
|
// Deprecated pairing/conversation exports from the SQLite pairing migration
|
|
// landed on main (#105802) without entrypoint pins; not touched by this PR.
|
|
"channel-pairing": 1,
|
|
"conversation-runtime": 4,
|
|
"channel-send-result": 1,
|
|
"channel-policy": 15,
|
|
"channel-route": 5,
|
|
"session-store-runtime": 4,
|
|
"session-transcript-runtime": 2,
|
|
"group-access": 13,
|
|
"media-generation-runtime-shared": 3,
|
|
"music-generation-core": 20,
|
|
"reply-history": 8,
|
|
"messaging-targets": 12,
|
|
"memory-core": 45,
|
|
"memory-core-engine-runtime": 15,
|
|
"memory-core-host-multimodal": 3,
|
|
"memory-core-host-query": 2,
|
|
"memory-core-host-events": 12,
|
|
"memory-core-host-status": 1,
|
|
"memory-core-host-runtime-core": 1,
|
|
"memory-host-core": 1,
|
|
"memory-host-files": 7,
|
|
"memory-host-status": 72,
|
|
"provider-auth": 20,
|
|
"provider-oauth-runtime": 2,
|
|
"provider-auth-login": 3,
|
|
"provider-model-shared": 30,
|
|
"provider-stream-family": 40,
|
|
"provider-stream-shared": 29,
|
|
"provider-stream": 40,
|
|
"provider-web-search": 1,
|
|
"provider-zai-endpoint": 3,
|
|
"telegram-account": 3,
|
|
"telegram-command-config": 7,
|
|
"webhook-ingress": 2,
|
|
"webhook-path": 2,
|
|
zalouser: 5,
|
|
zod: 282,
|
|
});
|
|
|
|
export function readPluginSdkSurfaceBudgets(env = process.env) {
|
|
const budgets = {
|
|
publicEntrypoints: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS",
|
|
// Registry sweep: 77 packages, zero fetch failures; retired dead channel-ingress facade.
|
|
// +1: speech-settings keeps agent prompt imports off the synthesis/runtime graph.
|
|
// +1: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter.
|
|
// +1: question-gateway-runtime resolves ask_user choices for channel plugins.
|
|
// +1: ingress-effect-once gives drained channels a narrow durable side-effect guard.
|
|
332,
|
|
env,
|
|
),
|
|
// ScopeTree adds six channel-policy exports, mirrored by compat, including three functions.
|
|
// Its flat channel-groups builder adds one function, also mirrored by compat.
|
|
// Its case-insensitive scope-key resolver adds one function, also mirrored by compat.
|
|
// Its length-prefixed segment encoder and scope-key builder add two functions, also mirrored.
|
|
// The focused HTML entity runtime and quote-aware HTML tokenizer add one public function each.
|
|
// Plugin service Gateway event scope and emitter types add four facade exports.
|
|
publicExports: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
|
|
// +4: registerMcpServerConnectionResolver context/result/resolver/registration types (#106229).
|
|
// +2: materializeRequesterScopedMcpToolsForHarnessRun (agent-harness-runtime + compat mirror).
|
|
// +1: matchesNoProxy exposes canonical Undici-compatible bypass selection to plugins.
|
|
// +4: group scope encoder/key builder (channel-policy + compat mirror).
|
|
// +1: runDetachedWebhookWork gives post-ack work an independently tracked admission root.
|
|
// +9: app-guided provider setup context/candidate/hook types and their public mirrors.
|
|
// +3: atomic SQLite STRICT migration function, options, and result for plugin stores.
|
|
// Harvest: channel-ingress -64; dead channel-message dispatch aliases -23.
|
|
// Harvest: retired qa-live-transport-scenarios subpath -6.
|
|
// +12: typed plan step/status and checklist formatter across channel barrels.
|
|
// +8: plan-step ingress union and normalizer across channel barrels.
|
|
// Harvest: retired dual-field plan payload builder -1.
|
|
// +12: active plan-step consumers pinned through channel-outbound and mirrors.
|
|
// +6: app-guided provider setup types retained by plugin-entry and mirrors.
|
|
// +3: widget HTML validation helpers and tool input error.
|
|
// Used-union narrowing: 31 wildcard barrels drop to explicit used exports;
|
|
// proxy stream API and codex marker/scaffold pins retained.
|
|
// +2: generic channel retry runner and Retry-After parser.
|
|
// +1: shared speech-provider API key resolver.
|
|
// +32: shared channel setup, config-schema, policy, and status helpers.
|
|
// +2: shared channel replay-guard factory and claim handle.
|
|
// +6: lightweight speech settings types, normalizers, and config resolver.
|
|
// +4: unified implicit-mention config, schema, resolved policy, and resolver.
|
|
// Harvest: retired AudioConfig type -1.
|
|
// +4: bounded plugin blob store options, entry, entry info, and store types.
|
|
// +6: shared progress receipt tracker + compositor snapshot across channel barrels.
|
|
// +1: selectPreferredLocalModelId shares app-guided local model ranking across providers.
|
|
// +4: shared audio-energy stats and speech-threshold gate through realtime-voice.
|
|
// +2: supplemental sender decision and outbound text chunk sequencer.
|
|
// +2: shared realtime voice session harness through realtime-voice.
|
|
// +24: narrowed durable channel-ingress drain seam — factory, lifecycle binding,
|
|
// tuning constants, and telegram-consumed claim helpers with compat mirrors,
|
|
// after harvesting exports orphaned by the split-out WhatsApp adapter (#108656).
|
|
// +10: supplemental sender helpers plus host-owned SQLite lease contracts.
|
|
// Harvest: retired dual-field plan payload builder -1.
|
|
// +23: core channel, envelope, direct-DM, feedback, legacy-payload, and memory contracts.
|
|
// +81: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter.
|
|
// +3: question-gateway-runtime resolver plus request/result types.
|
|
// +1: async memory prompt preparation registration.
|
|
// +1: canonical memory host event normalization for SQLite storage.
|
|
// +1: centralized remember-across-conversations effective-default resolver.
|
|
// +4: gateway-backed harness question runner, claim/cancel helpers, and caller type.
|
|
// Harvest: internal question runtime exports -2.
|
|
// +1: ingress-effect-once factory.
|
|
// +1: shared persistent-dedupe claim loop.
|
|
// +3: bounded raw transcript cursor request, result, and reader.
|
|
// +3: bounded visible transcript cursor request, result, and reader.
|
|
// +1: explicit AgentModelPolicyConfig shared with provider setup surfaces.
|
|
// +1: AgentHarnessSessionSupersededError lets harness plugins stop stale-owner fallback.
|
|
// +1: AgentThinkingLevel shared by default-turn and compaction config.
|
|
// +9: shared ingress monitor factory and lifecycle/result contracts across
|
|
// channel-outbound and its two deprecated compatibility barrels.
|
|
// +1: SwarmConfig exposes the tools.swarm contract through config-types.
|
|
// +3: harness sessionFork capability params, result, and failure-code contracts.
|
|
// +2: upstream-link registry write/delete for harness-owned session forks.
|
|
// Harvest: mention-pattern schemas and helper exports -3.
|
|
// +1: config-backed main-session resolver for Gateway-hosted plugin services.
|
|
8180,
|
|
env,
|
|
),
|
|
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
|
|
// +2: materializeRequesterScopedMcpToolsForHarnessRun (agent-harness-runtime + compat mirror).
|
|
// +4: group scope encoder/key builder (channel-policy + compat mirror).
|
|
// +1: atomic SQLite STRICT migration for plugin stores.
|
|
// +1: runDetachedWebhookWork gives post-ack work an independently tracked admission root.
|
|
// Harvest: channel-ingress -19; dead channel-message dispatch aliases -23.
|
|
// Harvest: retired qa-live-transport-scenarios subpath -3.
|
|
// +4: shared plan checklist formatter across channel barrels.
|
|
// +4: plan-step normalizer across channel barrels.
|
|
// Harvest: retired dual-field plan payload builder -1.
|
|
// +6: active plan-step helpers pinned through channel-outbound and mirrors.
|
|
// +2: widget HTML document detection and size assertion.
|
|
// Used-union narrowing of the 31 wildcard barrels.
|
|
// +2: generic channel retry runner and Retry-After parser.
|
|
// +1: shared speech-provider API key resolver.
|
|
// +24: shared channel setup, config-schema, policy, and status helpers.
|
|
// +1: shared channel replay-guard factory.
|
|
// +3: receipt tracker/snapshot callables across channel barrels.
|
|
// +3: lightweight speech settings normalizers and config resolver.
|
|
// +1: unified implicit-mention policy resolver.
|
|
// +1: selectPreferredLocalModelId shares app-guided local model ranking across providers.
|
|
// +3: PCM16/mu-law energy readers and speech-threshold gate factory.
|
|
// +2: supplemental sender decision and outbound text chunk sequencer.
|
|
// +1: shared realtime voice session harness through realtime-voice.
|
|
// +9: narrowed drain seam functions and compat mirrors after the
|
|
// WhatsApp-split harvest (#108656).
|
|
// +3: supplemental sender helpers plus the PluginStateLeaseRunner callback.
|
|
// Harvest: retired dual-field plan payload builder -1.
|
|
// +13: core channel, envelope, direct-DM, feedback, legacy-payload, and memory operations.
|
|
// +32: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter.
|
|
// +1: question-gateway-runtime resolver.
|
|
// +1: async memory prompt preparation registration.
|
|
// +1: canonical memory host event normalization for SQLite storage.
|
|
// +1: centralized remember-across-conversations effective-default resolver.
|
|
// +3: gateway-backed harness question runner and claim/cancel helpers.
|
|
// Harvest: internal question runtime callable -1.
|
|
// +1: ingress-effect-once factory.
|
|
// +1: shared persistent-dedupe claim loop.
|
|
// +1: bounded raw transcript cursor reader.
|
|
// +1: bounded visible transcript cursor reader.
|
|
// +3: shared ingress monitor factory across channel-outbound and compat mirrors.
|
|
// +2: upstream-link registry write/delete for harness-owned session forks.
|
|
// +1: config-backed main-session resolver for Gateway-hosted plugin services.
|
|
4549,
|
|
env,
|
|
),
|
|
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
|
// +2: group scope encoder/key builder mirrored by deprecated compat.
|
|
// Harvest: channel-ingress -8; dead channel-message dispatch aliases -23.
|
|
// +77: five zero-consumer subpaths enter their removal window.
|
|
// +9: typed plan exports and formatter through deprecated channel barrels.
|
|
// +6: plan-step ingress union and normalizer through deprecated channel barrels.
|
|
// +8: channel-outbound plan pins mirrored through deprecated barrels.
|
|
// Used-union narrowing drops inherited deprecated exports.
|
|
// +1: Telegram runner alias retained for plugin SDK compatibility.
|
|
// +8: shared channel helpers mirrored by deprecated barrels.
|
|
// +3: receipt/snapshot exports through deprecated channel barrels.
|
|
// +1: unified implicit-mention config type through deprecated config-types.
|
|
// +24: narrowed drain seam compat mirrors in the channel-message
|
|
// deprecation-window barrels (#108656).
|
|
// Harvest: retired dual-field plan payload builder -1; lower-only drift -8.
|
|
// +1: AgentModelPolicyConfig mirrored by deprecated config-types.
|
|
// +6: ingress monitor lifecycle/result contracts through deprecated channel barrels.
|
|
// +1: AgentThinkingLevel mirrored by deprecated config-types.
|
|
// +1: SwarmConfig mirrored by deprecated config-types.
|
|
3014,
|
|
env,
|
|
),
|
|
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
|
|
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
|
|
// Used-union narrowing removes 103 wildcard re-exports.
|
|
// Harvest: freeze the compat config-schema barrel to explicit exports -1;
|
|
// retire the Memory Core facade's event-store wildcard -1.
|
|
103,
|
|
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),
|
|
);
|
|
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;
|
|
}
|
|
}
|