From a2e441bc360412d83a44af772da8572f970ee5bb Mon Sep 17 00:00:00 2001 From: TrueNine Date: Tue, 11 Aug 2026 22:29:43 +0800 Subject: [PATCH] Refactor output plans and cleanup logic - Introduced workspace scope handling in base_output_plans to manage cleanup files. - Updated cleanup logic to exclude workspace scope files from deletion. - Enhanced path normalization across output plans to ensure consistent path formatting. - Refactored regex compilation in project_prompt and skill modules for efficiency. - Improved error handling and logging in clean, dry_run, and install services. - Simplified output file building logic in output_plan service. - Added tests to validate new cleanup behavior and path normalization. --- .github/workflows/ci.yml | 2 +- cli/local-tests/tests/agents_md_smoke.rs | 7 +- cli/local-tests/tests/claude_smoke.rs | 7 +- cli/local-tests/tests/clean_blackbox.rs | 7 +- cli/local-tests/tests/codex_smoke.rs | 7 +- cli/local-tests/tests/dry_run_smoke.rs | 88 +++++++--- cli/local-tests/tests/install_smoke.rs | 7 +- cli/local-tests/tests/logging_dry_run.rs | 97 ++++++++-- cli/local-tests/tests/packaging_smoke.rs | 149 ++++++++++++++++ cli/local-tests/tests/rules_source_smoke.rs | 7 +- cli/local-tests/tests/support/opencode.rs | 7 +- cli/local-tests/tests/trae_smoke.rs | 7 +- doc/app/docs/[[...mdxPath]]/layout.tsx | 6 +- mcp/integrate-tests/tests/packaging_smoke.rs | 80 --------- mcp/tests/packaging_smoke.rs | 166 ++++++++++++++++++ sdk/src/domain/base_output_plans.rs | 47 ++++- .../output_plans/claude_code_output_plan.rs | 10 +- .../domain/output_plans/codex_output_plan.rs | 7 +- .../domain/output_plans/droid_output_plan.rs | 5 +- sdk/src/domain/output_plans/mod.rs | 76 +++++--- .../output_plans/opencode_output_plan.rs | 7 +- .../domain/output_plans/warp_output_plan.rs | 16 +- sdk/src/infra/desk_paths.rs | 13 +- sdk/src/infra/file_ops.rs | 11 +- sdk/src/infra/git_fs.rs | 38 +++- sdk/src/infra/md_compiler/expression_eval.rs | 4 +- sdk/src/repositories/project_prompt.rs | 5 +- sdk/src/repositories/skill.rs | 68 +++---- sdk/src/services/clean_service.rs | 29 +-- sdk/src/services/dry_run_service.rs | 2 +- sdk/src/services/install_service.rs | 2 +- sdk/src/services/output_plan.rs | 8 +- sdk/src/services/prompt_service.rs | 4 + 33 files changed, 740 insertions(+), 256 deletions(-) create mode 100644 cli/local-tests/tests/packaging_smoke.rs delete mode 100644 mcp/integrate-tests/tests/packaging_smoke.rs create mode 100644 mcp/tests/packaging_smoke.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eaa0c6c..c06c7738 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: run: cargo test -p tnmsc-local-tests packaging_smoke_covers_release_binary_and_global_install -- --exact --nocapture - name: MCP packaging smoke - run: cargo test -p tnmsm-local-tests packaging_smoke_covers_release_binary_and_global_install -- --exact --nocapture + run: cargo test -p tnmsm packaging_smoke_covers_release_binary_and_global_install -- --exact --nocapture gui-smoke: needs: changes diff --git a/cli/local-tests/tests/agents_md_smoke.rs b/cli/local-tests/tests/agents_md_smoke.rs index 961491d4..452396d5 100644 --- a/cli/local-tests/tests/agents_md_smoke.rs +++ b/cli/local-tests/tests/agents_md_smoke.rs @@ -93,7 +93,12 @@ fn init_git_repo(project_dir: &Path) { .arg("--quiet") .current_dir(project_dir) .output() - .unwrap_or_else(|error| panic!("failed to run git init in {}: {error}", project_dir.display())); + .unwrap_or_else(|error| { + panic!( + "failed to run git init in {}: {error}", + project_dir.display() + ) + }); assert!( output.status.success(), diff --git a/cli/local-tests/tests/claude_smoke.rs b/cli/local-tests/tests/claude_smoke.rs index 6159f156..353e6f25 100644 --- a/cli/local-tests/tests/claude_smoke.rs +++ b/cli/local-tests/tests/claude_smoke.rs @@ -111,7 +111,12 @@ fn init_git_repo(project_dir: &Path) { .arg("--quiet") .current_dir(project_dir) .output() - .unwrap_or_else(|error| panic!("failed to run git init in {}: {error}", project_dir.display())); + .unwrap_or_else(|error| { + panic!( + "failed to run git init in {}: {error}", + project_dir.display() + ) + }); assert!( output.status.success(), diff --git a/cli/local-tests/tests/clean_blackbox.rs b/cli/local-tests/tests/clean_blackbox.rs index fc7db17d..874b7d8c 100644 --- a/cli/local-tests/tests/clean_blackbox.rs +++ b/cli/local-tests/tests/clean_blackbox.rs @@ -102,7 +102,12 @@ fn init_git_repo(project_dir: &Path) { .arg("--quiet") .current_dir(project_dir) .output() - .unwrap_or_else(|error| panic!("failed to run git init in {}: {error}", project_dir.display())); + .unwrap_or_else(|error| { + panic!( + "failed to run git init in {}: {error}", + project_dir.display() + ) + }); assert!( output.status.success(), diff --git a/cli/local-tests/tests/codex_smoke.rs b/cli/local-tests/tests/codex_smoke.rs index 4b71f0cc..ba2cf3fd 100644 --- a/cli/local-tests/tests/codex_smoke.rs +++ b/cli/local-tests/tests/codex_smoke.rs @@ -122,7 +122,12 @@ fn init_git_repo(project_dir: &Path) { .arg("--quiet") .current_dir(project_dir) .output() - .unwrap_or_else(|error| panic!("failed to run git init in {}: {error}", project_dir.display())); + .unwrap_or_else(|error| { + panic!( + "failed to run git init in {}: {error}", + project_dir.display() + ) + }); assert!( output.status.success(), diff --git a/cli/local-tests/tests/dry_run_smoke.rs b/cli/local-tests/tests/dry_run_smoke.rs index 4a14644b..88525b2f 100644 --- a/cli/local-tests/tests/dry_run_smoke.rs +++ b/cli/local-tests/tests/dry_run_smoke.rs @@ -1,31 +1,79 @@ -//! 本地裸机 dry-run 测试:验证 tnmsc dry-run 不写入文件。 -//! -//! **前提**:项目已配置。 +//! Isolated black-box test for the `tnmsc dry-run` no-write guarantee. + +use std::fs; +use std::path::Path; use tnmsc_local_tests::LocalTestRunner; -/// Verify that `tnmsc dry-run` reports what would be written but does NOT create -/// any project files. The core safety guarantee of dry-run mode. #[test] fn local_dry_run_does_not_write_project_files() { - let runner = LocalTestRunner::new(); - runner.assert_project_ready(); - - // 先 clean 确保干净 - let clean = runner.clean(); - clean.assert_success("tnmsc clean before dry-run"); + let temp_root = std::env::temp_dir().join(format!( + "tnmsc-local-dry-run-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let temp_home = temp_root.join("home"); + let workspace_dir = temp_root.join("workspace"); + let project_dir = workspace_dir.join("memory-sync"); + let prompt_dir = workspace_dir.join("aindex").join("app").join("memory-sync"); - assert!( - !runner.file_exists("CLAUDE.md"), - "~/workspace/memory-sync/CLAUDE.md should not exist before dry-run" - ); + fs::create_dir_all(temp_home.join(".aindex")).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + fs::create_dir_all(&prompt_dir).unwrap(); + init_git_repo(&project_dir); + fs::write(prompt_dir.join("agt.mdx"), "# Project memory\n").unwrap(); + fs::write( + temp_home.join(".aindex").join(".tnmsc.json"), + serde_json::json!({ + "workspaceDir": workspace_dir.to_string_lossy(), + "plugins": { + "agentsMd": false, + "git": false, + "readme": false, + "vscode": false, + "zed": false, + "jetbrains": false, + "jetbrainsCodeStyle": false, + "claudeCode": true, + "codex": false, + "cursor": false, + "droid": false, + "gemini": false, + "kiro": false, + "opencode": false, + "qoder": false, + "trae": false, + "traeCn": false, + "windsurf": false + } + }) + .to_string(), + ) + .unwrap(); - // dry-run 不应写入文件 - let dry = runner.dry_run(); - dry.assert_success("tnmsc dry-run"); + let runner = LocalTestRunner::with_cwd(&project_dir); + let temp_home_string = temp_home.to_string_lossy().into_owned(); + let claude_path = project_dir.join("CLAUDE.md"); + assert!(!claude_path.exists()); + runner + .run_at_with_env(&project_dir, &["dry-run"], &[("HOME", &temp_home_string)]) + .assert_success("isolated tnmsc dry-run"); assert!( - !runner.file_exists("CLAUDE.md"), - "~/workspace/memory-sync/CLAUDE.md should not be created by dry-run" + !claude_path.exists(), + "project CLAUDE.md should not be created by dry-run" ); } + +fn init_git_repo(project_dir: &Path) { + let output = std::process::Command::new("git") + .arg("init") + .arg("--quiet") + .current_dir(project_dir) + .output() + .unwrap(); + assert!(output.status.success(), "git init should succeed"); +} diff --git a/cli/local-tests/tests/install_smoke.rs b/cli/local-tests/tests/install_smoke.rs index e0319e58..5b0a76d5 100644 --- a/cli/local-tests/tests/install_smoke.rs +++ b/cli/local-tests/tests/install_smoke.rs @@ -100,7 +100,12 @@ fn init_git_repo(project_dir: &Path) { .arg("--quiet") .current_dir(project_dir) .output() - .unwrap_or_else(|error| panic!("failed to run git init in {}: {error}", project_dir.display())); + .unwrap_or_else(|error| { + panic!( + "failed to run git init in {}: {error}", + project_dir.display() + ) + }); assert!( output.status.success(), diff --git a/cli/local-tests/tests/logging_dry_run.rs b/cli/local-tests/tests/logging_dry_run.rs index fa4a656e..f0cf7196 100644 --- a/cli/local-tests/tests/logging_dry_run.rs +++ b/cli/local-tests/tests/logging_dry_run.rs @@ -1,25 +1,90 @@ -//! Dry-run 可观测性测试:验证 dry-run 命令输出足够的可观测信息。 +//! Dry-run observability tests for isolated local fixtures. + +use std::fs; +use std::path::PathBuf; use tnmsc_local_tests::LocalTestRunner; -/// Verify that `--trace` dry-run outputs all major spans: -/// config.load, context.collect, output.build. +struct IsolatedLoggingDryRunFixture { + runner: LocalTestRunner, + temp_home: PathBuf, + project_dir: PathBuf, +} + +impl IsolatedLoggingDryRunFixture { + fn new() -> Self { + let temp_root = std::env::temp_dir().join(format!( + "tnmsc-local-logging-dry-run-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let temp_home = temp_root.join("home"); + let workspace_dir = temp_root.join("workspace"); + let project_dir = workspace_dir.join("memory-sync"); + let aindex_dir = workspace_dir.join("aindex"); + + fs::create_dir_all(temp_home.join(".aindex")).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + fs::create_dir_all(&aindex_dir).unwrap(); + fs::write( + temp_home.join(".aindex").join(".tnmsc.json"), + serde_json::json!({ + "workspaceDir": workspace_dir.to_string_lossy(), + "plugins": { + "agentsMd": false, + "git": false, + "readme": false, + "vscode": false, + "zed": false, + "jetbrains": false, + "jetbrainsCodeStyle": false, + "claudeCode": true, + "codex": false, + "cursor": false, + "droid": false, + "gemini": false, + "kiro": false, + "opencode": false, + "qoder": false, + "trae": false, + "traeCn": false, + "windsurf": false + } + }) + .to_string(), + ) + .unwrap(); + fs::write(aindex_dir.join("workspace.mdx"), "# Workspace memory\n").unwrap(); + + Self { + runner: LocalTestRunner::with_cwd(&project_dir), + temp_home, + project_dir, + } + } + + fn run(&self, args: &[&str]) -> tnmsc_local_tests::CommandResult { + let temp_home = self.temp_home.to_string_lossy().into_owned(); + self + .runner + .run_at_with_env(&self.project_dir, args, &[("HOME", &temp_home)]) + } +} + #[test] fn dry_run_outputs_key_spans_and_events() { - let runner = LocalTestRunner::new(); - runner.assert_project_ready(); - - let result = runner.run(&["--trace", "dry-run"]); - result.assert_success("tnmsc --trace dry-run"); + let fixture = IsolatedLoggingDryRunFixture::new(); + let result = fixture.run(&["--trace", "dry-run"]); + result.assert_success("isolated tnmsc --trace dry-run"); - // 验证顶层事件 assert!( result.stdout.contains("### Running dry-run"), "dry-run should output 'Running dry-run'. stdout:\n{}", result.stdout ); - - // 验证主要 Span assert!( result.stdout.contains("### config.load started"), "dry-run should output 'config.load' span. stdout:\n{}", @@ -37,16 +102,12 @@ fn dry_run_outputs_key_spans_and_events() { ); } -/// Verify that `--info` dry-run outputs a plan summary (what files would be written). #[test] fn dry_run_outputs_plan_preview() { - let runner = LocalTestRunner::new(); - runner.assert_project_ready(); - - let result = runner.run(&["--info", "dry-run"]); - result.assert_success("tnmsc --info dry-run"); + let fixture = IsolatedLoggingDryRunFixture::new(); + let result = fixture.run(&["--info", "dry-run"]); + result.assert_success("isolated tnmsc --info dry-run"); - // Info 级别应该输出计划摘要 assert!( result.stdout.contains("Planned") || result.stdout.contains("No files needed updates"), "dry-run should output plan summary. stdout:\n{}", diff --git a/cli/local-tests/tests/packaging_smoke.rs b/cli/local-tests/tests/packaging_smoke.rs new file mode 100644 index 00000000..b5f70499 --- /dev/null +++ b/cli/local-tests/tests/packaging_smoke.rs @@ -0,0 +1,149 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after the Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("tnmsc-packaging-{}-{nonce}", std::process::id())); + fs::create_dir_all(&path).expect("packaging temp dir should be created"); + Self(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn host_package() -> (&'static str, &'static str) { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => ("win32-x64-msvc", "tnmsc.exe"), + ("linux", "x86_64") => ("linux-x64-gnu", "tnmsc"), + ("linux", "aarch64") => ("linux-arm64-gnu", "tnmsc"), + ("macos", "x86_64") => ("darwin-x64", "tnmsc"), + ("macos", "aarch64") => ("darwin-arm64", "tnmsc"), + (os, arch) => panic!("unsupported packaging smoke host: {os}-{arch}"), + } +} + +fn npm() -> &'static str { + if cfg!(windows) { "npm.cmd" } else { "npm" } +} + +fn copy_file(source: impl AsRef, target: impl AsRef) { + let target = target.as_ref(); + fs::create_dir_all(target.parent().expect("target should have a parent")) + .expect("target parent should be created"); + fs::copy(source, target).expect("package file should be copied"); +} + +fn run(command: &mut Command, context: &str) -> std::process::Output { + let output = command + .output() + .unwrap_or_else(|error| panic!("{context} should start: {error}")); + assert!( + output.status.success(), + "{context} should succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn pack(package_dir: &Path, output_dir: &Path) -> PathBuf { + let output = run( + Command::new(npm()) + .args(["pack", "--json", "--pack-destination"]) + .arg(output_dir) + .current_dir(package_dir), + "npm pack", + ); + let reports: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("npm pack output should be JSON"); + let filename = reports[0]["filename"] + .as_str() + .expect("npm pack report should include filename"); + output_dir.join(filename) +} + +#[test] +fn packaging_smoke_covers_release_binary_and_global_install() { + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("local-tests should be nested under cli") + .to_path_buf(); + let source_root = workspace_root.join("cli"); + let (suffix, binary_name) = host_package(); + let temp = TempDir::new(); + let staged = temp.0.join("staged"); + let platform_dir = staged.join("npm").join(suffix); + let tarballs = temp.0.join("tarballs"); + let install_root = temp.0.join("install"); + fs::create_dir_all(&tarballs).expect("tarball dir should be created"); + + copy_file( + source_root.join("package.json"), + staged.join("package.json"), + ); + copy_file( + source_root.join("bin/tnmsc.js"), + staged.join("bin/tnmsc.js"), + ); + copy_file( + source_root.join("schema/tnmsc.schema.json"), + staged.join("schema/tnmsc.schema.json"), + ); + copy_file( + source_root.join("npm").join(suffix).join("package.json"), + platform_dir.join("package.json"), + ); + + let release_binary = workspace_root.join("target/release").join(binary_name); + assert!( + release_binary.is_file(), + "release binary missing at {}; run cargo build --release -p tnmsc first", + release_binary.display() + ); + run( + Command::new(&release_binary) + .args(["assemble-npm", "--profile", "release"]) + .env("TNMSC_NPM_PACKAGE_ROOT", &staged) + .env("TNMSC_WORKSPACE_ROOT", &workspace_root) + .current_dir(&workspace_root), + "tnmsc assemble-npm", + ); + + let platform_tarball = pack(&platform_dir, &tarballs); + let main_tarball = pack(&staged, &tarballs); + run( + Command::new(npm()) + .arg("install") + .arg("--ignore-scripts") + .arg("--prefix") + .arg(&install_root) + .arg(&main_tarball) + .arg(&platform_tarball), + "isolated npm install", + ); + + let shim = install_root + .join("node_modules/.bin") + .join(if cfg!(windows) { "tnmsc.cmd" } else { "tnmsc" }); + let help = run(Command::new(&shim).arg("help"), "installed tnmsc help"); + let stdout = String::from_utf8_lossy(&help.stdout); + for subcommand in tnmsc_local_tests::EXPECTED_SUBCOMMANDS { + assert!( + stdout.contains(subcommand), + "help should include {subcommand}: {stdout}" + ); + } +} diff --git a/cli/local-tests/tests/rules_source_smoke.rs b/cli/local-tests/tests/rules_source_smoke.rs index 75e6a71e..b26378e9 100644 --- a/cli/local-tests/tests/rules_source_smoke.rs +++ b/cli/local-tests/tests/rules_source_smoke.rs @@ -79,7 +79,12 @@ fn init_git_repo(project_dir: &Path) { .arg("--quiet") .current_dir(project_dir) .output() - .unwrap_or_else(|error| panic!("failed to run git init in {}: {error}", project_dir.display())); + .unwrap_or_else(|error| { + panic!( + "failed to run git init in {}: {error}", + project_dir.display() + ) + }); assert!( output.status.success(), diff --git a/cli/local-tests/tests/support/opencode.rs b/cli/local-tests/tests/support/opencode.rs index 830a4554..836fa873 100644 --- a/cli/local-tests/tests/support/opencode.rs +++ b/cli/local-tests/tests/support/opencode.rs @@ -142,7 +142,12 @@ fn init_git_repo(project_dir: &Path) { .arg("--quiet") .current_dir(project_dir) .output() - .unwrap_or_else(|error| panic!("failed to run git init in {}: {error}", project_dir.display())); + .unwrap_or_else(|error| { + panic!( + "failed to run git init in {}: {error}", + project_dir.display() + ) + }); assert!( output.status.success(), diff --git a/cli/local-tests/tests/trae_smoke.rs b/cli/local-tests/tests/trae_smoke.rs index 5743a5a8..c84b9672 100644 --- a/cli/local-tests/tests/trae_smoke.rs +++ b/cli/local-tests/tests/trae_smoke.rs @@ -104,7 +104,12 @@ fn init_git_repo(project_dir: &Path) { .arg("--quiet") .current_dir(project_dir) .output() - .unwrap_or_else(|error| panic!("failed to run git init in {}: {error}", project_dir.display())); + .unwrap_or_else(|error| { + panic!( + "failed to run git init in {}: {error}", + project_dir.display() + ) + }); assert!( output.status.success(), diff --git a/doc/app/docs/[[...mdxPath]]/layout.tsx b/doc/app/docs/[[...mdxPath]]/layout.tsx index 571999f0..2eccf941 100644 --- a/doc/app/docs/[[...mdxPath]]/layout.tsx +++ b/doc/app/docs/[[...mdxPath]]/layout.tsx @@ -4,7 +4,7 @@ import {DocsSectionNav} from '../../../components/docs-section-nav' import {isDocSectionName} from '../../../lib/docs-sections' import {siteConfig, withBasePath} from '../../../lib/site' -type PageMapItem = { +interface PageMapItem { readonly name?: string readonly route?: string readonly title?: string @@ -15,7 +15,7 @@ function DocsSidebar({pageMap}: {readonly pageMap: readonly PageMapItem[]}) { return (