diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 2c036689f..60aa3f5e1 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -181,6 +181,7 @@ const desktopShellNavigationSource = fs.readFileSync( ); const productionSourceRoots = [ new URL('../package.json', import.meta.url), + new URL('../scripts/', import.meta.url), new URL('../src-tauri/Cargo.toml', import.meta.url), new URL('../src-tauri/Info.plist', import.meta.url), new URL('../src-tauri/build.rs', import.meta.url), @@ -301,6 +302,9 @@ const expectedDesktopShellRustFiles = [ 'webview.rs', 'window_state.rs', ]; +const expectedDesktopShellScripts = [ + 'check-config.mjs', +]; function extractCargoPackageString(source, key) { const match = source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, 'm')); @@ -576,7 +580,7 @@ function collectProductionSourceFiles(entry) { if (!productionFileExtensions.has(extension)) { return []; } - if (path.includes('.test.') || path.endsWith('/scripts/check-config.mjs')) { + if (path.includes('.test.')) { return []; } @@ -1197,6 +1201,11 @@ function readDirectoryFileList(directory, label) { } function assertDesktopSourceLayout() { + assertSameList( + readDirectoryFileList(new URL('../scripts/', import.meta.url), 'desktop shell scripts'), + expectedDesktopShellScripts, + 'desktop shell scripts', + ); assertSameList( readDirectoryEntryList(rustSourceDir, 'desktop shell Rust root entries'), expectedDesktopRustRootEntries, @@ -2009,11 +2018,19 @@ if ( } for (const snippet of [ 'resolve_desktop_host_bridge_network_status', + 'map_desktop_host_bridge_network_status_result', 'resolve_desktop_host_bridge_network_status_payload', + 'resolve_desktop_host_bridge_network_status_payload_with', 'network_status_unavailable_response', - 'spawn_blocking(resolve_desktop_network_status)', + 'spawn_blocking(resolver)', + 'resolve_desktop_host_bridge_network_status_payload_with(resolve_desktop_network_status)', + '"isConnected": true', + '"connectionType": "unknown"', '"network status unavailable"', 'network_status_unavailable_response_is_stable', + 'network_status_success_response_reports_contract_shape', + 'network_status_failure_hides_native_error_detail', + 'private native resolver detail', ]) { if (!desktopHostBridgeNetworkSource.includes(snippet)) { throw new Error(`desktop shell network module is missing ${snippet}`); diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/network.rs b/apps/desktop-shell/src-tauri/src/host_bridge/network.rs index 0eab269bc..c51dbc227 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/network.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/network.rs @@ -2,19 +2,35 @@ use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResp use crate::shell::webview::resolve_desktop_network_status; use serde_json::Value; -async fn resolve_desktop_host_bridge_network_status_payload() -> Result { - tauri::async_runtime::spawn_blocking(resolve_desktop_network_status) +fn map_desktop_host_bridge_network_status_result( + status: Result, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + match status { + Ok(status) => ok(request.id.clone(), status), + Err(_) => network_status_unavailable_response(request), + } +} + +async fn resolve_desktop_host_bridge_network_status_payload_with( + resolver: fn() -> Value, +) -> Result { + tauri::async_runtime::spawn_blocking(resolver) .await .map_err(|error| error.to_string()) } +async fn resolve_desktop_host_bridge_network_status_payload() -> Result { + resolve_desktop_host_bridge_network_status_payload_with(resolve_desktop_network_status).await +} + pub(crate) async fn resolve_desktop_host_bridge_network_status( request: &HostBridgeRequest, ) -> HostBridgeResponse { - match resolve_desktop_host_bridge_network_status_payload().await { - Ok(status) => ok(request.id.clone(), status), - Err(_) => network_status_unavailable_response(request), - } + map_desktop_host_bridge_network_status_result( + resolve_desktop_host_bridge_network_status_payload().await, + request, + ) } fn network_status_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { @@ -25,6 +41,7 @@ fn network_status_unavailable_response(request: &HostBridgeRequest) -> HostBridg mod tests { use super::*; use crate::host_bridge::protocol::request; + use serde_json::json; #[test] fn network_status_unavailable_response_is_stable() { @@ -35,4 +52,37 @@ mod tests { assert_eq!(error.code, "host_error"); assert_eq!(error.message, "network status unavailable"); } + + #[test] + fn network_status_success_response_reports_contract_shape() { + let payload = json!({ + "isConnected": true, + "isInternetReachable": true, + "connectionType": "unknown", + "nativeType": "online", + }); + let response = map_desktop_host_bridge_network_status_result( + Ok(payload.clone()), + &request("network.status"), + ); + + assert!(response.ok); + assert_eq!(response.id, "request-1"); + assert_eq!(response.result, Some(payload)); + assert!(response.error.is_none()); + } + + #[test] + fn network_status_failure_hides_native_error_detail() { + let response = map_desktop_host_bridge_network_status_result( + Err("private native resolver detail".to_string()), + &request("network.status"), + ); + + assert!(!response.ok); + let error = response.error.expect("network status error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "network status unavailable"); + assert!(!error.message.contains("private native resolver detail")); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a31f31934..d5a31b572 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3078,3 +3078,10 @@ - 决策:`openMobileShellExternalNavigation(...)` 只在非法 URL 或系统明确不能打开时返回 `false`,原生 `canOpenURL` / `openURL` 异常必须抛给 `ShellApp` 的 `logMobileShellNavigationFailure(...)` 记录;`scanner.scanQrCode` pending 状态必须使用共享 `HOST_BRIDGE_SCANNER_TIMEOUT_MS` 自动拒绝并清理,成功、取消、失败和测试 reset 都必须清理 timer。 - 影响范围:`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/navigation.test.ts`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/scanner.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`。 - 验证方式:`npm run mobile-shell:test -- src/shell/navigation.test.ts src/shell/ShellApp.test.tsx src/host-bridge/scanner.test.ts src/shell/QrScannerOverlay.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳门禁脚本与网络状态测试边界 + +- 背景:Tauri 桌面壳 `network.status` 已由 `host_bridge/network.rs` 统一包装,但成功响应和底层 resolver 失败映射主要靠字符串门禁;同时桌面单端检查没有把 `apps/desktop-shell/scripts/check-config.mjs` 自身纳入脚本清单和生产替身词扫描。 +- 决策:`host_bridge/network.rs` 新增可注入映射 helper,Rust 单测直接覆盖 `network.status` 成功 response shape 和 resolver 失败不暴露原生细节;桌面壳单端配置检查登记并扫描 `scripts/check-config.mjs`,根级文档门禁改为只从“结构门禁按完整相对路径”canonical 段反查文件清单,短清单只保留指针文案。 +- 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::network shell::network`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 9345276fb..62d88b6e8 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -66,7 +66,7 @@ src/ 三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` 承接 Expo DocumentPicker / ImagePicker / File / Sharing 系统交互、取消语义、读写编排和 HostBridge 响应包装,`filePayloads.ts` 承接文件 MIME、大小、base64、文件名清洗和 picker 结果到 HostBridge payload 的边界,`share.ts` / `scanner.ts` / `notifications.ts` 分别承接分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。 -当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信页面包装层 `share-grid/index.js`、`share-grid/index.json`、`share-grid/index.wxml`、`share-grid/index.wxss`、`subscribe-message/index.js`、`subscribe-message/index.json`、`subscribe-message/index.wxml`、`subscribe-message/index.wxss`、`web-view/index.js`、`web-view/index.json`、`web-view/index.wxml`、`web-view/index.wxss`、`wechat-pay/index.js`、`wechat-pay/index.json`、`wechat-pay/index.wxml`、`wechat-pay/index.wxss`;移动源码根 `env.d.ts`;移动桥接层 `appearance.test.ts`、`appearance.ts`、`badge.test.ts`、`badge.ts`、`bridge.ts`、`capabilities.ts`、`clipboard.test.ts`、`clipboard.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`;移动 shell 层 `QrScannerOverlay.test.tsx`、`QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.test.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `appearance.rs`、`badge.rs`、`capabilities.rs`、`clipboard.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`notifications.rs`、`protocol.rs`、`runtime.rs`、`share.rs`、`title.rs`;桌面 shell 层 `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`。 +当前 `npm run check:native-shells` 锁定的生产文件清单以本文后续“结构门禁按完整相对路径反查文档和目录”段落为唯一文档口径;不要再维护只含文件名的短清单,避免测试文件、`file_payloads.rs` 或新增宿主脚本登记发生文档漂移。 生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index c6fd48246..6072077fe 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -39,7 +39,7 @@ AI H5 sandbox 桥接层文件结构按宿主统一为“协议 / 能力清单 / 分发 / 宿主容器行为”四类职责。微信小程序不硬套 Expo / Tauri 的 request 总线:`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`;`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` 承接 Expo DocumentPicker / ImagePicker / File / Sharing 系统交互、取消语义、读写编排和 HostBridge 响应包装,`filePayloads.ts` 承接文件 MIME、大小、base64、文件名清洗和 picker 结果到 HostBridge payload 的边界,`share.ts` / `scanner.ts` / `notifications.ts` 分别承接分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。`npm run check:native-shells` 会检查这些目录清单。 -当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信页面包装层 `share-grid/index.js`、`share-grid/index.json`、`share-grid/index.wxml`、`share-grid/index.wxss`、`subscribe-message/index.js`、`subscribe-message/index.json`、`subscribe-message/index.wxml`、`subscribe-message/index.wxss`、`web-view/index.js`、`web-view/index.json`、`web-view/index.wxml`、`web-view/index.wxss`、`wechat-pay/index.js`、`wechat-pay/index.json`、`wechat-pay/index.wxml`、`wechat-pay/index.wxss`;移动源码根 `env.d.ts`;移动桥接层 `appearance.test.ts`、`appearance.ts`、`badge.test.ts`、`badge.ts`、`bridge.ts`、`capabilities.ts`、`clipboard.test.ts`、`clipboard.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`;移动 shell 层 `QrScannerOverlay.test.tsx`、`QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.test.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `appearance.rs`、`badge.rs`、`capabilities.rs`、`clipboard.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`notifications.rs`、`protocol.rs`、`runtime.rs`、`share.rs`、`title.rs`;桌面 shell 层 `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`。 +当前 `npm run check:native-shells` 锁定的生产文件清单以本文后续“结构门禁按完整相对路径反查文档和目录”段落为唯一文档口径;不要再维护只含文件名的短清单,避免测试文件、`file_payloads.rs` 或新增宿主脚本登记发生文档漂移。 生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index fe6d75a06..4da08c762 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -928,9 +928,20 @@ function readDirectoryNameList(directory, label) { } function assertShellLayerLayoutDocumented(source, label) { + const canonicalMarker = '结构门禁按完整相对路径反查文档和目录'; + const pointerMarker = + '当前 `npm run check:native-shells` 锁定的生产文件清单以本文后续“结构门禁按完整相对路径反查文档和目录”段落为唯一文档口径'; + if (!source.includes(pointerMarker)) { + throw new Error(`${label} must point short shell file lists to the canonical full-path section`); + } + const canonicalStart = source.indexOf(canonicalMarker); + if (canonicalStart < 0) { + throw new Error(`${label} missing canonical shell layer layout section`); + } + const canonicalSource = source.slice(canonicalStart); for (const group of documentedShellLayerGroups) { for (const fileName of group.files) { - if (!source.includes(fileName)) { + if (!canonicalSource.includes(fileName)) { throw new Error(`${label} missing ${group.label}: ${fileName}`); } }