diff --git a/.changeset/hotload-boundary-manifest.md b/.changeset/hotload-boundary-manifest.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/hotload-boundary-manifest.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/package.json b/package.json
index 7ef6382fb9c..0af07572927 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"bundlewatch": "turbo run bundlewatch",
"changeset": "changeset",
"changeset:empty": "pnpm changeset --empty",
+ "check:hotload-boundary": "node ./scripts/hotload-boundary/check-hotload-boundary.mjs",
"clean": "turbo run clean",
"dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension",
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
diff --git a/scripts/hotload-boundary/abi-manifest.json b/scripts/hotload-boundary/abi-manifest.json
new file mode 100644
index 00000000000..8d4e9e1ea20
--- /dev/null
+++ b/scripts/hotload-boundary/abi-manifest.json
@@ -0,0 +1,427 @@
+{
+ "version": 1,
+ "description": "Runtime contract manifest for Clerk / IsomorphicClerk __internal_* members that can cross the Clerk.js hotload boundary.",
+ "surfaces": {
+ "Clerk": {
+ "package": "@clerk/clerk-js",
+ "file": "packages/clerk-js/src/core/clerk.ts",
+ "className": "Clerk"
+ },
+ "IsomorphicClerk": {
+ "package": "@clerk/react",
+ "file": "packages/react/src/isomorphicClerk.ts",
+ "className": "IsomorphicClerk"
+ }
+ },
+ "entries": [
+ {
+ "member": "__internal_addNavigationListener",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/ui/src/router/VirtualRouter.tsx"],
+ "notes": "Consumed by UI routing code to observe external navigation; removal would break UI packages paired with hotloaded Clerk.js."
+ },
+ {
+ "member": "__internal_attemptToEnableEnvironmentSetting",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts",
+ "packages/vue/src/composables/useOrganization.ts"
+ ],
+ "notes": "Consumed by shared and framework hooks to enable environment-backed features on demand."
+ },
+ {
+ "member": "__internal_closeBlankCaptchaModal",
+ "surfaces": ["Clerk"],
+ "boundary": "local-only",
+ "compatibility": "keep",
+ "risk": "low",
+ "consumerFiles": ["packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts"],
+ "notes": "Used inside clerk-js captcha handling; no current cross-package consumer."
+ },
+ {
+ "member": "__internal_closeCheckout",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/react/src/isomorphicClerk.ts", "packages/shared/src/types/clerk.ts"],
+ "notes": "Part of the checkout UI control surface mirrored through IsomorphicClerk."
+ },
+ {
+ "member": "__internal_closeEnableOrganizationsPrompt",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx"],
+ "notes": "Consumed by UI prompts that can be bundled separately from the hotloaded Clerk runtime."
+ },
+ {
+ "member": "__internal_closePlanDetails",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/react/src/isomorphicClerk.ts", "packages/shared/src/types/clerk.ts"],
+ "notes": "Part of the plan details UI control surface mirrored through IsomorphicClerk."
+ },
+ {
+ "member": "__internal_closeReverification",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/shared/src/types/clerk.ts"],
+ "notes": "Part of the reverification UI control surface exposed through shared Clerk types."
+ },
+ {
+ "member": "__internal_closeSubscriptionDetails",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/react/src/isomorphicClerk.ts", "packages/shared/src/types/clerk.ts"],
+ "notes": "Part of the subscription details UI control surface mirrored through IsomorphicClerk."
+ },
+ {
+ "member": "__internal_country",
+ "surfaces": ["Clerk"],
+ "boundary": "local-only",
+ "compatibility": "keep",
+ "risk": "low",
+ "consumerFiles": ["packages/clerk-js/src/core/clerk.ts"],
+ "notes": "Set and read by clerk-js resources for country-specific behavior."
+ },
+ {
+ "member": "__internal_createPublicCredentials",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/expo/src/provider/singleton/createClerkInstance.ts"],
+ "notes": "Assigned by Expo to provide native passkey credential creation to Clerk resources."
+ },
+ {
+ "member": "__internal_environment",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "high",
+ "consumerFiles": [
+ "packages/shared/src/react/hooks/useBillingIsEnabled.ts",
+ "packages/shared/src/react/billing/payment-element.tsx",
+ "packages/expo/src/provider/singleton/createClerkInstance.ts"
+ ],
+ "notes": "Broadly consumed for environment settings and feature flags; shape changes can silently alter feature availability."
+ },
+ {
+ "member": "__internal_getCachedResources",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/expo/src/provider/singleton/createClerkInstance.ts",
+ "packages/shared/src/types/clerk.ts"
+ ],
+ "notes": "Installed SDKs can provide cached resources that the hotloaded runtime consumes during bootstrap."
+ },
+ {
+ "member": "__internal_getOption",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "high",
+ "consumerFiles": [
+ "packages/ui/src/contexts/components/SessionTasks.ts",
+ "packages/shared/src/react/billing/payment-element.tsx",
+ "packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts"
+ ],
+ "notes": "Shared, UI, and runtime code read Clerk options through this method; signature changes affect multiple package generations."
+ },
+ {
+ "member": "__internal_getPublicCredentials",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/expo/src/provider/singleton/createClerkInstance.ts",
+ "packages/clerk-js/src/core/resources/SignIn.ts"
+ ],
+ "notes": "Assigned by Expo and consumed by Clerk resources for native passkey credential retrieval."
+ },
+ {
+ "member": "__internal_handleUnauthenticatedDevBrowser",
+ "surfaces": ["Clerk"],
+ "boundary": "local-only",
+ "compatibility": "keep",
+ "risk": "low",
+ "consumerFiles": ["packages/clerk-js/src/core/resources/Base.ts"],
+ "notes": "Used by clerk-js resources for development browser handling."
+ },
+ {
+ "member": "__internal_isWebAuthnAutofillSupported",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/expo/src/provider/singleton/createClerkInstance.ts",
+ "packages/clerk-js/src/core/resources/SignIn.ts"
+ ],
+ "notes": "Assigned by Expo and consumed by Clerk resources for native WebAuthn autofill support."
+ },
+ {
+ "member": "__internal_isWebAuthnPlatformAuthenticatorSupported",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/expo/src/provider/singleton/createClerkInstance.ts",
+ "packages/clerk-js/src/core/resources/Passkey.ts"
+ ],
+ "notes": "Assigned by Expo and consumed by Clerk resources for native platform authenticator support."
+ },
+ {
+ "member": "__internal_isWebAuthnSupported",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/expo/src/provider/singleton/createClerkInstance.ts",
+ "packages/clerk-js/src/core/resources/SignIn.ts"
+ ],
+ "notes": "Assigned by Expo and consumed by Clerk resources for native WebAuthn support."
+ },
+ {
+ "member": "__internal_lastEmittedResources",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "high",
+ "consumerFiles": [
+ "packages/react/src/hooks/useAuthBase.tsx",
+ "packages/shared/src/react/hooks/base/useClientBase.ts",
+ "packages/ui/src/Components.tsx"
+ ],
+ "notes": "Used by hooks to read the latest emitted resource snapshot; stale or missing values can produce silent empty state."
+ },
+ {
+ "member": "__internal_last_error",
+ "surfaces": ["Clerk"],
+ "boundary": "local-only",
+ "compatibility": "keep",
+ "risk": "low",
+ "consumerFiles": ["packages/clerk-js/src/core/clerk.ts"],
+ "notes": "Internal backing storage for navigation-with-error handling."
+ },
+ {
+ "member": "__internal_loadStripeJs",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/shared/src/react/billing/useStripeClerkLibs.tsx"],
+ "notes": "Shared billing code loads Stripe through Clerk; keep the function shape coordinated with billing packages."
+ },
+ {
+ "member": "__internal_mountOAuthConsent",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/react/src/components/uiComponents.tsx"],
+ "notes": "React UI components mount OAuth consent through this bridge."
+ },
+ {
+ "member": "__internal_navigateWithError",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/ui/src/components/SignIn/shared.ts"],
+ "notes": "UI sign-in flows use this method to route while preserving Clerk API errors."
+ },
+ {
+ "member": "__internal_onAfterResponse",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/expo/src/provider/singleton/createClerkInstance.ts",
+ "packages/chrome-extension/src/internal/clerk.ts"
+ ],
+ "notes": "Installed SDKs attach response interceptors to the Clerk runtime."
+ },
+ {
+ "member": "__internal_onBeforeRequest",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/expo/src/provider/singleton/createClerkInstance.ts",
+ "packages/chrome-extension/src/internal/clerk.ts"
+ ],
+ "notes": "Installed SDKs attach request interceptors to the Clerk runtime."
+ },
+ {
+ "member": "__internal_openBlankCaptchaModal",
+ "surfaces": ["Clerk"],
+ "boundary": "local-only",
+ "compatibility": "keep",
+ "risk": "low",
+ "consumerFiles": ["packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts"],
+ "notes": "Used inside clerk-js captcha handling; no current cross-package consumer."
+ },
+ {
+ "member": "__internal_openCheckout",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/react/src/components/CheckoutButton.tsx",
+ "packages/ui/src/contexts/components/Plans.tsx",
+ "packages/vue/src/components/CheckoutButton.vue"
+ ],
+ "notes": "Framework and UI packages open checkout through this runtime method."
+ },
+ {
+ "member": "__internal_openEnableOrganizationsPrompt",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/react/src/isomorphicClerk.ts", "packages/shared/src/types/clerk.ts"],
+ "notes": "Prompt control surface used when installed packages request organization enablement."
+ },
+ {
+ "member": "__internal_openPlanDetails",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/react/src/components/PlanDetailsButton.tsx",
+ "packages/ui/src/components/PricingTable/PricingTableDefault.tsx",
+ "packages/vue/src/components/PlanDetailsButton.vue"
+ ],
+ "notes": "Framework and UI packages open plan details through this runtime method."
+ },
+ {
+ "member": "__internal_openReverification",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/shared/src/react/hooks/useReverification.ts"],
+ "notes": "Shared reverification hooks open the runtime reverification UI through this method."
+ },
+ {
+ "member": "__internal_openSubscriptionDetails",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/react/src/components/SubscriptionDetailsButton.tsx",
+ "packages/ui/src/contexts/components/Plans.tsx",
+ "packages/vue/src/components/SubscriptionDetailsButton.vue"
+ ],
+ "notes": "Framework and UI packages open subscription details through this runtime method."
+ },
+ {
+ "member": "__internal_queryClient",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "shim",
+ "risk": "high",
+ "notes": "Backward-compatibility shim for older installed @clerk/shared versions that read this getter and wait for queryClientStatus."
+ },
+ {
+ "member": "__internal_reloadInitialResources",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/expo/src/provider/ClerkProvider.tsx", "packages/expo/src/native/AuthView.tsx"],
+ "notes": "Expo uses this method to recover initial resources after native auth flows."
+ },
+ {
+ "member": "__internal_setActiveInProgress",
+ "surfaces": ["Clerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": [
+ "packages/ui/src/common/SSOCallback.tsx",
+ "packages/ui/src/components/SignIn/SignInFactorOne.tsx"
+ ],
+ "notes": "UI flows use this flag to avoid racing active-session transitions."
+ },
+ {
+ "member": "__internal_setCountry",
+ "surfaces": ["Clerk"],
+ "boundary": "local-only",
+ "compatibility": "keep",
+ "risk": "low",
+ "consumerFiles": ["packages/clerk-js/src/core/resources/Base.ts"],
+ "notes": "Used by clerk-js resources to persist response-derived country state."
+ },
+ {
+ "member": "__internal_setEnvironment",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "released-together",
+ "compatibility": "keep",
+ "risk": "low",
+ "consumerFiles": ["packages/react/src/isomorphicClerk.ts"],
+ "notes": "Compatibility bridge for older Clerk implementations; currently used by IsomorphicClerk when available."
+ },
+ {
+ "member": "__internal_state",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "high",
+ "consumerFiles": [
+ "packages/react/src/hooks/useClerkSignal.ts",
+ "packages/shared/src/react/hooks/useCheckout.ts",
+ "packages/react/src/stateProxy.ts"
+ ],
+ "notes": "State signal object consumed by installed React/shared code; shape and signal semantics are runtime contract."
+ },
+ {
+ "member": "__internal_unmountOAuthConsent",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "medium",
+ "consumerFiles": ["packages/react/src/components/uiComponents.tsx"],
+ "notes": "React UI components unmount OAuth consent through this bridge."
+ },
+ {
+ "member": "__internal_updateProps",
+ "surfaces": ["Clerk", "IsomorphicClerk"],
+ "boundary": "hotload-boundary",
+ "compatibility": "keep",
+ "risk": "high",
+ "consumerFiles": [
+ "packages/react/src/components/uiComponents.tsx",
+ "packages/vue/src/utils/updateClerkOptions.ts",
+ "packages/astro/src/internal/create-clerk-instance.ts"
+ ],
+ "notes": "Framework adapters and component wrappers update runtime props through this method."
+ }
+ ]
+}
diff --git a/scripts/hotload-boundary/check-hotload-boundary.mjs b/scripts/hotload-boundary/check-hotload-boundary.mjs
new file mode 100644
index 00000000000..60f572f62e1
--- /dev/null
+++ b/scripts/hotload-boundary/check-hotload-boundary.mjs
@@ -0,0 +1,253 @@
+#!/usr/bin/env node
+
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import ts from 'typescript';
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDir, '../..');
+const manifestPath = path.join(scriptDir, 'abi-manifest.json');
+
+const allowedBoundaries = new Set(['local-only', 'released-together', 'hotload-boundary']);
+const allowedCompatibility = new Set(['keep', 'shim', 'deprecate', 'major-only removal']);
+const allowedRisks = new Set(['low', 'medium', 'high']);
+
+const args = new Set(process.argv.slice(2));
+
+function toPosixPath(filePath) {
+ return filePath.split(path.sep).join('/');
+}
+
+function toAbsolutePath(filePath) {
+ return path.join(repoRoot, filePath);
+}
+
+async function readJson(filePath) {
+ return JSON.parse(await readFile(filePath, 'utf8'));
+}
+
+function getMemberName(nameNode) {
+ if (!nameNode) {
+ return undefined;
+ }
+
+ if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) {
+ return nameNode.text;
+ }
+
+ return undefined;
+}
+
+function getMemberKind(member) {
+ if (ts.isGetAccessor(member)) {
+ return 'getter';
+ }
+ if (ts.isSetAccessor(member)) {
+ return 'setter';
+ }
+ if (ts.isMethodDeclaration(member)) {
+ return 'method';
+ }
+ if (ts.isPropertyDeclaration(member)) {
+ return 'property';
+ }
+ return 'member';
+}
+
+async function extractInternalMembers(surface) {
+ const sourceText = await readFile(toAbsolutePath(surface.file), 'utf8');
+ const sourceFile = ts.createSourceFile(surface.file, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
+ const membersByName = new Map();
+
+ function visit(node) {
+ if (ts.isClassDeclaration(node) && node.name?.text === surface.className) {
+ for (const member of node.members) {
+ const memberName = getMemberName(member.name);
+
+ if (!memberName?.startsWith('__internal_')) {
+ continue;
+ }
+
+ const line = sourceFile.getLineAndCharacterOfPosition(member.getStart(sourceFile)).line + 1;
+ const existing = membersByName.get(memberName);
+
+ if (existing) {
+ existing.kinds.add(getMemberKind(member));
+ existing.line = Math.min(existing.line, line);
+ } else {
+ membersByName.set(memberName, {
+ member: memberName,
+ surface: surface.name,
+ file: surface.file,
+ line,
+ kinds: new Set([getMemberKind(member)]),
+ });
+ }
+ }
+ return;
+ }
+
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sourceFile);
+
+ return [...membersByName.values()]
+ .map(member => ({
+ ...member,
+ kinds: [...member.kinds].sort(),
+ }))
+ .sort((left, right) => left.member.localeCompare(right.member));
+}
+
+function getSurfaceDefinitions(manifest) {
+ return Object.entries(manifest.surfaces || {}).map(([name, surface]) => ({
+ name,
+ ...surface,
+ }));
+}
+
+function formatMemberKey(surface, member) {
+ return `${surface}.${member}`;
+}
+
+async function validateConsumerFiles(entry, failures) {
+ for (const consumerFile of entry.consumerFiles || []) {
+ const absolutePath = toAbsolutePath(consumerFile);
+ let content;
+
+ try {
+ content = await readFile(absolutePath, 'utf8');
+ } catch {
+ failures.push(`${entry.member}: consumer file does not exist: ${consumerFile}`);
+ continue;
+ }
+
+ if (!content.includes(entry.member)) {
+ failures.push(`${entry.member}: consumer file does not reference member: ${consumerFile}`);
+ }
+ }
+}
+
+function renderMarkdownTable(entries) {
+ const rows = [
+ '| Member | Surfaces | Boundary | Compatibility | Risk | Consumer files |',
+ '| --- | --- | --- | --- | --- | --- |',
+ ];
+
+ for (const entry of entries) {
+ rows.push(
+ [
+ `\`${entry.member}\``,
+ entry.surfaces.map(surface => `\`${surface}\``).join(', '),
+ entry.boundary,
+ entry.compatibility,
+ entry.risk,
+ (entry.consumerFiles || []).map(file => `\`${file}\``).join('
') || 'None listed',
+ ]
+ .join(' | ')
+ .replace(/^/, '| ') + ' |',
+ );
+ }
+
+ return rows.join('\n');
+}
+
+async function main() {
+ const manifest = await readJson(manifestPath);
+ const failures = [];
+ const surfaceDefinitions = getSurfaceDefinitions(manifest);
+ const knownSurfaces = new Set(surfaceDefinitions.map(surface => surface.name));
+ const discoveredMembers = (await Promise.all(surfaceDefinitions.map(extractInternalMembers))).flat();
+ const discoveredByKey = new Map(
+ discoveredMembers.map(member => [formatMemberKey(member.surface, member.member), member]),
+ );
+ const manifestByKey = new Map();
+
+ if (!Array.isArray(manifest.entries)) {
+ failures.push('Manifest must contain an entries array.');
+ }
+
+ for (const surface of surfaceDefinitions) {
+ if (!surface.file || !surface.className) {
+ failures.push(`${surface.name}: surface must define file and className.`);
+ }
+ }
+
+ for (const entry of manifest.entries || []) {
+ if (!entry.member?.startsWith('__internal_')) {
+ failures.push(`Invalid member name: ${entry.member || ''}`);
+ continue;
+ }
+
+ if (!Array.isArray(entry.surfaces) || entry.surfaces.length === 0) {
+ failures.push(`${entry.member}: must list at least one surface.`);
+ }
+
+ if (!allowedBoundaries.has(entry.boundary)) {
+ failures.push(`${entry.member}: invalid boundary "${entry.boundary}".`);
+ }
+
+ if (!allowedCompatibility.has(entry.compatibility)) {
+ failures.push(`${entry.member}: invalid compatibility "${entry.compatibility}".`);
+ }
+
+ if (!allowedRisks.has(entry.risk)) {
+ failures.push(`${entry.member}: invalid risk "${entry.risk}".`);
+ }
+
+ if (!entry.notes) {
+ failures.push(`${entry.member}: notes are required.`);
+ }
+
+ if (entry.consumerFiles && !Array.isArray(entry.consumerFiles)) {
+ failures.push(`${entry.member}: consumerFiles must be an array when present.`);
+ }
+
+ for (const surface of entry.surfaces || []) {
+ if (!knownSurfaces.has(surface)) {
+ failures.push(`${entry.member}: unknown surface "${surface}".`);
+ continue;
+ }
+
+ const key = formatMemberKey(surface, entry.member);
+ if (manifestByKey.has(key)) {
+ failures.push(`${key}: duplicate manifest entry.`);
+ }
+ manifestByKey.set(key, entry);
+
+ if (!discoveredByKey.has(key)) {
+ failures.push(`${key}: listed in manifest but not found in source.`);
+ }
+ }
+
+ await validateConsumerFiles(entry, failures);
+ }
+
+ for (const [key, member] of discoveredByKey) {
+ if (!manifestByKey.has(key)) {
+ failures.push(`${key}: found in ${member.file}:${member.line} but missing from manifest.`);
+ }
+ }
+
+ if (failures.length > 0) {
+ console.error('Hotload boundary manifest check failed:\n');
+ for (const failure of failures) {
+ console.error(`- ${failure}`);
+ }
+ process.exit(1);
+ }
+
+ const coveredPairs = manifestByKey.size;
+ const sourceFiles = surfaceDefinitions.map(surface => toPosixPath(surface.file)).join(', ');
+ console.log(`Hotload boundary manifest covers ${coveredPairs} internal member surface(s) from ${sourceFiles}.`);
+
+ if (args.has('--markdown')) {
+ console.log('');
+ console.log(renderMarkdownTable(manifest.entries));
+ }
+}
+
+await main();