From 832fca8ae19b77f0e77e50cfcb8401bb1db3cd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 19:44:43 +0800 Subject: [PATCH] =?UTF-8?q?layout=20=E7=9A=84=E7=94=A8=E4=BE=8B=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=8A=E5=AE=9E=E7=8E=B0=E6=8A=84=E4=B8=80=E9=81=8D?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E6=88=90=E5=8F=AA=E9=92=89=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E7=9A=84=E5=9B=9B=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原来的 artifact_relative_path_stays_inside_the_export_directory 断言的是 format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}") 本身,只重复实现、挡不住任何真实改动 - 改成:前端字符串相对路径与宿主 PathBuf 必须指向同一文件;相对路径必须项目内、单层、无 .. 且不出现反斜杠(跨平台的线上字符串);注册表与产物共用一个项目根锚定的 flat 目录;脚本名保住 build:<后缀> 形式且不许退化成裸 build(否则跑掉的是项目自己的构建) - 逐条反证:把产物路径改成 .export/xhs-minitool/ 子目录、把 export_dir 挪进子目录、把 SCRIPT_NAME 改成 build,对应用例分别变红;export:: 50 → 53 例 --- .../src/export/draft/xhs_minitool/layout.rs | 57 ++++++++++++++++--- ...’】导出产物面板与小红书小工具导出-2026-10-05.md | 3 +- ...】导出产物面板与小红书小工具导出-2026-10-05.md | 2 +- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs index 66a6748fe..f78c6f691 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs @@ -35,19 +35,58 @@ pub(crate) fn artifact_relative_path() -> String { #[cfg(test)] mod tests { + use std::path::Component; + use super::*; + /// 前端拿的是**字符串**相对路径(下载命令与 prompt 都用它),宿主写的是 `PathBuf`。两条路各自 + /// 拼一遍,必须落在同一个文件上——谁只改了一边,这条先红。 #[test] - fn artifact_relative_path_stays_inside_the_export_directory() { + fn the_relative_path_names_the_same_file_the_host_writes() { + let root = Path::new("/tmp/project"); + assert_eq!(root.join(artifact_relative_path()), artifact_path(root)); assert_eq!( - artifact_relative_path(), - format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}") - ); - assert_eq!( - registry_path(Path::new("/tmp/project")), - Path::new("/tmp/project") - .join(".export") - .join(REGISTRY_FILE_NAME) + artifact_path(root) + .file_name() + .and_then(|name| name.to_str()), + Some(ARTIFACT_FILE_NAME) ); } + + /// 这个字符串会交给宿主的路径门禁(下载命令复核项目内相对路径)并写进 prompt:它必须是 + /// **项目内、单层、不带 `..`** 的相对路径。前端与 agent 都按它找文件,跑出 `.export/` 就是越界。 + #[test] + fn the_relative_path_cannot_walk_out_of_the_export_directory() { + let relative_path = artifact_relative_path(); + let relative = Path::new(&relative_path); + assert!(relative.is_relative()); + assert!(relative + .components() + .all(|component| matches!(component, Component::Normal(_)))); + // flat 布局:导出目录**直接**下的一个文件名,不为目标建子目录。 + assert_eq!(relative.parent(), Some(Path::new(EXPORT_WORK_RELATIVE_DIR))); + // 跨平台:它是给前端与 prompt 的字符串,Windows 上不许漏出 `\`。 + assert!(!relative_path.contains('\\')); + } + + /// flat 是刻意的决定:注册表与产物共用一个**项目根锚定**的目录,多目标也只是同目录不同文件名。 + /// 谁加了一层 per-target 子目录,这条就红。 + #[test] + fn every_file_of_this_target_shares_one_flat_directory() { + let root = Path::new("/tmp/project"); + assert_eq!(registry_path(root).parent(), artifact_path(root).parent()); + assert_eq!(export_dir(root).parent(), Some(root)); + assert_eq!( + export_dir(root).file_name().and_then(|name| name.to_str()), + Some(EXPORT_WORK_RELATIVE_DIR) + ); + } + + /// 脚本名不是自由文本:`command.exec` 的白名单只放行 `build` 与 `build:<安全后缀>`。 + /// 尤其不能退化成裸 `build`——那会跑掉项目自己的构建,产物永远不会出现。 + #[test] + fn the_script_name_keeps_the_form_the_command_allowlist_requires() { + assert_ne!(SCRIPT_NAME, "build"); + assert!(SCRIPT_NAME.starts_with("build:")); + } } diff --git a/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md b/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md index 684a230fd..f52da7fd8 100644 --- a/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md +++ b/docs/project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md @@ -58,11 +58,12 @@ - `5e2426c8a` 导出收尾判据抽成 `conclude(...)` 并补 5 例确定性用例(非零退出 / 超时 / 产物缺失 / 空产物 / 成功),同时去掉 `XHSMiniToolExportRunResult.exitCode` 这个恒为 0 的死字段;`463341c4b` 导出成功后把脚本输出尾部报给用户;`b3be3ee71` 证据口径修正;`5dd158ee1` 补默认 ignore 的真实起进程用例。 - `1b7741ac6` 适配与修复指令补上「包根在 game/ 时落点写成 `../.export/xhs-minitool.zip`」,skill 契约同步(manifest `2026-08-26.42`);`f8ed4e898` 保存对话框的默认文件名收进下载助手并复用既有取文件名工具;`90f651ddf` 刷新时先撤掉待触发的自动保存(附回归用例;该行为已被下面的自动刷新取代——轮询不再撤掉待保存的输入,因为「磁盘为准」的刷新动作没有了)。 - `ac666daad` 之后:未适配提示改成带入队按钮的卡片、失败卡片按变体分派适配/修/重试、提示只在注册表读回来之后出现(同一时刻只有一张卡片、一个入口)。 +- layout 的用例重写:原来那条 `assert_eq!(artifact_relative_path(), format!(...))` 只是把实现抄了一遍,现改成 4 条只钉关系的用例(相对路径与宿主 `PathBuf` 指向同一文件、不许跑出 `.export/` 且不出现反斜杠、注册表与产物共用一个项目根锚定的 flat 目录、脚本名保住 `build:<后缀>` 形式);逐条反证过会红,`export::` 50 → 53 例。 - `dffe4de55` 导出链路按 ADR 收口(策略拒绝与宿主故障拆成两个变体、输出尾部只回原文与省略量);`df3a6826b` 前端失败通道按 ADR 分流(宿主故障与未分类拒绝原样抛出并去重)与复制失败可见反馈。 - 复制按钮改用 Tauri 剪贴板插件(原 `navigator.clipboard` 在 WebView 里可能根本没有,失败还会静默吞掉),失败态在按钮上可见。 - 指令正文里的契约字符串全部从常量组合(`XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH` + 三个脚本落点),用例的期望值也从常量推;`9d3018a49` / `6dd0dc00c` 之后:去掉刷新按钮,改成按固定间隔自动重读内容;内容与注册表指纹拆成两条命令(自动刷新拿不到写回基线);同步细节(指纹、保存中、待保存)退出前端对外状态与界面。 -已验证:Rust `export::` 50 例(`export::draft::xhs_minitool` 34 例)、`export_bindings` 116 例、`project_snapshot` 22 例、`check:generated-bindings`(118 个文件)、前端 vitest 25 例(新增 19 + 契约 2 + 既有 4)、`ai-game-creator-shell:typecheck`、`ai-game-creator-shell:check:rust:shell`(本机 4 个 shard 都跑到了底,唯一失败是 4 个 `process_session` 真 PTY 用例——与下面的已知环境噪声同一批:沙箱里的 npm 由另一个 node 版本执行、`Cannot find module '../lib/cli.js'`;本次改动涉及的 `export::` 与 `project_snapshot` 全绿)、`agc:skill-pack:check`、`check:doc-index`、`check:encoding`、`git diff --check`。 +已验证:Rust `export::` 53 例(`export::draft::xhs_minitool` 37 例)、`export_bindings` 116 例、`project_snapshot` 22 例、`check:generated-bindings`(118 个文件)、前端 vitest 25 例(新增 19 + 契约 2 + 既有 4)、`ai-game-creator-shell:typecheck`、`ai-game-creator-shell:check:rust:shell`(本机 4 个 shard 都跑到了底,唯一失败是 4 个 `process_session` 真 PTY 用例——与下面的已知环境噪声同一批:沙箱里的 npm 由另一个 node 版本执行、`Cannot find module '../lib/cli.js'`;本次改动涉及的 `export::` 与 `project_snapshot` 全绿)、`agc:skill-pack:check`、`check:doc-index`、`check:encoding`、`git diff --check`。 未验证(唯一开口项):真实 vite 项目上的首轮适配闭环——agent 跑通 `build:xhs-minitool`、产出可上传 zip、二次导出零 agent 调用;以及 `.export/` 换机后重新适配的体感。宿主侧这一段已尽力自动化:`build.rs` 的 `executes_the_project_script_and_requires_a_non_empty_artifact_each_time`(`#[ignore]`)会真起进程执行项目脚本,覆盖「第一次产出 → 删掉产物再跑一次重新产出 → 脚本成功但没产出报 artifactMissing」。本机尝试运行被容器环境挡下,读日志时注意两点、别误判成 bug: - 子进程输出里的 `running 1 test` 是**预期现象**:命令沙箱的垫片就是当前可执行文件本身(`desktop.rs` 按 `--command-sandbox-trampoline` 分流),编译到测试态时 `sandbox_trampoline_arguments()` 会改成 `--exact ...trampoline_child_fixture --ignored`,所以垫片进程是测试二进制在跑那一个 fixture 用例。 diff --git a/docs/project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md b/docs/project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md index 4882ead65..157905695 100644 --- a/docs/project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md +++ b/docs/project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md @@ -50,7 +50,7 @@ ## 证据要求 - 自动化(已落): - - Rust `cargo test ... --bin genarrative-ai-game-creator-shell export::` 50 例,其中 `export::draft::xhs_minitool` 34 例:注册表自动建空表单、格式漂移静默吸收、严格解析拒绝、表单首错、icon 越界/符号链接/扩展名、`hasScript` 现算、`contentHash` 冲突、产物路径守卫,以及构建收尾判据的逐条映射(脚本缺失、非零退出带退出码与输出尾部、超时无退出码、产物缺失、空产物不算、成功回输出尾部、截断只回省略量不回句子)。 + - Rust `cargo test ... --bin genarrative-ai-game-creator-shell export::` 53 例,其中 `export::draft::xhs_minitool` 37 例:注册表自动建空表单、格式漂移静默吸收、严格解析拒绝、表单首错、icon 越界/符号链接/扩展名、`hasScript` 现算、`contentHash` 冲突、产物路径守卫,以及构建收尾判据的逐条映射(脚本缺失、非零退出带退出码与输出尾部、超时无退出码、产物缺失、空产物不算、成功回输出尾部、截断只回省略量不回句子;layout 的 4 例只钉关系不抄实现——前端字符串相对路径与宿主 `PathBuf` 必须指向同一文件、相对路径不许跑出 `.export/`(含 Windows 反斜杠)、注册表与产物共用一个项目根锚定的 flat 目录、脚本名保住 `build:<后缀>` 形式且不许退化成裸 `build`)。 - `export_bindings` 116 例 + `npm run check:generated-bindings`(118 个生成文件)通过。 - 前端 `npx vitest run tests/xhsMinitoolExport.test.tsx`(19 例)、`tests/xhsMinitoolContract.test.ts`(2 例,把前端契约常量的值逐条对到 Rust `layout.rs` / `export/mod.rs`)与既有 `tests/artifactsPanel.test.tsx`(4 例)通过;`npm run ai-game-creator-shell:typecheck` 通过。其中 19 例覆盖(期望值一律从契约常量推,不另抄字面量):指令正文三条硬契约与 game/ 落点、失败现场拼装、失败变体人话映射与三向 action 分派(适配 / 修 / 重试)、未适配卡片自带入队按钮且按钮行不重复、注册表读回来之前不下「还没适配」结论、表单复制、icon 与 zip 下载、冲突逐字段二选一、自动保存带回读到的 `baseHash`、自动重读只更新脚本判据而不动用户未保存的表单、agent 事后改注册表/加脚本不用点刷新就能带回来、导出把脚本输出尾部报给用户、失败变体的产出量与前端拼出的省略说明、认不出形状的拒绝原样抛出且不落成业务提示、宿主侧事实故障先给卡片再抛一次且同一故障只抛一次,复制失败在按钮上可见(不静默吞掉),以及「只有适配/修复才叫 agent」。 - `.export/` 快照排除有定向用例:`project_snapshot::tests::project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials` 同时钉住「同步排除」与「通用口径不排除(Agent 仍可写)」。