#!/usr/bin/env node import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const nativeShellPlanPath = 'docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md'; const hostBridgeProtocolDocPath = 'docs/【前端架构】宿主壳能力统一协议-2026-06-17.md'; const developmentWorkflowDocPath = 'docs/project-memory/shared-memory/development-workflow.md'; const decisionLogDocPath = 'docs/project-memory/shared-memory/decision-log.md'; const productionShellScanRoots = [ 'apps/mobile-shell', 'apps/desktop-shell', 'miniprogram', 'packages/shared/src/contracts/hostBridge.ts', 'src/services/host-bridge', ]; const h5HostBridgeFacadeModule = 'src/services/host-bridge/hostBridge'; const h5HostBridgeScannedFacadeImports = new Set([ 'canUseHostShareGrid', 'captureHostImageFile', 'exportHostAudioFile', 'exportHostImageFile', 'exportHostTextFile', 'getHostAppearanceColorScheme', 'getHostNetworkStatus', 'getNativeAppHostRuntime', 'importHostAudioFile', 'importHostDocumentFile', 'importHostImageFile', 'importHostTextFile', 'navigateHostNativePage', 'openHostExternalUrl', 'openHostShare', 'openHostShareGrid', 'readHostClipboardText', 'refreshNativeAppHostRuntime', 'reloadHostWebView', 'requestHostHapticsImpact', 'requestHostLogin', 'requestHostPayment', 'scanHostQrCode', 'setHostAppBadgeCount', 'setHostAppTitle', 'setHostShareTarget', 'showHostLocalNotification', 'subscribeHostAppLifecycle', 'subscribeHostImageDrop', 'subscribeHostNavigationCanGoBack', 'subscribeHostNetworkStatusChange', 'subscribeHostRuntimeChange', 'writeHostClipboardText', ]); const h5HostBridgeCallChainWrapperFiles = [ 'src/hooks/useHostLifecycleActive.ts', 'src/hooks/useHostNavigationCanGoBack.ts', 'src/hooks/useHostNetworkOnline.ts', 'src/components/platform-entry/platformProfileHostClipboard.ts', 'src/components/platform-entry/platformHostBridgeSync.ts', ]; const h5HostBridgeRequiredCallChainFiles = [ 'src/App.tsx', 'src/components/auth/AuthGate.tsx', 'src/components/bark-battle-creation/BarkBattleResultView.tsx', 'src/components/common/CreativeAudioInputPanel.tsx', 'src/components/common/CreativeImageInputPanel.tsx', 'src/components/common/PublishShareModal.tsx', 'src/components/common/publishShareCardImage.ts', 'src/components/creation-agent/CreationAgentWorkspace.tsx', 'src/components/match3d-runtime/Match3DRuntimeShell.tsx', 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', 'src/components/platform-entry/PlatformFeedbackView.tsx', 'src/components/platform-entry/PlatformProfilePrimitives.tsx', 'src/components/platform-entry/PlatformProfileQrScannerModal.tsx', 'src/components/platform-entry/PlatformProfileReferralModal.tsx', 'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx', 'src/components/platform-entry/usePlatformProfileCenterController.ts', 'src/components/puzzle-runtime/PuzzleRuntimeShell.tsx', 'src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx', 'src/components/rpg-entry/RpgEntryHomeView.tsx', 'src/components/square-hole-result/SquareHoleResultView.tsx', 'src/components/visual-novel-result/VisualNovelResultView.tsx', 'src/hooks/useBackgroundMusic.ts', 'src/hooks/useHostLifecycleActive.ts', 'src/hooks/useHostNavigationCanGoBack.ts', 'src/hooks/useHostNetworkOnline.ts', 'src/main.tsx', 'src/services/appTitle.ts', 'src/services/authService.ts', 'src/services/clipboard.ts', 'src/services/payment/paymentRedirect.ts', 'src/services/runtimeAudioFeedback.ts', 'src/services/wechatMiniProgramShareTarget.ts', 'src/services/wechatMiniProgramSubscribe.ts', 'src/services/wechatMiniProgramShareGrid.ts', ]; const h5HostBridgeEventSubscriptionFacades = [ { functionName: 'subscribeHostAppLifecycle', eventName: 'app.lifecycle', }, { functionName: 'subscribeHostNetworkStatusChange', eventName: 'network.statusChanged', }, { functionName: 'subscribeHostNavigationCanGoBack', eventName: 'navigation.canGoBack', }, { functionName: 'subscribeHostImageDrop', eventName: 'file.imageDropped', }, ]; const wechatCapabilityFlowContracts = [ { capability: 'auth.requestLogin', files: [ 'miniprogram/host-bridge/protocol.js', 'miniprogram/host-bridge/webView.js', 'miniprogram/shell/webView.js', 'miniprogram/pages/web-view/index.js', ], snippets: [ ['miniprogram/host-bridge/protocol.js', 'WECHAT_AUTH_PAGE_URL'], ['miniprogram/host-bridge/webView.js', 'resolveWebViewUrlFromRuntimeConfig'], ['miniprogram/shell/webView.js', 'shouldStartAuthFromQuery'], ['miniprogram/shell/webView.js', 'wx.login'], ['miniprogram/shell/webView.js', '/api/auth/wechat/miniprogram-login'], ['miniprogram/pages/web-view/index.js', 'createWechatWebViewPage'], ], tests: [ 'miniprogram/host-bridge/protocol.test.js', 'miniprogram/host-bridge/webView.test.js', 'miniprogram/shell/webView.test.js', 'scripts/miniprogram-web-view-auth.test.ts', ], }, { capability: 'payment.request', files: [ 'miniprogram/host-bridge/protocol.js', 'miniprogram/host-bridge/payment.js', 'miniprogram/shell/payment.js', 'miniprogram/pages/wechat-pay/index.js', ], snippets: [ ['miniprogram/host-bridge/protocol.js', 'WECHAT_PAY_PAGE_URL'], ['miniprogram/host-bridge/payment.js', 'requestWechatPayment'], ['miniprogram/host-bridge/payment.js', 'wx.requestPayment'], ['miniprogram/host-bridge/payment.js', 'wx.requestVirtualPayment'], ['miniprogram/shell/payment.js', 'createWechatPayPage'], ['miniprogram/shell/payment.js', 'notifyPreviousWebView'], ['miniprogram/pages/wechat-pay/index.js', 'createWechatPayPage'], ], tests: [ 'miniprogram/host-bridge/protocol.test.js', 'miniprogram/host-bridge/payment.test.js', 'miniprogram/shell/payment.test.js', 'scripts/miniprogram-web-view-auth.test.ts', ], }, { capability: 'share.setTarget', files: [ 'miniprogram/host-bridge/protocol.js', 'miniprogram/host-bridge/webView.js', 'miniprogram/shell/webView.js', ], snippets: [ ['miniprogram/host-bridge/protocol.js', 'WECHAT_SHARE_TARGET_MESSAGE_TYPE'], ['miniprogram/host-bridge/webView.js', 'resolveShareTargetFromWebViewMessage'], ['miniprogram/shell/webView.js', 'handleWebViewMessage'], ['miniprogram/shell/webView.js', '_currentShareTarget'], ], tests: [ 'miniprogram/host-bridge/protocol.test.js', 'miniprogram/host-bridge/webView.test.js', 'miniprogram/shell/webView.test.js', ], }, { capability: 'share.open', files: [ 'miniprogram/host-bridge/protocol.js', 'miniprogram/host-bridge/webView.js', 'miniprogram/host-bridge/shareGrid.js', 'miniprogram/shell/webView.js', 'miniprogram/shell/shareGrid.js', 'miniprogram/pages/share-grid/index.js', ], snippets: [ ['miniprogram/host-bridge/protocol.js', 'WECHAT_SHARE_GRID_PAGE_URL'], ['miniprogram/host-bridge/webView.js', 'buildWebViewSharePath'], ['miniprogram/host-bridge/shareGrid.js', 'buildShareGridTilePlan'], ['miniprogram/shell/webView.js', 'onShareAppMessage'], ['miniprogram/shell/webView.js', 'onShareTimeline'], ['miniprogram/shell/shareGrid.js', 'wx.saveImageToPhotosAlbum'], ['miniprogram/pages/share-grid/index.js', 'createWechatShareGridPage'], ], tests: [ 'miniprogram/host-bridge/protocol.test.js', 'miniprogram/host-bridge/webView.test.js', 'miniprogram/host-bridge/shareGrid.test.js', 'miniprogram/shell/webView.test.js', 'miniprogram/shell/shareGrid.test.js', ], }, ]; const expectedWechatHostBridgeFiles = [ 'dispatch.js', 'payment.js', 'payment.test.js', 'protocol.js', 'protocol.test.js', 'shareGrid.js', 'shareGrid.test.js', 'subscribeMessage.js', 'subscribeMessage.test.js', 'webView.js', 'webView.test.js', ]; const expectedWechatShellFiles = [ 'payment.js', 'payment.test.js', 'shareGrid.js', 'shareGrid.test.js', 'subscribeMessage.js', 'subscribeMessage.test.js', 'webView.js', 'webView.test.js', ]; const expectedWechatPageFilesByRoute = { 'share-grid': ['index.js', 'index.json', 'index.wxml', 'index.wxss'], 'subscribe-message': ['index.js', 'index.json', 'index.wxml', 'index.wxss'], 'web-view': [ 'index.js', 'index.json', 'index.style.test.js', 'index.wxml', 'index.wxss', ], 'wechat-pay': ['index.js', 'index.json', 'index.wxml', 'index.wxss'], }; const expectedMobileHostBridgeFiles = [ 'appearance.test.ts', 'appearance.ts', 'badge.test.ts', 'badge.ts', 'bridge.test.ts', 'bridge.ts', 'capabilities.test.ts', 'capabilities.ts', 'clipboard.test.ts', 'clipboard.ts', 'dispatch.test.ts', 'dispatch.ts', 'filePayloads.test.ts', 'filePayloads.ts', 'files.test.ts', 'files.ts', 'haptics.test.ts', 'haptics.ts', 'navigation.test.ts', 'navigation.ts', 'network.test.ts', 'network.ts', 'notifications.test.ts', 'notifications.ts', 'protocol.test.ts', 'protocol.ts', 'runtime.test.ts', 'runtime.ts', 'scanner.test.ts', 'scanner.ts', 'share.test.ts', 'share.ts', ]; const expectedMobileSrcRootEntries = [ 'dir:host-bridge', 'dir:shell', 'file:env.d.ts', ]; const expectedMobileShellFiles = [ 'QrScannerOverlay.test.tsx', 'QrScannerOverlay.tsx', 'ShellApp.test.tsx', 'ShellApp.tsx', 'deepLink.test.ts', 'deepLink.ts', 'lifecycle.test.ts', 'lifecycle.ts', 'loadFailure.test.ts', 'loadFailure.ts', 'navigation.test.ts', 'navigation.ts', 'network.test.ts', 'network.ts', 'runtime.test.ts', 'runtime.ts', 'safeArea.test.ts', 'safeArea.ts', 'url.test.ts', 'url.ts', 'webViewGlobals.d.ts', 'webViewHistory.test.ts', 'webViewHistory.ts', 'webViewPolicy.test.ts', 'webViewPolicy.ts', ]; const expectedDesktopHostBridgeRustFiles = [ 'appearance.rs', 'badge.rs', 'capabilities.rs', 'clipboard.rs', 'dispatch.rs', 'file_payloads.rs', 'files.rs', 'mod.rs', 'navigation.rs', 'network.rs', 'notifications.rs', 'protocol.rs', 'runtime.rs', 'share.rs', 'title.rs', ]; const expectedDesktopShellRustFiles = [ 'deep_link.rs', 'events.rs', 'file_drop.rs', 'lifecycle.rs', 'menu.rs', 'mod.rs', 'navigation.rs', 'network.rs', 'runtime.rs', 'tray.rs', 'url.rs', 'webview.rs', 'window_state.rs', ]; const documentedShellLayerGroups = [ { label: 'wechat host bridge files', files: expectedWechatHostBridgeFiles .filter((fileName) => !fileName.includes('.test.')) .map((fileName) => `miniprogram/host-bridge/${fileName}`), }, { label: 'wechat shell files', files: expectedWechatShellFiles .filter((fileName) => !fileName.includes('.test.')) .map((fileName) => `miniprogram/shell/${fileName}`), }, { label: 'wechat page wrapper files', files: Object.entries(expectedWechatPageFilesByRoute).flatMap( ([route, files]) => files .filter((fileName) => !fileName.includes('.test.')) .map((fileName) => `miniprogram/pages/${route}/${fileName}`), ), }, { label: 'mobile src root entries', files: ['apps/mobile-shell/src/env.d.ts'], }, { label: 'mobile host bridge files', files: expectedMobileHostBridgeFiles .filter((fileName) => !fileName.includes('.test.')) .map((fileName) => `apps/mobile-shell/src/host-bridge/${fileName}`), }, { label: 'mobile shell files', files: expectedMobileShellFiles .filter((fileName) => !fileName.includes('.test.')) .map((fileName) => `apps/mobile-shell/src/shell/${fileName}`), }, { label: 'desktop entrypoint files', files: [ 'apps/desktop-shell/src-tauri/src/app.rs', 'apps/desktop-shell/src-tauri/src/main.rs', ], }, { label: 'desktop host bridge files', files: expectedDesktopHostBridgeRustFiles.map( (fileName) => `apps/desktop-shell/src-tauri/src/host_bridge/${fileName}`, ), }, { label: 'desktop shell files', files: expectedDesktopShellRustFiles.map( (fileName) => `apps/desktop-shell/src-tauri/src/shell/${fileName}`, ), }, ]; const capabilityListMarkers = { desktop: '桌面壳当前真实能力完整清单为', desktopCurrentState: '当前真实能力为', mobile: '移动壳当前通用真实能力完整清单为', mobileCurrentState: '首轮真实能力包括', mobileIosExtra: '移动壳 iOS 额外真实能力为', wechat: '微信小程序壳当前真实能力完整清单为', }; const sharedHostBridgeContractPath = 'packages/shared/src/contracts/hostBridge.ts'; const productionShellExtensions = new Set([ '.js', '.json', '.mjs', '.plist', '.rs', '.toml', '.ts', '.tsx', '.wxml', '.wxss', ]); const h5ProductionSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']); const productionShellExcludedSegments = new Set([ '.expo', '.expo-export-smoke', 'node_modules', 'target', 'test-utils', ]); const productionShellExcludedPaths = new Set([ 'apps/desktop-shell/src-tauri/gen', 'apps/desktop-shell/src-tauri/permissions/autogenerated', ]); const productionShellDevScaffoldTerms = [ 'mo' + 'ck', 'fa' + 'ke', 'place' + 'holder', 'st' + 'ub', 'TO' + 'DO', 'FIX' + 'ME', '占' + '位', '模' + '拟', '伪' + '造', '未' + '实现', '临' + '时', ]; const h5HostBridgeCallChainDevScaffoldTerms = [ 'mo' + 'ck', 'fa' + 'ke', 'st' + 'ub', 'TO' + 'DO', 'FIX' + 'ME', '模' + '拟', '伪' + '造', ]; const h5HostBridgeTests = [ 'packages/shared/src/contracts/hostBridge.test.ts', 'src/services/host-bridge/hostBridge.test.ts', 'src/services/host-bridge/nativeAppHostBridge.test.ts', 'src/App.test.tsx', 'src/components/auth/AuthGate.test.tsx', 'src/hooks/useHostNavigationCanGoBack.test.tsx', 'src/components/bark-battle-creation/BarkBattleResultView.test.tsx', 'src/components/common/CreativeAudioInputPanel.test.tsx', 'src/components/common/PublishShareModal.test.tsx', 'src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx', 'src/components/creation-agent/CreationAgentWorkspace.test.tsx', 'src/components/visual-novel-result/VisualNovelResultView.test.tsx', 'src/components/platform-entry/platformDraftGenerationShelfModel.test.ts', 'src/components/platform-entry/platformHostBridgeSync.test.ts', 'src/components/platform-entry/platformHostNotificationModel.test.ts', 'src/services/runtimeAudioFeedback.test.ts', 'src/services/clipboard.test.ts', 'src/services/appTitle.test.ts', ]; const h5PlatformHostBridgeIntegrationTest = 'native app jump hop draft completion sends host notification and badge from platform shell'; const wechatShellTests = [ 'miniprogram/host-bridge/protocol.test.js', 'miniprogram/host-bridge/webView.test.js', 'miniprogram/host-bridge/payment.test.js', 'miniprogram/host-bridge/shareGrid.test.js', 'miniprogram/host-bridge/subscribeMessage.test.js', 'miniprogram/shell/webView.test.js', 'miniprogram/shell/payment.test.js', 'miniprogram/shell/shareGrid.test.js', 'miniprogram/shell/subscribeMessage.test.js', 'miniprogram/pages/web-view/index.style.test.js', 'scripts/miniprogram-web-view-auth.test.ts', ]; const steps = [ { label: 'h5-host-bridge-tests', command: npmCommand, args: ['run', 'test', '--', ...h5HostBridgeTests], }, { label: 'h5-platform-host-bridge-integration', command: npmCommand, args: [ 'run', 'test', '--', 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', '-t', h5PlatformHostBridgeIntegrationTest, ], }, { label: 'wechat-shell-tests', command: npmCommand, args: ['run', 'test', '--', ...wechatShellTests], }, { label: 'mobile-shell-typecheck', command: npmCommand, args: ['run', 'mobile-shell:typecheck'], }, { label: 'mobile-shell-test', command: npmCommand, args: ['run', 'mobile-shell:test'], }, { label: 'mobile-shell-eas-build-config-smoke', command: npmCommand, args: ['run', 'mobile-shell:build-config'], }, { label: 'mobile-shell-expo-config-smoke', command: npmCommand, args: ['run', 'mobile-shell:config'], }, { label: 'mobile-shell-expo-export-smoke', command: npmCommand, args: ['run', 'mobile-shell:export'], }, { label: 'desktop-shell-typecheck', command: npmCommand, args: ['run', 'desktop-shell:typecheck'], }, { label: 'desktop-shell-test', command: npmCommand, args: ['run', 'desktop-shell:test'], }, { label: 'desktop-shell-release-build-smoke', command: npmCommand, args: ['run', 'desktop-shell:build', '--', '--no-bundle'], }, ]; function shouldScanProductionShellFile(filePath) { const normalizedPath = filePath.split(path.sep).join('/'); if ( normalizedPath.includes('.test.') || normalizedPath.endsWith('/scripts/check-config.mjs') ) { return false; } return productionShellExtensions.has(path.extname(filePath)); } function shouldScanH5ProductionSourceFile(filePath) { const normalizedPath = filePath.split(path.sep).join('/'); if ( normalizedPath.includes('.test.') || normalizedPath.includes('/test-utils/') || normalizedPath.includes('/services/host-bridge/') ) { return false; } return h5ProductionSourceExtensions.has(path.extname(filePath)); } function collectProductionShellFiles(entryPath) { const normalizedPath = entryPath.split(path.sep).join('/'); if (productionShellExcludedPaths.has(normalizedPath)) { return []; } if (!fs.existsSync(entryPath)) { throw new Error(`production shell scan path does not exist: ${entryPath}`); } const stats = fs.statSync(entryPath); if (stats.isDirectory()) { const name = path.basename(entryPath); if (productionShellExcludedSegments.has(name)) { return []; } return fs .readdirSync(entryPath, { withFileTypes: true }) .flatMap((entry) => collectProductionShellFiles(path.join(entryPath, entry.name))); } return shouldScanProductionShellFile(entryPath) ? [entryPath] : []; } function collectFiles(entryPath, shouldIncludeFile) { if (!fs.existsSync(entryPath)) { throw new Error(`scan path does not exist: ${entryPath}`); } const stats = fs.statSync(entryPath); if (stats.isDirectory()) { const name = path.basename(entryPath); if (productionShellExcludedSegments.has(name)) { return []; } return fs .readdirSync(entryPath, { withFileTypes: true }) .flatMap((entry) => collectFiles(path.join(entryPath, entry.name), shouldIncludeFile)); } return shouldIncludeFile(entryPath) ? [entryPath] : []; } function normalizeModulePath(modulePath) { return modulePath.split(path.sep).join('/').replace(/\.(jsx?|tsx?)$/, ''); } function importedModulePath(fromFile, specifier) { if (specifier === '@') { return '.'; } if (specifier.startsWith('@/')) { return normalizeModulePath(specifier.slice(2)); } if (!specifier.startsWith('.')) { return specifier; } return normalizeModulePath(path.normalize(path.join(path.dirname(fromFile), specifier))); } function extractImportSpecifiers(source) { const specifiers = []; for (const match of source.matchAll(/import\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]/g)) { specifiers.push(match[1]); } for (const match of source.matchAll(/export\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s+['"]([^'"]+)['"]/g)) { specifiers.push(match[1]); } for (const match of source.matchAll(/import\s*['"]([^'"]+)['"]/g)) { specifiers.push(match[1]); } return specifiers; } function extractNamedImportsFromModule(source, fromFile, targetModule) { const importedNames = new Set(); const importPattern = /import\s+(?:type\s+)?(?:\{([\s\S]*?)\}|[A-Za-z0-9_$]+\s*,\s*\{([\s\S]*?)\})\s+from\s+['"]([^'"]+)['"]/g; const exportPattern = /export\s+(?:type\s+)?\{([\s\S]*?)\}\s+from\s+['"]([^'"]+)['"]/g; const collectNamedImports = (importBody) => { for (const rawImport of importBody.split(',')) { const cleanedImport = rawImport .replace(/\btype\s+/g, '') .trim(); if (!cleanedImport) { continue; } importedNames.add(cleanedImport.split(/\s+as\s+/)[0]?.trim() ?? ''); } }; for (const match of source.matchAll(importPattern)) { if (importedModulePath(fromFile, match[3]) !== targetModule) { continue; } collectNamedImports(match[1] ?? match[2] ?? ''); } for (const match of source.matchAll(exportPattern)) { if (importedModulePath(fromFile, match[2]) !== targetModule) { continue; } collectNamedImports(match[1] ?? ''); } return importedNames; } function collectH5HostBridgeCallChainFiles() { const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile); const wrapperModules = new Set( h5HostBridgeCallChainWrapperFiles.map(normalizeModulePath), ); const scannedFiles = new Set(); for (const file of sourceFiles) { const source = fs.readFileSync(file, 'utf8'); const imports = extractImportSpecifiers(source).map((specifier) => importedModulePath(file, specifier), ); const facadeImports = extractNamedImportsFromModule( source, file, h5HostBridgeFacadeModule, ); const importsScannedFacadeCapability = [...facadeImports].some((importName) => h5HostBridgeScannedFacadeImports.has(importName), ); if ( importsScannedFacadeCapability || imports.some((specifier) => wrapperModules.has(specifier)) ) { scannedFiles.add(file); } } for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) { scannedFiles.add(wrapperFile); } const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter( (file) => !scannedFiles.has(file), ); if (missingRequiredFiles.length > 0) { throw new Error( `H5 HostBridge call chain scan is missing required files: ${missingRequiredFiles.join(', ')}`, ); } return [...scannedFiles].sort(); } function assertNoDevScaffoldTermsInFiles(files, terms) { for (const file of files) { const source = fs.readFileSync(file, 'utf8'); const lowerSource = source.toLowerCase(); for (const term of terms) { const matchIndex = lowerSource.indexOf(term.toLowerCase()); if (matchIndex === -1) { continue; } const line = source.slice(0, matchIndex).split('\n').length; throw new Error( `production native shell source must not include ${term}: ${file}:${line}`, ); } } } function assertNoProductionShellDevScaffoldTerms() { assertNoDevScaffoldTermsInFiles( productionShellScanRoots.flatMap(collectProductionShellFiles), productionShellDevScaffoldTerms, ); assertNoDevScaffoldTermsInFiles( collectH5HostBridgeCallChainFiles().flatMap(collectProductionShellFiles), h5HostBridgeCallChainDevScaffoldTerms, ); } function assertSameList(actual, expected, label) { if ( actual.length !== expected.length || actual.some((value, index) => value !== expected[index]) ) { throw new Error( `${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`, ); } } function assertSameSet(actual, expected, label) { const sortedActual = [...actual].sort(); const sortedExpected = [...expected].sort(); assertSameList(sortedActual, sortedExpected, label); } function readDirectoryEntryList(directory, label) { const entries = fs .readdirSync(directory, { withFileTypes: true }) .map((entry) => { if (entry.isFile()) { return `file:${entry.name}`; } if (entry.isDirectory()) { return `dir:${entry.name}`; } throw new Error(`${label} must not contain special entries: ${entry.name}`); }) .sort(); if (entries.length === 0) { throw new Error(`${label} must not be empty`); } return entries; } function readDirectoryFileList(directory, label) { const files = []; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { if (!entry.isFile()) { throw new Error(`${label} must not contain nested entries: ${entry.name}`); } files.push(entry.name); } return files.sort(); } function readDirectoryNameList(directory, label) { const directories = []; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { if (!entry.isDirectory()) { throw new Error(`${label} must not contain root files: ${entry.name}`); } directories.push(entry.name); } return directories.sort(); } function assertShellLayerLayoutDocumented(source, label) { for (const group of documentedShellLayerGroups) { for (const fileName of group.files) { if (!source.includes(fileName)) { throw new Error(`${label} missing ${group.label}: ${fileName}`); } } } } function extractTsStringArray(source, exportName, seen = new Set()) { if (seen.has(exportName)) { throw new Error(`cyclic string array export ${exportName}`); } const match = source.match( new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), ); if (!match) { throw new Error(`unable to read ${exportName}`); } const nextSeen = new Set(seen); nextSeen.add(exportName); const entries = []; for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) { if (entry[1]) { entries.push(...extractTsStringArray(source, entry[1], nextSeen)); } else { entries.push(entry[2]); } } return entries; } function extractRustCapabilities(source) { const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/); if (!match) { throw new Error('unable to read desktop shell capabilities'); } return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); } function extractRustStringArray(source, constName) { const match = source.match( new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`), ); if (!match) { throw new Error(`unable to read Rust ${constName}`); } return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); } function extractStringConst(source, constName) { const match = source.match( new RegExp(`const ${constName}\\s*=\\s*['"]([^'"]+)['"];`), ); if (!match) { throw new Error(`unable to read string const ${constName}`); } return match[1]; } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function extractFunctionSource(source, functionName) { const declarationStart = source.indexOf(`function ${functionName}`); const exportedDeclarationStart = source.indexOf(`export function ${functionName}`); const start = exportedDeclarationStart === -1 ? declarationStart : exportedDeclarationStart; if (start === -1) { throw new Error(`unable to read function ${functionName}`); } const openBrace = source.indexOf('{', start); if (openBrace === -1) { throw new Error(`unable to read function body ${functionName}`); } let depth = 0; for (let index = openBrace; index < source.length; index += 1) { if (source[index] === '{') { depth += 1; } else if (source[index] === '}') { depth -= 1; if (depth === 0) { return source.slice(start, index + 1); } } } throw new Error(`unterminated function body ${functionName}`); } function extractTsStringObject(source, exportName) { const match = source.match( new RegExp(`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`), ); if (!match) { throw new Error(`unable to read ${exportName}`); } return Object.fromEntries( [...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [ entry[1], entry[2], ]), ); } function assertH5HostBridgeEventSubscriptionGates() { const sharedContractSource = fs.readFileSync( sharedHostBridgeContractPath, 'utf8', ); const h5HostBridgeSource = fs.readFileSync( 'src/services/host-bridge/hostBridge.ts', 'utf8', ); const sharedEvents = extractTsStringArray( sharedContractSource, 'HOST_BRIDGE_EVENTS', ); assertSameList( h5HostBridgeEventSubscriptionFacades.map((entry) => entry.eventName), sharedEvents, 'H5 HostBridge event subscription facade coverage', ); const helperSource = extractFunctionSource( h5HostBridgeSource, 'canUseNativeHostEventCapability', ); if ( !helperSource.includes("canUseNativeHostCapability('host.events')") || !helperSource.includes('canUseNativeHostCapability(capability)') ) { throw new Error( 'H5 HostBridge event capability helper must require host.events and the event capability', ); } const subscribedEvents = [ ...h5HostBridgeSource.matchAll( /subscribeNativeAppHostBridgeEvent(?:<[^>]+>)?\(\s*['"]([^'"]+)['"]/g, ), ].map((entry) => entry[1]); assertSameList( subscribedEvents, sharedEvents, 'H5 HostBridge subscribed event list', ); for (const { functionName, eventName } of h5HostBridgeEventSubscriptionFacades) { const functionSource = extractFunctionSource(h5HostBridgeSource, functionName); if ( !functionSource.includes(`canUseNativeHostEventCapability('${eventName}')`) ) { throw new Error( `${functionName} must gate ${eventName} with host.events and the event capability`, ); } const directCapabilityPattern = new RegExp( `canUseNativeHostCapability\\('${escapeRegExp(eventName)}'\\)`, ); if (directCapabilityPattern.test(functionSource)) { throw new Error( `${functionName} must not bypass canUseNativeHostEventCapability for ${eventName}`, ); } } } function assertH5HostBridgePayloadBoundaries() { const sharedContractSource = fs.readFileSync( 'packages/shared/src/contracts/hostBridge.ts', 'utf8', ); const h5HostBridgeSource = fs.readFileSync( 'src/services/host-bridge/hostBridge.ts', 'utf8', ); const nativeRequestCapabilities = extractTsStringArray( sharedContractSource, 'HOST_BRIDGE_METHODS', ).filter((method) => method !== 'host.getRuntime'); for (const sharedBoundary of [ 'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', 'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', 'HOST_BRIDGE_APP_TITLE_MAX_LENGTH', ]) { if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) { throw new Error( `H5 HostBridge facade must import shared HostBridge boundary ${sharedBoundary}`, ); } if (new RegExp(`const ${sharedBoundary}\\s*=\\s*new Set`).test(h5HostBridgeSource)) { throw new Error( `H5 HostBridge facade must not redeclare shared payload boundary ${sharedBoundary}`, ); } } for (const mimeLiteral of [ "'text/plain'", "'text/markdown'", "'text/csv'", "'application/json'", "'image/png'", "'image/jpeg'", "'image/webp'", "'audio/mpeg'", "'audio/mp4'", "'audio/wav'", "'audio/ogg'", "'audio/webm'", ]) { if (h5HostBridgeSource.includes(mimeLiteral)) { throw new Error( `H5 HostBridge facade must read MIME boundaries from shared contract instead of ${mimeLiteral}`, ); } } if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) { throw new Error( 'H5 HostBridge facade must use shared HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', ); } if (h5HostBridgeSource.includes('timeoutMs: 30000')) { throw new Error( 'H5 HostBridge facade must use shared HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', ); } if (h5HostBridgeSource.includes('timeoutMs: 60000')) { throw new Error( 'H5 HostBridge facade must use shared HOST_BRIDGE_SCANNER_TIMEOUT_MS', ); } if ( !h5HostBridgeSource.includes( 'return normalizeHostBridgeExternalUrlPayload(trimmedUrl);', ) || !h5HostBridgeSource.includes( 'return normalizeHostBridgeExternalUrlPayload(\n new URL(trimmedUrl, window.location.origin).toString(),\n );', ) || !h5HostBridgeSource.includes( 'const normalizedPayload = normalizeHostExternalUrlPayload(url);', ) || !h5HostBridgeSource.includes( "return await requestNativeHostBoolean(\n 'app.openExternalUrl',\n normalizedPayload,\n );", ) ) { throw new Error( 'H5 HostBridge facade must normalize app.openExternalUrl payloads with the shared external URL boundary', ); } if ( !h5HostBridgeSource.includes('function normalizeNativeAppPageUrl(url: string)') || !h5HostBridgeSource.includes("trimmedUrl.startsWith('//')") || !h5HostBridgeSource.includes( "nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN", ) || !h5HostBridgeSource.includes('const normalizedUrl = normalizeNativeAppPageUrl(url);') || !h5HostBridgeSource.includes("{ url: normalizedUrl },") ) { throw new Error( 'H5 HostBridge facade must reject unsafe native app navigation targets before sending navigation.openNativePage', ); } if ( !h5HostBridgeSource.includes( 'absolutizeHostSharePayloadUrls(params),', ) || !h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") || !h5HostBridgeSource.includes( "return await requestNativeHostBoolean(\n 'share.open',\n normalizedPayload.payload,\n );", ) ) { throw new Error( 'H5 HostBridge facade must normalize share.open payloads with the shared share boundary', ); } if ( !h5HostBridgeSource.includes( 'const normalizedPayload = normalizeHostBridgeShareOpenPayload(message);', ) || !h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") || !h5HostBridgeSource.includes("'share.setTarget', {\n target: message,") ) { throw new Error( 'H5 HostBridge facade must validate native share.setTarget payloads before sending them to native shells', ); } if ( !h5HostBridgeSource.includes( 'const normalizedPayload = normalizeHostBridgeExportTextPayload(params);', ) || !h5HostBridgeSource.includes( "'file.exportText',\n normalizedPayload,", ) ) { throw new Error( 'H5 HostBridge facade must normalize file.exportText payloads with the shared text export boundary', ); } if ( !h5HostBridgeSource.includes( 'const normalizedPayload = normalizeHostBridgeExportImagePayload(params);', ) || !h5HostBridgeSource.includes( "'file.exportImage',\n normalizedPayload,", ) ) { throw new Error( 'H5 HostBridge facade must normalize file.exportImage payloads with the shared image export boundary', ); } if ( !h5HostBridgeSource.includes( 'const normalizedPayload = normalizeHostBridgeExportAudioPayload(params);', ) || !h5HostBridgeSource.includes( "'file.exportAudio',\n normalizedPayload,", ) ) { throw new Error( 'H5 HostBridge facade must normalize file.exportAudio payloads with the shared audio export boundary', ); } if ( !h5HostBridgeSource.includes( 'const clipboardText = normalizeHostBridgeClipboardText(text);', ) || !h5HostBridgeSource.includes( "return await requestNativeHostBoolean('clipboard.writeText', clipboardText);", ) ) { throw new Error( 'H5 HostBridge facade must normalize clipboard.writeText payloads with the shared clipboard boundary', ); } if ( !h5HostBridgeSource.includes( 'const clipboardText = normalizeHostBridgeClipboardText(', ) || !h5HostBridgeSource.includes( "await requestNativeAppHostBridge(\n 'clipboard.readText',", ) || !h5HostBridgeSource.includes( 'return clipboardText ?? false;', ) ) { throw new Error( 'H5 HostBridge facade must normalize clipboard.readText results with the shared clipboard boundary', ); } if ( !h5HostBridgeSource.includes( 'const style = normalizeHostBridgeHapticsImpactStyle(params.style);', ) || !h5HostBridgeSource.includes( "return await requestNativeHostBoolean('haptics.impact', { style });", ) ) { throw new Error( 'H5 HostBridge facade must normalize haptics.impact payloads with the shared style boundary', ); } if ( !h5HostBridgeSource.includes( 'colorScheme: normalizeHostBridgeColorScheme(result?.colorScheme),', ) ) { throw new Error( 'H5 HostBridge facade must normalize appearance.getColorScheme results with the shared color scheme boundary', ); } if ( !h5HostBridgeSource.includes( 'const connectionType = normalizeHostBridgeConnectionType(\n payload?.connectionType ?? payload?.nativeType,\n );', ) || !h5HostBridgeSource.includes( 'listener(normalizeHostNetworkStatus(payload));', ) ) { throw new Error( 'H5 HostBridge facade must normalize network.status results and network.statusChanged events with shared network boundaries', ); } if ( !h5HostBridgeSource.includes( 'const normalizedTitle = normalizeHostBridgeAppTitle(title);', ) || !h5HostBridgeSource.includes( "return await requestNativeHostBoolean('app.setTitle', normalizedTitle);", ) ) { throw new Error( 'H5 HostBridge facade must normalize app.setTitle payloads with the shared title boundary', ); } for (const nativeCapability of nativeRequestCapabilities) { if ( h5HostBridgeSource.includes( `runtime.hostCapabilities.includes('${nativeCapability}')`, ) || h5HostBridgeSource.includes( `runtime.hostCapabilities.includes("${nativeCapability}")`, ) ) { throw new Error( `H5 HostBridge facade must gate ${nativeCapability} through canUseNativeHostCapability()`, ); } if ( !h5HostBridgeSource.includes( `canUseNativeHostCapability('${nativeCapability}')`, ) ) { throw new Error( `H5 HostBridge facade must check ${nativeCapability} with canUseNativeHostCapability()`, ); } } for (const importBoundary of [ { functionName: 'importHostTextFile', normalizer: 'normalizeHostBridgeImportTextResult', preserveCancellation: true, }, { functionName: 'importHostDocumentFile', normalizer: 'normalizeHostBridgeImportDocumentResult', preserveCancellation: true, }, { functionName: 'importHostImageFile', normalizer: 'normalizeHostBridgeImportImageResult', preserveCancellation: true, }, { functionName: 'captureHostImageFile', normalizer: 'normalizeHostBridgeImportImageResult', preserveCancellation: true, }, { functionName: 'importHostAudioFile', normalizer: 'normalizeHostBridgeImportAudioResult', preserveCancellation: true, }, { functionName: 'subscribeHostImageDrop', normalizer: 'normalizeHostBridgeImportImageResult', preserveCancellation: false, }, ]) { const functionSource = extractFunctionSource( h5HostBridgeSource, importBoundary.functionName, ); if (!functionSource.includes(`${importBoundary.normalizer}(`)) { throw new Error( `${importBoundary.functionName} must normalize imported file results with shared ${importBoundary.normalizer}`, ); } if ( importBoundary.preserveCancellation && (!functionSource.includes('isCancelledHostBridgeError(error)') || !functionSource.includes('return null;')) ) { throw new Error( `${importBoundary.functionName} must preserve native user cancellation as null`, ); } } const unsupportedHelperSource = extractFunctionSource( h5HostBridgeSource, 'isUnsupportedHostBridgeError', ); if (unsupportedHelperSource.includes("'cancelled'")) { throw new Error( 'H5 HostBridge facade must not treat native user cancellation as unsupported capability', ); } for (const localMimeSet of [ 'HOST_BRIDGE_TEXT_MIME_TYPE_SET', 'HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET', 'HOST_BRIDGE_IMAGE_MIME_TYPE_SET', 'HOST_BRIDGE_AUDIO_MIME_TYPE_SET', ]) { if (h5HostBridgeSource.includes(localMimeSet)) { throw new Error( `H5 HostBridge facade must use shared import normalizers instead of local ${localMimeSet}`, ); } } } function assertH5NativeAppTransportTimeoutBoundaries() { const nativeAppHostBridgeSource = fs.readFileSync( 'src/services/host-bridge/nativeAppHostBridge.ts', 'utf8', ); for (const sharedBoundary of [ 'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS', 'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS', ]) { if (!nativeAppHostBridgeSource.includes(`${sharedBoundary},`)) { throw new Error( `H5 native app transport must import shared timeout boundary ${sharedBoundary}`, ); } } for (const staleTimeoutBoundary of [ 'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS', 'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS', ]) { if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) { throw new Error( `H5 native app transport must not redeclare timeout boundary ${staleTimeoutBoundary}`, ); } } } function extractDocumentCapabilityList(source, marker) { return extractDocumentCapabilityListBefore(source, marker, '。'); } function extractDocumentCapabilityListBefore(source, marker, terminator) { const markerIndex = source.indexOf(marker); if (markerIndex === -1) { throw new Error(`native shell plan missing ${marker}`); } const sentenceEnd = source.indexOf(terminator, markerIndex); const sentence = source.slice( markerIndex, sentenceEnd === -1 ? undefined : sentenceEnd, ); return [...sentence.matchAll(/`([^`]+)`/g)].map((entry) => entry[1]); } function extractDocumentMethodTable(source) { const start = source.indexOf('首批 method:'); if (start === -1) { throw new Error('native shell plan missing HostBridge method table'); } const end = source.indexOf('每个 method 都必须', start); if (end === -1) { throw new Error('native shell plan method table missing end marker'); } return [...source.slice(start, end).matchAll(/^\| `([^`]+)` \|/gm)].map( (entry) => entry[1], ); } function assertNativeShellScaffoldScanWording(source, label) { if ( source.includes('三端生产壳临时替身词扫描') || source.includes('三端壳生产源码') || source.includes('壳生产源码禁替身') || !source.includes('H5 HostBridge 真实调用链的临时替身词扫描') ) { throw new Error( `${label} must document production scaffold scanning for the H5 HostBridge call chain`, ); } } function assertNativeShellCapabilityPlan() { const planSource = fs.readFileSync(nativeShellPlanPath, 'utf8'); const hostBridgeProtocolDocSource = fs.readFileSync( hostBridgeProtocolDocPath, 'utf8', ); const developmentWorkflowDocSource = fs.readFileSync( developmentWorkflowDocPath, 'utf8', ); const decisionLogDocSource = fs.readFileSync(decisionLogDocPath, 'utf8'); if ( planSource.includes('permissions` 必须只包含 `core:default`') || planSource.includes('permissions=["core:default","allow-host-bridge-request"]') ) { throw new Error( 'native shell plan must not document core:default as a desktop capability permission', ); } if (!planSource.includes('主窗口 capability 只授予 `allow-host-bridge-request`')) { throw new Error( 'native shell plan must document the minimal desktop capability permission', ); } for (const staleMobilePermissionText of [ 'Android `permissions` 不手写显式权限', '最终 Expo public config 只允许扫码能力由 `expo-camera` plugin 带入 `android.permission.CAMERA`', '移动拍摄不请求麦克风权限', ]) { if ( planSource.includes(staleMobilePermissionText) || hostBridgeProtocolDocSource.includes(staleMobilePermissionText) ) { throw new Error( `native shell docs must not keep stale mobile permission wording: ${staleMobilePermissionText}`, ); } } assertNativeShellScaffoldScanWording(planSource, 'native shell plan'); assertNativeShellScaffoldScanWording( hostBridgeProtocolDocSource, 'HostBridge protocol document', ); assertNativeShellScaffoldScanWording( developmentWorkflowDocSource, 'development workflow document', ); assertNativeShellScaffoldScanWording( decisionLogDocSource, 'decision log document', ); assertShellLayerLayoutDocumented(planSource, 'native shell plan'); assertShellLayerLayoutDocumented( hostBridgeProtocolDocSource, 'HostBridge protocol document', ); const desktopCapabilitySource = fs.readFileSync( 'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs', 'utf8', ); const sharedContractSource = fs.readFileSync(sharedHostBridgeContractPath, 'utf8'); const sharedMethods = extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_METHODS'); const sharedEvents = extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_EVENTS'); const sharedWechatCapabilities = extractTsStringArray( sharedContractSource, 'HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES', ); const mobileCapabilities = extractTsStringArray( sharedContractSource, 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', ); const iosMobileCapabilities = extractTsStringArray( sharedContractSource, 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', ); const iosExtraCapabilities = iosMobileCapabilities.filter( (capability) => !mobileCapabilities.includes(capability), ); const sharedDesktopCapabilities = extractTsStringArray( sharedContractSource, 'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES', ); const desktopCapabilities = extractRustCapabilities(desktopCapabilitySource); const desktopEventSource = fs.readFileSync( 'apps/desktop-shell/src-tauri/src/shell/events.rs', 'utf8', ); const wechatProtocol = requireCommonJsModule( 'miniprogram/host-bridge/protocol.js', ); assertSameList( wechatProtocol.WECHAT_HOST_CAPABILITIES ?? [], sharedWechatCapabilities, 'wechat mini program runtime capability profile', ); assertSameList( extractRustStringArray(desktopEventSource, 'HOST_BRIDGE_EVENTS'), sharedEvents, 'desktop shell runtime event whitelist', ); for (const eventName of sharedEvents) { if (!sharedDesktopCapabilities.includes(eventName)) { throw new Error(`shared HostBridge event must be in desktop capability profile: ${eventName}`); } } assertSameList( desktopCapabilities, sharedDesktopCapabilities, 'desktop shell runtime capability profile', ); assertSameList( extractDocumentMethodTable(planSource), [ ...sharedMethods.slice(0, 8), 'app.lifecycle', 'navigation.canGoBack', ...sharedMethods.slice(8, 12), 'network.statusChanged', ...sharedMethods.slice(12, 22), sharedMethods[22], 'file.imageDropped', ...sharedMethods.slice(23), ], 'native shell documented method table', ); assertSameList( extractDocumentCapabilityList(planSource, capabilityListMarkers.wechat), sharedWechatCapabilities, 'wechat mini program documented capabilities', ); assertSameList( extractDocumentCapabilityList(planSource, capabilityListMarkers.mobile), mobileCapabilities, 'mobile shell documented common capabilities', ); assertSameSet( extractDocumentCapabilityListBefore( planSource, capabilityListMarkers.mobileCurrentState, ';', ).filter((capability) => capability !== 'Android 返回键回退'), mobileCapabilities, 'mobile shell current state documented capabilities', ); assertSameList( extractDocumentCapabilityList(planSource, capabilityListMarkers.mobileIosExtra), iosExtraCapabilities, 'mobile shell documented iOS extra capabilities', ); assertSameList( extractDocumentCapabilityList(planSource, capabilityListMarkers.desktop), desktopCapabilities, 'desktop shell documented capabilities', ); assertSameSet( extractDocumentCapabilityListBefore( planSource, capabilityListMarkers.desktopCurrentState, ';', ), desktopCapabilities, 'desktop shell current state documented capabilities', ); } function assertExternalUrlProtocolParity() { const sharedContractSource = fs.readFileSync( sharedHostBridgeContractPath, 'utf8', ); const desktopNavigationSource = fs.readFileSync( 'apps/desktop-shell/src-tauri/src/shell/navigation.rs', 'utf8', ); assertSameList( extractRustStringArray(desktopNavigationSource, 'EXTERNAL_URL_PROTOCOLS'), extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS'), 'desktop shell external URL protocol list', ); } function requireCommonJsModule(modulePath) { const absolutePath = path.resolve(modulePath); const module = { exports: {} }; const sandbox = { module, exports: module.exports, URL, URLSearchParams, }; vm.runInNewContext(fs.readFileSync(absolutePath, 'utf8'), sandbox, { filename: absolutePath, }); return module.exports; } function pageRouteFromMiniProgramUrl(url) { return String(url).split('?')[0].replace(/^\//, ''); } function assertHttpsDomain(value, label) { const trimmed = String(value ?? '').trim(); let url; try { url = new URL(trimmed); } catch { throw new Error(`${label} must be a valid HTTPS domain`); } if ( url.protocol !== 'https:' || url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash || (trimmed !== url.origin && trimmed !== `${url.origin}/`) || url.hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/u.test(url.hostname) || url.hostname.includes(':') || !url.hostname.includes('.') ) { throw new Error(`${label} must be an HTTPS domain without path or query`); } } function assertWechatMiniProgramRouteParity() { const sharedContractSource = fs.readFileSync( sharedHostBridgeContractPath, 'utf8', ); const appConfig = JSON.parse(fs.readFileSync('miniprogram/app.json', 'utf8')); const runtimeConfig = requireCommonJsModule('miniprogram/config.js'); const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js'); const webViewBridgeSource = fs.readFileSync( 'miniprogram/host-bridge/webView.js', 'utf8', ); const webViewShellSource = fs.readFileSync( 'miniprogram/shell/webView.js', 'utf8', ); const appPageRoutesSource = fs.readFileSync( 'src/routing/appPageRoutes.ts', 'utf8', ); const h5HostBridgeSource = fs.readFileSync( 'src/services/host-bridge/hostBridge.ts', 'utf8', ); const h5SubscribeSource = fs.readFileSync( 'src/services/wechatMiniProgramSubscribe.ts', 'utf8', ); assertSameList( appConfig.pages ?? [], [ protocol.WECHAT_WEB_VIEW_PAGE_URL, protocol.WECHAT_SHARE_GRID_PAGE_URL, protocol.WECHAT_PAY_PAGE_URL, protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL, ].map(pageRouteFromMiniProgramUrl), 'wechat mini program app pages', ); const h5RoutePairs = [ ['MINI_PROGRAM_AUTH_PAGE_URL', protocol.WECHAT_AUTH_PAGE_URL], ['MINI_PROGRAM_PAY_PAGE_URL', protocol.WECHAT_PAY_PAGE_URL], ['MINI_PROGRAM_SHARE_GRID_PAGE_URL', protocol.WECHAT_SHARE_GRID_PAGE_URL], ]; for (const [constName, expectedValue] of h5RoutePairs) { const actualValue = extractStringConst(h5HostBridgeSource, constName); if (actualValue !== expectedValue) { throw new Error( `H5 HostBridge ${constName} drifted: expected ${expectedValue} but got ${actualValue}`, ); } } const h5SubscribePageUrl = extractStringConst( h5SubscribeSource, 'MINI_PROGRAM_SUBSCRIBE_MESSAGE_PAGE_URL', ); if (h5SubscribePageUrl !== protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL) { throw new Error( `H5 subscribe page URL drifted: expected ${protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL} but got ${h5SubscribePageUrl}`, ); } if ( extractStringConst(webViewBridgeSource, 'WEB_VIEW_SHARE_PATH') !== protocol.WECHAT_WEB_VIEW_PAGE_URL ) { throw new Error('wechat mini program share path must use the web-view page route'); } if ( extractStringConst(webViewBridgeSource, 'SHARE_TARGET_MESSAGE_TYPE') !== protocol.WECHAT_SHARE_TARGET_MESSAGE_TYPE ) { throw new Error('wechat mini program share target message type drifted'); } for (const [configKey, label] of [ ['WEB_VIEW_ENTRY_URL', 'wechat release web-view entry URL'], ['DEV_WEB_VIEW_ENTRY_URL', 'wechat dev web-view entry URL'], ['API_BASE_URL', 'wechat release API base URL'], ['DEV_API_BASE_URL', 'wechat dev API base URL'], ]) { assertHttpsDomain(runtimeConfig[configKey], label); } const sourceQuery = runtimeConfig.WEB_VIEW_SOURCE_QUERY ?? {}; const sharedRuntimeContextQueryKey = extractTsStringObject( sharedContractSource, 'HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY', ); const sharedPreservedRuntimeContextQueryKeys = extractTsStringArray( sharedContractSource, 'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', ); const sharedWechatSourceQuery = extractTsStringObject( sharedContractSource, 'HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY', ); assertSameList( Object.keys(sourceQuery), Object.keys(sharedWechatSourceQuery), 'wechat web-view source query keys', ); if ( Object.entries(sharedWechatSourceQuery).some( ([key, value]) => sourceQuery[key] !== value, ) ) { throw new Error('wechat web-view source query drifted from HostBridge runtime markers'); } assertSameList( sharedPreservedRuntimeContextQueryKeys, [ sharedRuntimeContextQueryKey.clientType, sharedRuntimeContextQueryKey.clientRuntime, sharedRuntimeContextQueryKey.miniProgramEnv, sharedRuntimeContextQueryKey.hostShell, sharedRuntimeContextQueryKey.hostPlatform, sharedRuntimeContextQueryKey.hostVersion, sharedRuntimeContextQueryKey.bridgeVersion, sharedRuntimeContextQueryKey.hostCapabilities, ], 'preserved HostBridge runtime context query keys', ); if ( !appPageRoutesSource.includes( 'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', ) || !appPageRoutesSource.includes( 'APP_RUNTIME_CONTEXT_QUERY_KEYS =\n HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', ) ) { throw new Error('H5 app routes must preserve HostBridge runtime context keys from shared contract'); } if ( !h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY') || h5HostBridgeSource.includes("params.get('clientType')") || h5HostBridgeSource.includes("params.get('clientRuntime')") || h5HostBridgeSource.includes("params.get('hostCapabilities')") || h5HostBridgeSource.includes("params.get('miniProgramEnv')") ) { throw new Error('H5 HostBridge runtime parser must read query keys from shared contract'); } for (const snippet of [ "readWebViewSourceQueryValue('clientType')", "readWebViewSourceQueryValue('clientRuntime')", ]) { if (!webViewShellSource.includes(snippet)) { throw new Error('wechat request headers must read runtime markers from WEB_VIEW_SOURCE_QUERY'); } } if ( webViewShellSource.includes('MINI_PROGRAM_CLIENT_TYPE') || webViewShellSource.includes('MINI_PROGRAM_CLIENT_RUNTIME') ) { throw new Error('wechat request runtime markers must not be duplicated outside WEB_VIEW_SOURCE_QUERY'); } } function assertFileIncludesSnippet(filePath, snippet, label) { const source = fs.readFileSync(filePath, 'utf8'); if (!source.includes(snippet)) { throw new Error(`${label} must include ${snippet} in ${filePath}`); } } function assertWechatMiniProgramCapabilityFlows() { const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js'); const declaredCapabilities = protocol.WECHAT_HOST_CAPABILITIES ?? []; const declaredCapabilitySet = new Set(declaredCapabilities); const contractedCapabilities = wechatCapabilityFlowContracts.map( ({ capability }) => capability, ); assertSameList( contractedCapabilities, declaredCapabilities, 'wechat mini program capability flow contracts', ); const wechatShellTestSet = new Set(wechatShellTests); for (const contract of wechatCapabilityFlowContracts) { if (!declaredCapabilitySet.has(contract.capability)) { throw new Error( `wechat capability flow contract declared for missing capability: ${contract.capability}`, ); } for (const filePath of contract.files) { if (!fs.existsSync(filePath)) { throw new Error( `wechat ${contract.capability} flow file is missing: ${filePath}`, ); } } for (const [filePath, snippet] of contract.snippets) { assertFileIncludesSnippet( filePath, snippet, `wechat ${contract.capability} flow`, ); } for (const testPath of contract.tests) { if (!wechatShellTestSet.has(testPath)) { throw new Error( `wechat ${contract.capability} flow test is not part of check:native-shells: ${testPath}`, ); } } } } function assertWechatPaymentResultBoundaries() { const paymentSource = fs.readFileSync( 'miniprogram/host-bridge/payment.js', 'utf8', ); const paymentTestSource = fs.readFileSync( 'miniprogram/host-bridge/payment.test.js', 'utf8', ); for (const snippet of [ "function normalizePayError()", "return 'wechat payment unavailable'", ]) { if (!paymentSource.includes(snippet)) { throw new Error(`wechat payment bridge must include ${snippet}`); } } if ( paymentSource.includes('JSON.stringify({\n errCode') || paymentSource.includes('String(error.errMsg || error)') ) { throw new Error('wechat payment bridge must not expose native payment errors to H5'); } for (const snippet of [ 'maps virtual payment cancel errCode to cancel result', 'hides ordinary payment native failure details from H5 result', "errorMessage: 'wechat payment unavailable'", ]) { if (!paymentTestSource.includes(snippet)) { throw new Error(`wechat payment bridge test must include ${snippet}`); } } } function assertWechatSubscribeResultBoundaries() { const subscribeSource = fs.readFileSync( 'miniprogram/host-bridge/subscribeMessage.js', 'utf8', ); const subscribeTestSource = fs.readFileSync( 'miniprogram/host-bridge/subscribeMessage.test.js', 'utf8', ); for (const snippet of [ "WECHAT_SUBSCRIBE_UNAVAILABLE_REASON = 'wechat subscribe unavailable'", "console.error('[subscribe-message] request failed', error)", 'WECHAT_SUBSCRIBE_UNAVAILABLE_REASON', ]) { if (!subscribeSource.includes(snippet)) { throw new Error(`wechat subscribe bridge must include ${snippet}`); } } if (subscribeSource.includes("error && error.errMsg ? error.errMsg : 'failed'")) { throw new Error('wechat subscribe bridge must not expose native subscribe errors to H5'); } for (const snippet of [ 'hides requestSubscribeMessage native failure details from H5 result', 'wechat%20subscribe%20unavailable', "expect(console.error).toHaveBeenCalledWith(", ]) { if (!subscribeTestSource.includes(snippet)) { throw new Error(`wechat subscribe bridge test must include ${snippet}`); } } } function assertHostBridgeLayerLayout() { assertSameList( readDirectoryFileList( 'miniprogram/host-bridge', 'wechat host bridge files', ), expectedWechatHostBridgeFiles, 'wechat host bridge files', ); assertSameList( readDirectoryFileList('miniprogram/shell', 'wechat shell files'), expectedWechatShellFiles, 'wechat shell files', ); assertSameList( readDirectoryNameList('miniprogram/pages', 'wechat page directories'), Object.keys(expectedWechatPageFilesByRoute).sort(), 'wechat page directories', ); for (const [route, expectedFiles] of Object.entries( expectedWechatPageFilesByRoute, )) { assertSameList( readDirectoryFileList( `miniprogram/pages/${route}`, `wechat ${route} page wrapper files`, ), expectedFiles, `wechat ${route} page wrapper files`, ); const pagePath = `miniprogram/pages/${route}/index.js`; const source = fs.readFileSync(pagePath, 'utf8'); if (!source.includes("require('../../shell/")) { throw new Error(`${pagePath} must import from miniprogram/shell`); } if ( source.includes("require('../../host-bridge/") || source.includes("require('./index.shared')") ) { throw new Error(`${pagePath} must not import bridge logic directly`); } } assertSameList( readDirectoryEntryList('apps/mobile-shell/src', 'mobile shell src root entries'), expectedMobileSrcRootEntries, 'mobile shell src root entries', ); assertSameList( readDirectoryFileList( 'apps/mobile-shell/src/host-bridge', 'mobile host bridge files', ), expectedMobileHostBridgeFiles, 'mobile host bridge files', ); assertSameList( readDirectoryFileList('apps/mobile-shell/src/shell', 'mobile shell files'), expectedMobileShellFiles, 'mobile shell files', ); const mobileAppSource = fs.readFileSync('apps/mobile-shell/App.tsx', 'utf8'); if (!mobileAppSource.includes("import ShellApp from './src/shell/ShellApp';")) { throw new Error('mobile shell App.tsx must import from apps/mobile-shell/src/shell'); } if (mobileAppSource.includes('./src/host-bridge/')) { throw new Error('mobile shell App.tsx must not import HostBridge directly'); } assertSameList( readDirectoryEntryList( 'apps/desktop-shell/src-tauri/src', 'desktop shell Rust root entries', ), ['dir:host_bridge', 'dir:shell', 'file:app.rs', 'file:main.rs'], 'desktop shell Rust root entries', ); const desktopMainSource = fs.readFileSync( 'apps/desktop-shell/src-tauri/src/main.rs', 'utf8', ); const desktopAppSource = fs.readFileSync( 'apps/desktop-shell/src-tauri/src/app.rs', 'utf8', ); if (!desktopMainSource.includes('mod app;') || !desktopMainSource.includes('app::run();')) { throw new Error('desktop shell main.rs must stay a thin app entrypoint'); } if ( desktopMainSource.includes('tauri::Builder::default()') || desktopMainSource.includes('tauri::generate_handler!') ) { throw new Error('desktop shell main.rs must not own Tauri app setup'); } if ( !desktopAppSource.includes('tauri::Builder::default()') || !desktopAppSource.includes('crate::host_bridge::host_bridge_request') ) { throw new Error('desktop shell app.rs must own Tauri app setup'); } assertSameList( readDirectoryFileList( 'apps/desktop-shell/src-tauri/src/host_bridge', 'desktop host bridge Rust files', ), expectedDesktopHostBridgeRustFiles, 'desktop host bridge Rust files', ); assertSameList( readDirectoryFileList( 'apps/desktop-shell/src-tauri/src/shell', 'desktop shell Rust files', ), expectedDesktopShellRustFiles, 'desktop shell Rust files', ); } function assertDesktopReleaseBinaryArtifact() { const releaseDir = path.join( 'apps', 'desktop-shell', 'src-tauri', 'target', 'release', ); const executableName = process.platform === 'win32' ? 'genarrative-desktop-shell.exe' : 'genarrative-desktop-shell'; const executablePath = path.join(releaseDir, executableName); if (!fs.existsSync(executablePath)) { throw new Error(`desktop release binary is missing: ${executablePath}`); } const stat = fs.statSync(executablePath); if (!stat.isFile() || stat.size < 1024 * 1024) { throw new Error('desktop release binary must be a real non-empty executable file'); } const header = fs.readFileSync(executablePath, { start: 0, end: 7 }); if (process.platform === 'linux') { const isElf = header[0] === 0x7f && header[1] === 0x45 && header[2] === 0x4c && header[3] === 0x46; if (!isElf || (stat.mode & 0o111) === 0) { throw new Error('desktop Linux release binary must be an executable ELF file'); } return; } if (process.platform === 'darwin') { const machMagic = header.readUInt32BE(0); const isMachO = machMagic === 0xcafebabe || machMagic === 0xcafed00d || machMagic === 0xfeedface || machMagic === 0xfeedfacf; if (!isMachO || (stat.mode & 0o111) === 0) { throw new Error('desktop macOS release binary must be an executable Mach-O file'); } return; } if (process.platform === 'win32') { if (header[0] !== 0x4d || header[1] !== 0x5a) { throw new Error('desktop Windows release binary must be a PE executable'); } } } for (const step of steps) { console.log(`[check:native-shells] ${step.label}`); const result = spawnSync(step.command, step.args, { cwd: process.cwd(), stdio: 'inherit', }); if (result.error) { console.error( `[check:native-shells] failed to start ${step.label}: ${result.error.message}`, ); process.exit(1); } if (result.signal) { console.error( `[check:native-shells] ${step.label} was terminated by signal ${result.signal}`, ); process.exit(1); } if ((result.status ?? 0) !== 0) { process.exit(result.status ?? 1); } } console.log('[check:native-shells] desktop-release-binary-artifact'); assertDesktopReleaseBinaryArtifact(); console.log('[check:native-shells] host-bridge-layer-layout'); assertHostBridgeLayerLayout(); console.log('[check:native-shells] native-shell-capability-plan'); assertNativeShellCapabilityPlan(); console.log('[check:native-shells] external-url-protocol-parity'); assertExternalUrlProtocolParity(); console.log('[check:native-shells] wechat-mini-program-route-parity'); assertWechatMiniProgramRouteParity(); console.log('[check:native-shells] wechat-mini-program-capability-flows'); assertWechatMiniProgramCapabilityFlows(); console.log('[check:native-shells] wechat-payment-result-boundaries'); assertWechatPaymentResultBoundaries(); console.log('[check:native-shells] wechat-subscribe-result-boundaries'); assertWechatSubscribeResultBoundaries(); console.log('[check:native-shells] h5-host-bridge-event-subscription-gates'); assertH5HostBridgeEventSubscriptionGates(); console.log('[check:native-shells] h5-host-bridge-payload-boundaries'); assertH5HostBridgePayloadBoundaries(); console.log('[check:native-shells] h5-native-app-transport-timeout-boundaries'); assertH5NativeAppTransportTimeoutBoundaries(); console.log('[check:native-shells] production-shell-dev-scaffold-scan'); assertNoProductionShellDevScaffoldTerms(); console.log('[check:native-shells] OK');