扩展宿主调用链扫描

自动发现 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
@@ -29,7 +29,7 @@
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。
- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck``npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 也由 `miniprogram/host-bridge/protocol.test.js` 反查同一共享白名单。新增能力必须先更新契约和真实壳实现,再通过这些检查。
- 2026-06-18 原生壳统一验收门禁:根级 `npm run check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、三端壳、Expo managed config、移动端 production bundle、桌面 release 入口和 H5 HostBridge 真实调用链禁替身验收散落成容易漏跑的单项命令。
- 2026-06-19 原生壳临时替身扫描范围:`npm run check:native-shells` 的生产替身词扫描必须覆盖微信小程序壳生产 `.js`、Expo / Tauri 壳源码与配置、共享 HostBridge 契约、H5 native transport以及已接入的 H5 直接调用链文件;新增 H5 调用点接入 HostBridge 时,必须同步把对应文件纳入扫描或在同等门禁中证明生产代码没有 mock / fake / placeholder / TODO / FIXME / 占位 / 伪造。
- 2026-06-19 原生壳临时替身扫描范围:`npm run check:native-shells` 的生产替身词扫描必须覆盖微信小程序壳生产 `.js`、Expo / Tauri 壳源码与配置、共享 HostBridge 契约、H5 native transport并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件;新增 H5 调用点接入 HostBridge 时,必须让自动扫描覆盖对应文件或在同等门禁中证明生产代码没有 mock / fake / stub / TODO / FIXME / 模拟 / 伪造。
- 2026-06-18 微信壳桥接层纳入统一验收:`npm run check:native-shells` 还会运行 `miniprogram/host-bridge/``miniprogram/shell/``pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的微信壳测试,覆盖 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图行为;三端桥接层文件结构检查只证明目录边界,行为回归必须由同一门禁中的微信壳测试证明。
- 2026-06-18 登录 / 支付能力禁伪声明:`auth.requestLogin``payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo / Tauri 壳在真实 SDK、渠道流程和后端契约落地前不得声明这些 capability,也不得把它们写入入口 URL `hostCapabilities`;两端检查脚本会拒绝伪声明,请求实际到达壳层时必须返回明确 `unsupported_method` 并让 H5 fallback,两端壳测试直接覆盖这两个 method。
- 2026-06-18 移动壳触觉反馈边界:`haptics.impact` 只接受 `light``medium``heavy` 三档 impact style,缺省为 `light`;未知值必须返回 `invalid_request`,不得静默降级成真实设备触觉反馈。桌面壳不声明该 capabilityH5 继续按 HostBridge fallback 处理。
@@ -90,6 +90,7 @@
- 2026-06-18 HostBridge capability / handler 关系门禁:两端壳声明 request method capability 时必须有对应 HostBridge handler;壳 handler 处理的 method 必须已被该壳声明,登录 / 支付等 SDK-backed method 只能保留明确 `unsupported_method` 路径。事件类 capability 不要求 request handler。
- 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*``ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval``tauri:``file:`
- 2026-06-18 壳生产代码禁用临时替身:微信 / Expo / Tauri 三端壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端原生壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 会统一扫描 `miniprogram``apps/mobile-shell``apps/desktop-shell`、H5 HostBridge transport 和共享 HostBridge 契约生产源码,防止把临时替身、占位文案或伪实现带进可分发壳或真实调用链。
- 2026-06-19 H5 HostBridge 调用链自动扫描:根级 `npm run check:native-shells``src/` 生产文件自动收集真实宿主能力 facade 的直接消费者,以及 `useHostLifecycleActive``useHostNetworkOnline``platformProfileHostClipboard` 等薄 wrapper 的消费者。H5 业务文件允许正常表单 `placeholder` 属性和业务占位图文案,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹;壳源码和配置仍继续禁用 placeholder / 占位。
- 2026-06-18 原生壳本地生成物边界:Expo `.expo/`、Expo export smoke 临时目录、Tauri `target/`、Tauri schema `gen/` 和 Tauri 自动生成权限目录都必须保持 gitignored,不作为生产源码敏感词扫描输入;手写 capability / 权限配置仍在扫描范围内。
- 2026-06-18 移动壳启动页与 adaptive iconExpo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。
- 2026-06-18 桌面壳 bundle 图标集:Tauri 桌面壳从现有真实品牌 PNG `apps/desktop-shell/src-tauri/icons/icon.png` 派生 `32x32.png``128x128.png``128x128@2x.png``icon.ico``icon.icns`,并在 `bundle.icon` 中同时声明这些平台图标。检查脚本会校验 PNG 尺寸、ICO 多尺寸头部、ICNS 容器长度和 bundle 图标列表,避免后续退回单图标或替换为非品牌 / 占位素材。
@@ -216,7 +216,7 @@ npm run build
npm run check:native-shells
```
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport 和已接入的 H5 直接调用链文件。
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
该命令同时会运行微信小程序 `miniprogram/host-bridge/``miniprogram/shell/``pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。
内容检查:
@@ -441,7 +441,9 @@ GameBridge 禁止:
2026-06-18 追加:登录 / 支付能力伪声明进入门禁。`auth.requestLogin``payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo 与 Tauri 壳在没有真实 SDK、渠道流程和后端契约前不得声明这些 capability,也不得把它们写入 `hostCapabilities`;请求到达壳层时必须返回明确 `unsupported_method`,让 H5 fallback 承接。两端壳测试会直接覆盖这两个 method,避免后续半接入时返回伪成功。
2026-06-18 追加:微信 / Expo / Tauri 三端壳的生产源码和配置禁止出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 `vi.mock` 或等价测试替身。`apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/scripts/check-config.mjs` 会扫描各自生产入口、配置和壳实现;根级 `npm run check:native-shells` 会统一扫描 `miniprogram``apps/mobile-shell``apps/desktop-shell`、H5 HostBridge transport 和共享 HostBridge 契约生产源码,防止把临时替身或占位文案带进可分发壳或真实调用链
2026-06-18 追加:微信 / Expo / Tauri 三端壳的生产源码和配置禁止出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 `vi.mock` 或等价测试替身。`apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/scripts/check-config.mjs` 会扫描各自生产入口、配置和壳实现;根级 `npm run check:native-shells` 会统一扫描 `miniprogram``apps/mobile-shell``apps/desktop-shell`、H5 HostBridge transport 和共享 HostBridge 契约生产源码,防止把临时替身或占位文案带进可分发壳。
2026-06-19 追加:H5 HostBridge 真实调用链扫描改为自动发现。根级 `npm run check:native-shells` 会从 `src/` 生产文件里收集直接导入并调用真实宿主能力 facade 的文件,以及 `useHostLifecycleActive``useHostNetworkOnline``platformProfileHostClipboard` 等薄 wrapper 的消费者;新增 `reloadHostWebView``showHostLocalNotification``setHostAppBadgeCount``importHostTextFile``importHostImageFile``captureHostImageFile``subscribeHostImageDrop``readHostClipboardText``openHostExternalUrl` 等 H5 调用点时,不再手工维护单个扫描文件清单。H5 业务文件允许正常表单 `placeholder` 属性和业务占位图文案,但仍会拒绝 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
2026-06-18 追加:微信壳行为测试进入原生壳统一验收。`npm run check:native-shells` 会运行 `miniprogram/host-bridge/``miniprogram/shell/``pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 测试,覆盖微信 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图桥接行为;三端桥接层文件结构检查只锁定职责边界,真实行为回归必须由同一门禁中的微信壳测试证明。
@@ -491,7 +493,7 @@ GameBridge 禁止:
- AI sandbox 无法调用 HostBridge,也无法读取 H5 登录态。
- Tauri release 包不允许任意远端页面调用桌面命令。
- Expo WebView 外链离开主站后不保留完整 HostBridge。
- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。该扫描范围必须包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport 和已接入的 H5 直接调用链文件。
- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。该扫描范围必须包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。
## 参考资料
@@ -83,7 +83,7 @@ HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_
- 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。
- 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。
- 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。
- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、共享 capability profile、HostBridge event 白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport 和已接入的 H5 直接调用链文件。
- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、共享 capability profile、HostBridge event 白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。H5 业务文件允许正常表单 `placeholder` 属性和业务占位图文案,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
## 后续
+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 ||
@@ -416,7 +416,7 @@ import {
buildVisualNovelEntryGenerationProgress,
type VisualNovelEntryFormPayload,
} from '../visual-novel-creation/visualNovelEntryGeneration';
import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData';
import { createLocalPreviewVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData';
import {
buildBarkBattleDraftConfigFromWorkSummary,
buildBarkBattlePublishedConfigFromDraft,
@@ -7267,7 +7267,7 @@ export function PlatformEntryFlowShellImpl({
const profileId =
visualNovelWork?.summary.profileId?.trim() ?? draft.profileId?.trim();
if (!profileId) {
const run = createMockVisualNovelRunFromDraft(draft);
const run = createLocalPreviewVisualNovelRunFromDraft(draft);
setVisualNovelRun(run);
setVisualNovelRuntimeReturnStage('visual-novel-result');
setSelectionStage('visual-novel-runtime');
@@ -7287,7 +7287,7 @@ export function PlatformEntryFlowShellImpl({
} catch (error) {
// 中文注释:VN-07 只要求结果页进入测试运行时;真实 runtime 接口未就绪时,
// 使用当前 draft 生成本地 test run,不扩展到正式玩家运行链路。
setVisualNovelRun(createMockVisualNovelRunFromDraft(draft));
setVisualNovelRun(createLocalPreviewVisualNovelRunFromDraft(draft));
setVisualNovelRuntimeReturnStage('visual-novel-result');
setSelectionStage('visual-novel-runtime');
setVisualNovelError(
@@ -5,7 +5,7 @@ import type { ComponentProps } from 'react';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
import { visualNovelLocalPreviewSession } from '../visual-novel-runtime/visualNovelLocalPreviewData';
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
import {
buildVisualNovelEntryGenerationAnchorEntries,
@@ -107,7 +107,7 @@ test('visual novel workspace submits idea and selected visual style as seed text
});
test('visual novel workspace restores idea text from existing session', () => {
renderWorkspace({ session: mockVisualNovelSession });
renderWorkspace({ session: visualNovelLocalPreviewSession });
expect(
(screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value,
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData';
import { VisualNovelResultView } from './VisualNovelResultView';
vi.mock('../../services/visual-novel-creation', () => ({
@@ -26,7 +26,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
test('visual novel profile tab uses PlatformSubpanel shells', () => {
const { container } = render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
);
const profilePanels = Array.from(
@@ -48,7 +48,7 @@ test('visual novel result header back button reuses shared compact back action b
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={onBack}
isBusy
/>,
@@ -69,7 +69,7 @@ test('visual novel result header back button reuses shared compact back action b
test('visual novel profile media previews use PlatformMediaFrame aspects', () => {
const draftWithCover = {
...mockVisualNovelDraft,
...visualNovelLocalPreviewDraft,
coverImageSrc: '/visual-novel/cover.png',
};
@@ -104,7 +104,7 @@ test('visual novel upload-only asset picker uses PlatformEmptyState chrome', asy
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '封面' }));
@@ -130,7 +130,7 @@ test('visual novel entity list items use interactive PlatformSubpanel shells', a
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '角色' }));
@@ -146,7 +146,7 @@ test('visual novel entity list items use interactive PlatformSubpanel shells', a
test('visual novel empty entity list uses PlatformEmptyState shell', async () => {
const user = userEvent.setup();
const emptyCharacterDraft = {
...mockVisualNovelDraft,
...visualNovelLocalPreviewDraft,
characters: [],
};
@@ -173,7 +173,7 @@ test('visual novel result opens complex editors as a dialog', async () => {
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
<VisualNovelResultView draft={visualNovelLocalPreviewDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '角色' }));
@@ -190,7 +190,7 @@ test('visual novel result exposes test run action with current draft', async ()
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
@@ -209,7 +209,7 @@ test('visual novel result sends edited character draft to save and test run', as
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
onStartTestRun={onStartTestRun}
@@ -251,7 +251,7 @@ test('visual novel result uploads scene and character assets into platform refer
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
@@ -304,7 +304,7 @@ test('visual novel result generates scene background from asset picker', async (
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
@@ -330,7 +330,7 @@ test('visual novel result generates scene background from asset picker', async (
expect.objectContaining({
kind: 'scene_background',
scene: expect.objectContaining({
sceneId: mockVisualNovelDraft.scenes[0]?.sceneId,
sceneId: visualNovelLocalPreviewDraft.scenes[0]?.sceneId,
}),
prompt: '默认图片提示词',
}),
@@ -57,7 +57,7 @@ import {
PlatformTextField,
} from '../common/PlatformTextField';
import { PlatformToggleRow } from '../common/PlatformToggleRow';
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData';
type VisualNovelResultViewProps = {
draft?: VisualNovelResultDraft | null;
@@ -1994,7 +1994,7 @@ function VisualNovelEditorDialog({
}
export function VisualNovelResultView({
draft = mockVisualNovelDraft,
draft = visualNovelLocalPreviewDraft,
isBusy = false,
error = null,
onBack,
@@ -2004,7 +2004,7 @@ export function VisualNovelResultView({
onPublish,
}: VisualNovelResultViewProps) {
const [editDraft, setEditDraft] = useState(() =>
cloneDraft(draft ?? mockVisualNovelDraft),
cloneDraft(draft ?? visualNovelLocalPreviewDraft),
);
const [activeTab, setActiveTab] = useState<VisualNovelResultTab>('profile');
const [editorTarget, setEditorTarget] =
@@ -5,14 +5,14 @@ import { expect, test } from 'vitest';
import { VisualNovelAttributePanel } from './VisualNovelAttributePanel';
import { VisualNovelHistoryPanel } from './VisualNovelHistoryPanel';
import { mockVisualNovelRun } from './visualNovelMockData';
import { visualNovelLocalPreviewRun } from './visualNovelLocalPreviewData';
import { VisualNovelSavePanel } from './VisualNovelSavePanel';
test('visual novel history panel uses platform empty state', () => {
render(
<VisualNovelHistoryPanel
run={{
...mockVisualNovelRun,
...visualNovelLocalPreviewRun,
history: [],
}}
/>,
@@ -28,7 +28,7 @@ test('visual novel attribute panel uses platform empty state', () => {
render(
<VisualNovelAttributePanel
run={{
...mockVisualNovelRun,
...visualNovelLocalPreviewRun,
metrics: {},
}}
/>,
@@ -43,7 +43,7 @@ test('visual novel attribute panel uses platform empty state', () => {
test('visual novel save panel uses platform empty states', () => {
const { rerender } = render(
<VisualNovelSavePanel
run={mockVisualNovelRun}
run={visualNovelLocalPreviewRun}
saveArchives={[]}
isLoadingArchives
/>,
@@ -52,7 +52,7 @@ test('visual novel save panel uses platform empty states', () => {
expect(screen.getByText('读取中').className).toContain('bg-white/74');
expect(screen.queryByText('暂无存档')).toBeNull();
rerender(<VisualNovelSavePanel run={mockVisualNovelRun} saveArchives={[]} />);
rerender(<VisualNovelSavePanel run={visualNovelLocalPreviewRun} saveArchives={[]} />);
expect(screen.getByText('暂无存档').className).toContain('bg-white/74');
expect(screen.queryByText('读取中')).toBeNull();
@@ -60,7 +60,7 @@ test('visual novel save panel uses platform empty states', () => {
test('visual novel runtime list cards use platform subpanel chrome', () => {
const { rerender } = render(
<VisualNovelHistoryPanel run={mockVisualNovelRun} />,
<VisualNovelHistoryPanel run={visualNovelLocalPreviewRun} />,
);
const historyCard = screen.getByText('#1').closest('article');
@@ -71,12 +71,12 @@ test('visual novel runtime list cards use platform subpanel chrome', () => {
rerender(
<VisualNovelSavePanel
run={mockVisualNovelRun}
run={visualNovelLocalPreviewRun}
saveArchives={[
{
worldKey: 'visual-novel:archive-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
ownerUserId: 'local-preview-user',
profileId: 'vn-profile-preview-1',
worldType: 'visual-novel',
worldName: '雪线电台',
subtitle: '风雪站台',
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from './visualNovelForbiddenCopy';
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
import { visualNovelLocalPreviewDraft, visualNovelLocalPreviewRun } from './visualNovelLocalPreviewData';
import { VisualNovelRuntimeShell } from './VisualNovelRuntimeShell';
test('visual novel runtime renders mock play surface and opens panels as dialogs', async () => {
@@ -13,8 +13,8 @@ test('visual novel runtime renders mock play surface and opens panels as dialogs
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
onBack={() => {}}
/>,
);
@@ -35,8 +35,8 @@ test('visual novel runtime submits free text action with client event id', async
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
onBack={() => {}}
onSubmitAction={onSubmitAction}
/>,
@@ -61,14 +61,14 @@ test('visual novel runtime submits choice and continue actions', async () => {
const onSubmitAction = vi.fn();
const onContinue = vi.fn();
const runWithoutChoices = {
...mockVisualNovelRun,
...visualNovelLocalPreviewRun,
availableChoices: [],
};
const { rerender } = render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
onBack={() => {}}
onSubmitAction={onSubmitAction}
onContinue={onContinue}
@@ -88,7 +88,7 @@ test('visual novel runtime submits choice and continue actions', async () => {
rerender(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
draft={visualNovelLocalPreviewDraft}
run={runWithoutChoices}
onBack={() => {}}
onSubmitAction={onSubmitAction}
@@ -108,8 +108,8 @@ test('visual novel runtime panels call regeneration and platform archive actions
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
onBack={() => {}}
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
onSaveRun={onSaveRun}
@@ -117,8 +117,8 @@ test('visual novel runtime panels call regeneration and platform archive actions
saveArchives={[
{
worldKey: 'visual-novel:archive-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
ownerUserId: 'local-preview-user',
profileId: 'vn-profile-preview-1',
worldType: 'visual-novel',
worldName: '雪线电台',
subtitle: '风雪站台',
@@ -147,8 +147,8 @@ test('visual novel runtime shows raw text only as transient stream text', () =>
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
draft={visualNovelLocalPreviewDraft}
run={visualNovelLocalPreviewRun}
streamingText={transientText}
onBack={() => {}}
/>,
@@ -25,7 +25,7 @@ import type {
} from '../../../packages/shared/src/contracts/visualNovel';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { useVisualNovelRuntimeController } from './useVisualNovelRuntimeController';
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
import { visualNovelLocalPreviewDraft, visualNovelLocalPreviewRun } from './visualNovelLocalPreviewData';
import {
VisualNovelRuntimePanel,
type VisualNovelRuntimePanelKind,
@@ -218,8 +218,8 @@ function resolveChoices(
}
export function VisualNovelRuntimeShell({
draft = mockVisualNovelDraft,
run = mockVisualNovelRun,
draft = visualNovelLocalPreviewDraft,
run = visualNovelLocalPreviewRun,
isBusy = false,
isSaving = false,
isLoadingArchives = false,
@@ -243,8 +243,8 @@ export function VisualNovelRuntimeShell({
const [localTextModeEnabled, setLocalTextModeEnabled] = useState(
run?.textModeEnabled ?? draft?.runtimeConfig.defaultTextMode ?? false,
);
const displayDraft = draft ?? mockVisualNovelDraft;
const baseRun = run ?? mockVisualNovelRun;
const displayDraft = draft ?? visualNovelLocalPreviewDraft;
const baseRun = run ?? visualNovelLocalPreviewRun;
const runtimeController = useVisualNovelRuntimeController({
draft: displayDraft,
initialRun: baseRun,
@@ -5,11 +5,11 @@ import type {
VisualNovelSourceMode,
} from '../../../packages/shared/src/contracts/visualNovel';
const MOCK_NOW = '2026-05-05T12:00:00.000Z';
const LOCAL_PREVIEW_NOW = '2026-05-05T12:00:00.000Z';
// 中文注释:VN-04 只提供 UI 骨架,mock 数据必须保持在前端组件域内,避免被误用成业务真相源。
export const mockVisualNovelDraft: VisualNovelResultDraft = {
profileId: 'vn-profile-mock-1',
// 中文注释:VN-04 只提供 UI 骨架,本地预览数据必须保持在前端组件域内,避免被误用成业务真相源。
export const visualNovelLocalPreviewDraft: VisualNovelResultDraft = {
profileId: 'vn-profile-preview-1',
workTitle: '雪线电台',
workDescription: '一列停在雪夜边境的列车,牵出旧电台、失踪乘客和未寄出的告白。',
workTags: ['悬疑', '冬夜', '双人叙事'],
@@ -140,12 +140,12 @@ export const mockVisualNovelDraft: VisualNovelResultDraft = {
message: '封面待补齐。',
},
],
updatedAt: MOCK_NOW,
updatedAt: LOCAL_PREVIEW_NOW,
};
export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = {
sessionId: 'vn-session-mock-1',
ownerUserId: 'mock-user',
export const visualNovelLocalPreviewSession: VisualNovelAgentSessionSnapshot = {
sessionId: 'vn-session-preview-1',
ownerUserId: 'local-preview-user',
sourceMode: 'idea',
status: 'ready',
messages: [
@@ -154,24 +154,24 @@ export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = {
role: 'user',
kind: 'chat',
text: '想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
createdAt: MOCK_NOW,
createdAt: LOCAL_PREVIEW_NOW,
},
{
id: 'vn-message-2',
role: 'assistant',
kind: 'summary',
text: '底稿已整理到世界观、角色、场景和前两段剧情阶段。',
createdAt: MOCK_NOW,
createdAt: LOCAL_PREVIEW_NOW,
},
],
draft: mockVisualNovelDraft,
draft: visualNovelLocalPreviewDraft,
pendingAction: {
actionId: 'vn-action-compile-mock',
actionId: 'vn-action-compile-preview',
kind: 'compile_work_profile',
label: '生成结果页',
},
createdAt: MOCK_NOW,
updatedAt: MOCK_NOW,
createdAt: LOCAL_PREVIEW_NOW,
updatedAt: LOCAL_PREVIEW_NOW,
};
function createVisualNovelDraftId(prefix: string) {
@@ -278,7 +278,7 @@ export function createBlankVisualNovelDraft(
};
}
export function createMockVisualNovelSessionFromDraft(
export function createLocalPreviewVisualNovelSessionFromDraft(
draft: VisualNovelResultDraft,
): VisualNovelAgentSessionSnapshot {
const now = new Date().toISOString();
@@ -307,7 +307,7 @@ export function createMockVisualNovelSessionFromDraft(
};
}
export function createMockVisualNovelRunFromDraft(
export function createLocalPreviewVisualNovelRunFromDraft(
draft: VisualNovelResultDraft,
): VisualNovelRunSnapshot {
const now = new Date().toISOString();
@@ -388,10 +388,10 @@ export function createMockVisualNovelRunFromDraft(
};
}
export const mockVisualNovelRun: VisualNovelRunSnapshot = {
runId: 'vn-run-mock-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
export const visualNovelLocalPreviewRun: VisualNovelRunSnapshot = {
runId: 'vn-run-preview-1',
ownerUserId: 'local-preview-user',
profileId: 'vn-profile-preview-1',
mode: 'test',
status: 'active',
currentSceneId: 'vn-scene-platform',
@@ -408,7 +408,7 @@ export const mockVisualNovelRun: VisualNovelRunSnapshot = {
history: [
{
entryId: 'vn-history-1',
runId: 'vn-run-mock-1',
runId: 'vn-run-preview-1',
turnIndex: 1,
source: 'assistant',
actionText: null,
@@ -432,24 +432,24 @@ export const mockVisualNovelRun: VisualNovelRunSnapshot = {
},
],
snapshotBeforeHash: null,
snapshotAfterHash: 'mock-hash-1',
createdAt: MOCK_NOW,
snapshotAfterHash: 'preview-hash-1',
createdAt: LOCAL_PREVIEW_NOW,
},
{
entryId: 'vn-history-2',
runId: 'vn-run-mock-1',
runId: 'vn-run-preview-1',
turnIndex: 2,
source: 'player',
actionText:
'靠近广播柜,确认频段来源,同时留意林遥是否在隐瞒什么。',
steps: [],
snapshotBeforeHash: 'mock-hash-1',
snapshotAfterHash: 'mock-hash-2',
createdAt: MOCK_NOW,
snapshotBeforeHash: 'preview-hash-1',
snapshotAfterHash: 'preview-hash-2',
createdAt: LOCAL_PREVIEW_NOW,
},
],
availableChoices: mockVisualNovelDraft.opening.initialChoices,
availableChoices: visualNovelLocalPreviewDraft.opening.initialChoices,
textModeEnabled: true,
createdAt: MOCK_NOW,
updatedAt: MOCK_NOW,
createdAt: LOCAL_PREVIEW_NOW,
updatedAt: LOCAL_PREVIEW_NOW,
};