扩展宿主调用链扫描

自动发现 H5 HostBridge 真实能力调用链

补齐 runtime 回读与 wrapper 消费者扫描兜底

区分壳源码和 H5 业务调用链的禁替身词表

将视觉小说生产预览数据从 mock 命名收口为本地预览命名

更新原生壳验收文档和共享决策记录
This commit is contained in:
2026-06-19 02:45:56 +08:00
parent 429e65952d
commit deea0e8655
13 changed files with 321 additions and 96 deletions
+233 -11
View File
@@ -20,15 +20,74 @@ const productionShellScanRoots = [
'packages/shared/src/contracts/hostBridge.ts',
'src/services/host-bridge',
];
const h5HostBridgeCallChainScanFiles = [
'src/services/clipboard.ts',
'src/services/appTitle.ts',
'src/services/runtimeAudioFeedback.ts',
const h5HostBridgeFacadeModule = 'src/services/host-bridge/hostBridge';
const h5HostBridgeScannedFacadeImports = new Set([
'canUseHostShareGrid',
'captureHostImageFile',
'exportHostAudioFile',
'exportHostImageFile',
'exportHostTextFile',
'getHostAppearanceColorScheme',
'getHostNetworkStatus',
'getNativeAppHostRuntime',
'importHostAudioFile',
'importHostImageFile',
'importHostTextFile',
'navigateHostNativePage',
'openHostExternalUrl',
'openHostShare',
'openHostShareGrid',
'readHostClipboardText',
'refreshNativeAppHostRuntime',
'reloadHostWebView',
'requestHostHapticsImpact',
'requestHostLogin',
'requestHostPayment',
'setHostAppBadgeCount',
'setHostAppTitle',
'setHostShareTarget',
'showHostLocalNotification',
'subscribeHostAppLifecycle',
'subscribeHostImageDrop',
'subscribeHostNavigationCanGoBack',
'subscribeHostNetworkStatusChange',
'subscribeHostRuntimeChange',
'writeHostClipboardText',
]);
const h5HostBridgeCallChainWrapperFiles = [
'src/hooks/useHostLifecycleActive.ts',
'src/hooks/useHostNetworkOnline.ts',
'src/components/platform-entry/platformProfileHostClipboard.ts',
];
const h5HostBridgeRequiredCallChainFiles = [
'src/App.tsx',
'src/components/auth/AuthGate.tsx',
'src/components/common/CreativeAudioInputPanel.tsx',
'src/components/common/CreativeImageInputPanel.tsx',
'src/components/common/PublishShareModal.tsx',
'src/components/common/publishShareCardImage.ts',
'src/components/common/CreativeAudioInputPanel.tsx',
'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/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/hooks/useBackgroundMusic.ts',
'src/hooks/useHostLifecycleActive.ts',
'src/hooks/useHostNetworkOnline.ts',
'src/main.tsx',
'src/services/appTitle.ts',
'src/services/clipboard.ts',
'src/services/runtimeAudioFeedback.ts',
'src/services/wechatMiniProgramShareTarget.ts',
'src/services/wechatMiniProgramSubscribe.ts',
'src/services/wechatMiniProgramShareGrid.ts',
];
const expectedWechatHostBridgeFiles = [
'dispatch.js',
@@ -126,6 +185,7 @@ const productionShellExtensions = new Set([
'.wxml',
'.wxss',
]);
const h5ProductionSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
const productionShellExcludedSegments = new Set([
'.expo',
'.expo-export-smoke',
@@ -148,6 +208,15 @@ const productionShellDevScaffoldTerms = [
'模' + '拟',
'伪' + '造',
];
const h5HostBridgeCallChainDevScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'模' + '拟',
'伪' + '造',
];
const h5HostBridgeTests = [
'packages/shared/src/contracts/hostBridge.test.ts',
@@ -234,6 +303,19 @@ function shouldScanProductionShellFile(filePath) {
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)) {
@@ -259,16 +341,145 @@ function collectProductionShellFiles(entryPath) {
return shouldScanProductionShellFile(entryPath) ? [entryPath] : [];
}
function assertNoProductionShellDevScaffoldTerms() {
const files = [
...productionShellScanRoots,
...h5HostBridgeCallChainScanFiles,
].flatMap(collectProductionShellFiles);
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 productionShellDevScaffoldTerms) {
for (const term of terms) {
const matchIndex = lowerSource.indexOf(term.toLowerCase());
if (matchIndex === -1) {
continue;
@@ -283,6 +494,17 @@ function assertNoProductionShellDevScaffoldTerms() {
}
}
function assertNoProductionShellDevScaffoldTerms() {
assertNoDevScaffoldTermsInFiles(
productionShellScanRoots.flatMap(collectProductionShellFiles),
productionShellDevScaffoldTerms,
);
assertNoDevScaffoldTermsInFiles(
collectH5HostBridgeCallChainFiles().flatMap(collectProductionShellFiles),
h5HostBridgeCallChainDevScaffoldTerms,
);
}
function assertSameList(actual, expected, label) {
if (
actual.length !== expected.length ||