lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit a05eefdd45d08fcf15564813b8d734d80575cc87
parent eefc32cc7f76cd761e58b06fe3147c6fd9e298da
Author: triesap <tyson@radroots.org>
Date:   Sun, 28 Jun 2026 02:26:24 +0000

packages: align npm publish payloads

- move TypeScript provenance metadata out of package source trees
- make public package manifests dist-only and release-ready
- enforce generated artifact and package metadata invariants in xtask
- remove wasm-pack ignore files before checking WASM package surfaces

Diffstat:
Mtools/sdk_xtask_import/src/check.rs | 468+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtools/sdk_xtask_import/src/generate.rs | 5+++++
Mtools/sdk_xtask_import/src/manifest.rs | 13++++++++-----
Mtools/sdk_xtask_import/src/output.rs | 22+++++++++++++---------
Mtools/sdk_xtask_import/src/wasm.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 511 insertions(+), 60 deletions(-)

diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs @@ -12,6 +12,12 @@ use crate::{ wasm_declarations::declaration_files, }; +const PACKAGE_VERSION: &str = "0.1.0"; +const PACKAGE_LICENSE: &str = "MIT OR Apache-2.0"; +const PACKAGE_HOMEPAGE: &str = "https://radroots.org"; +const PACKAGE_REPOSITORY_URL: &str = "git+https://github.com/radrootslabs/sdk.git"; +const PUBLISH_ACCESS: &str = "public"; + pub fn check() -> Result<(), String> { validate_package_matrix()?; let root = workspace_root()?; @@ -22,7 +28,9 @@ pub fn check() -> Result<(), String> { let package_dir = root.join(spec.package_dir); let package_json_path = package_dir.join("package.json"); let index_path = package_dir.join("src/index.ts"); - check_package_json(&package_json_path, spec.package_name)?; + let package_json = + check_package_json(&package_json_path, spec.package_name, spec.package_dir)?; + let _ = package_surface_paths(&package_json, &package_json_path)?; if !index_path.is_file() { return Err(format!("missing package index: {}", index_path.display())); } @@ -46,6 +54,13 @@ pub fn check() -> Result<(), String> { return Err(format!("stale generated output: {}", path.display())); } } + let expected = output.provenance_file(); + let path = root.join(&expected.relative_path); + let actual = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + if actual != expected.contents { + return Err(format!("stale generated provenance: {}", path.display())); + } } Ok(()) } @@ -184,7 +199,11 @@ fn check_forbidden_packages(root: &Path) -> Result<(), String> { Ok(()) } -fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> { +fn check_package_json( + path: &Path, + expected_name: &str, + expected_directory: &str, +) -> Result<serde_json::Value, String> { let raw = fs::read_to_string(path) .map_err(|error| format!("failed to read {}: {error}", path.display()))?; let json = serde_json::from_str::<serde_json::Value>(&raw) @@ -199,24 +218,28 @@ fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> { path.display() )); } - let private = json - .get("private") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - if !private { - return Err(format!("package must be private: {}", path.display())); + if json.get("private").is_some() { + return Err(format!( + "public package must not set private: {}", + path.display() + )); } - Ok(()) + require_string_field(&json, path, "version", PACKAGE_VERSION)?; + require_string_field(&json, path, "license", PACKAGE_LICENSE)?; + require_string_field(&json, path, "homepage", PACKAGE_HOMEPAGE)?; + require_string_field(&json, path, "type", "module")?; + require_bool_field(&json, path, "sideEffects", false)?; + check_publish_config(&json, path)?; + check_repository(&json, path, expected_directory)?; + check_package_files(&json, path)?; + check_workspace_dependencies(&json, path)?; + Ok(json) } pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { let package_dir = root.join(spec.package_dir); let package_json_path = package_dir.join("package.json"); - check_package_json(&package_json_path, spec.package_name)?; - let raw = fs::read_to_string(&package_json_path) - .map_err(|error| format!("failed to read {}: {error}", package_json_path.display()))?; - let json = serde_json::from_str::<serde_json::Value>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", package_json_path.display()))?; + let json = check_package_json(&package_json_path, spec.package_name, spec.package_dir)?; let dist_manifest = package_dir.join("dist").join("package.json"); if dist_manifest.exists() { return Err(format!( @@ -224,6 +247,7 @@ pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> dist_manifest.display() )); } + check_no_wasm_dist_ignore_files(&package_dir, spec)?; let surface_paths = package_surface_paths(&json, &package_json_path)?; check_public_wasm_declaration_inventory(&surface_paths, spec)?; for relative in surface_paths { @@ -237,10 +261,200 @@ pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> )); } } + check_wasm_runtime_files(&package_dir, spec)?; check_wasm_declaration_files(&package_dir, spec)?; Ok(()) } +fn require_string_field( + json: &serde_json::Value, + package_json_path: &Path, + field: &'static str, + expected: &str, +) -> Result<(), String> { + let actual = json + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "package.json missing {field}: {}", + package_json_path.display() + ) + })?; + if actual != expected { + return Err(format!( + "package.json {field} mismatch in {}: expected {expected}, found {actual}", + package_json_path.display() + )); + } + Ok(()) +} + +fn require_bool_field( + json: &serde_json::Value, + package_json_path: &Path, + field: &'static str, + expected: bool, +) -> Result<(), String> { + let actual = json + .get(field) + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + format!( + "package.json missing {field}: {}", + package_json_path.display() + ) + })?; + if actual != expected { + return Err(format!( + "package.json {field} mismatch in {}: expected {expected}, found {actual}", + package_json_path.display() + )); + } + Ok(()) +} + +fn check_publish_config(json: &serde_json::Value, package_json_path: &Path) -> Result<(), String> { + let access = json + .get("publishConfig") + .and_then(|value| value.get("access")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "package.json missing publishConfig.access: {}", + package_json_path.display() + ) + })?; + if access != PUBLISH_ACCESS { + return Err(format!( + "package.json publishConfig.access mismatch in {}: expected {PUBLISH_ACCESS}, found {access}", + package_json_path.display() + )); + } + Ok(()) +} + +fn check_repository( + json: &serde_json::Value, + package_json_path: &Path, + expected_directory: &str, +) -> Result<(), String> { + let repository = json.get("repository").ok_or_else(|| { + format!( + "package.json missing repository: {}", + package_json_path.display() + ) + })?; + let repository_type = repository + .get("type") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "package.json missing repository.type: {}", + package_json_path.display() + ) + })?; + if repository_type != "git" { + return Err(format!( + "package.json repository.type mismatch in {}: expected git, found {repository_type}", + package_json_path.display() + )); + } + let repository_url = repository + .get("url") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "package.json missing repository.url: {}", + package_json_path.display() + ) + })?; + if repository_url != PACKAGE_REPOSITORY_URL { + return Err(format!( + "package.json repository.url mismatch in {}: expected {PACKAGE_REPOSITORY_URL}, found {repository_url}", + package_json_path.display() + )); + } + let repository_directory = repository + .get("directory") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "package.json missing repository.directory: {}", + package_json_path.display() + ) + })?; + if repository_directory != expected_directory { + return Err(format!( + "package.json repository.directory mismatch in {}: expected {expected_directory}, found {repository_directory}", + package_json_path.display() + )); + } + Ok(()) +} + +fn check_package_files(json: &serde_json::Value, package_json_path: &Path) -> Result<(), String> { + let files = json + .get("files") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + format!( + "package.json missing files: {}", + package_json_path.display() + ) + })?; + let actual = files + .iter() + .map(|value| { + value.as_str().ok_or_else(|| { + format!( + "package.json files entries must be strings: {}", + package_json_path.display() + ) + }) + }) + .collect::<Result<Vec<_>, _>>()?; + if actual != ["dist"] { + return Err(format!( + "package.json files must publish dist only: {}", + package_json_path.display() + )); + } + Ok(()) +} + +fn check_workspace_dependencies( + json: &serde_json::Value, + package_json_path: &Path, +) -> Result<(), String> { + let Some(dependencies) = json.get("dependencies") else { + return Ok(()); + }; + let dependencies = dependencies.as_object().ok_or_else(|| { + format!( + "package.json dependencies must be an object: {}", + package_json_path.display() + ) + })?; + for (name, value) in dependencies { + if name.starts_with("@radroots/") { + let version = value.as_str().ok_or_else(|| { + format!( + "package.json dependency versions must be strings: {}", + package_json_path.display() + ) + })?; + if version != "workspace:^" { + return Err(format!( + "package.json workspace dependency {name} must use workspace:^ in {}", + package_json_path.display() + )); + } + } + } + Ok(()) +} + fn check_public_wasm_declaration_inventory( surface_paths: &BTreeSet<String>, spec: WasmPackageSpec, @@ -260,6 +474,45 @@ fn check_public_wasm_declaration_inventory( Ok(()) } +fn check_no_wasm_dist_ignore_files( + package_dir: &Path, + spec: WasmPackageSpec, +) -> Result<(), String> { + for file_name in [".gitignore", ".npmignore"] { + let path = package_dir.join("dist").join(file_name); + if path.exists() { + return Err(format!( + "wasm dist ignore file would hide package payload from npm for {}: {}", + spec.package_name, + path.display() + )); + } + } + Ok(()) +} + +fn check_wasm_runtime_files(package_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { + for relative in wasm_runtime_files(spec) { + let path = package_dir.join(&relative); + if !path.is_file() { + return Err(format!( + "missing wasm package runtime artifact for {}: {}", + spec.package_name, + path.display() + )); + } + } + Ok(()) +} + +fn wasm_runtime_files(spec: WasmPackageSpec) -> [String; 3] { + [ + format!("dist/{}.js", spec.out_name), + format!("dist/{}_bg.wasm", spec.out_name), + format!("dist/{}_bg.wasm.d.ts", spec.out_name), + ] +} + fn check_wasm_declaration_files(package_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { for expected in declaration_files(spec)? { let path = package_dir.join(&expected.relative_path); @@ -397,7 +650,8 @@ mod tests { use super::{ check_binding_crate_sources, check_generated_package_artifact_inventory, - check_no_typescript_files, check_package_index, check_wasm_package_surface, + check_no_typescript_files, check_package_index, check_package_json, + check_wasm_package_surface, }; #[test] @@ -481,25 +735,57 @@ mod tests { } #[test] + fn public_package_metadata_rejects_private_packages() { + let root = test_root("private_package_json"); + let package_dir = root.join("packages").join("example"); + fs::create_dir_all(&package_dir).expect("create package"); + let package_json = package_json("example").replace( + r#""sideEffects": false,"#, + r#""private": true, "sideEffects": false,"#, + ); + fs::write(package_dir.join("package.json"), package_json).expect("write package json"); + + let error = check_package_json( + &package_dir.join("package.json"), + "@radroots/example", + "packages/example", + ) + .expect_err("private package rejected"); + + assert!(error.contains("must not set private")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn public_package_metadata_rejects_src_generated_payloads() { + let root = test_root("src_generated_package_payload"); + let package_dir = root.join("packages").join("example"); + fs::create_dir_all(&package_dir).expect("create package"); + let package_json = package_json("example").replace( + r#""files": ["dist"]"#, + r#""files": ["dist", "src/generated"]"#, + ); + fs::write(package_dir.join("package.json"), package_json).expect("write package json"); + + let error = check_package_json( + &package_dir.join("package.json"), + "@radroots/example", + "packages/example", + ) + .expect_err("src generated package payload rejected"); + + assert!(error.contains("files must publish dist only")); + let _ = fs::remove_dir_all(root); + } + + #[test] fn wasm_package_surface_requires_exported_dist_files() { let root = test_root("wasm_surface"); let package_dir = root.join("packages").join("example-wasm"); fs::create_dir_all(package_dir.join("dist")).expect("create dist"); fs::write( package_dir.join("package.json"), - r#"{ - "name": "@radroots/example-wasm", - "private": true, - "main": "./dist/example.js", - "types": "./dist/example.d.ts", - "exports": { - ".": { - "types": "./dist/example.d.ts", - "import": "./dist/example.js", - "default": "./dist/example.js" - } - } -}"#, + package_json("example-wasm").replace("./dist/index", "./dist/example"), ) .expect("write package json"); fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); @@ -526,6 +812,7 @@ mod tests { dto_declaration("example_bg.wasm.d.ts"), ) .expect("write d.ts"); + fs::write(package_dir.join("dist").join("example_bg.wasm"), b"\0asm").expect("write wasm"); let error = check_wasm_package_surface(&root, spec) .expect_err("unknown declaration inventory rejected"); assert!(error.contains("missing wasm declaration inventory")); @@ -540,13 +827,7 @@ mod tests { fs::create_dir_all(package_dir.join("dist")).expect("create dist"); fs::write( package_dir.join("package.json"), - r#"{ - "name": "@radroots/example-wasm", - "private": true, - "main": "./dist/example.js", - "types": "./dist/example.d.ts", - "exports": "./dist/example.js" -}"#, + package_json("example-wasm").replace("./dist/index", "./dist/example"), ) .expect("write package json"); fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); @@ -580,22 +861,91 @@ mod tests { } #[test] + fn wasm_package_surface_rejects_dist_ignore_files() { + let root = test_root("wasm_dist_ignore"); + let package_dir = root.join("packages").join("example-wasm"); + fs::create_dir_all(package_dir.join("dist")).expect("create dist"); + fs::write( + package_dir.join("package.json"), + package_json("example-wasm").replace("./dist/index", "./dist/example"), + ) + .expect("write package json"); + fs::write(package_dir.join("dist").join(".gitignore"), "*\n").expect("write ignore"); + let spec = WasmPackageSpec { + key: "example", + crate_name: "radroots_example_wasm", + crate_dir: "crates/example_wasm", + package_name: "@radroots/example-wasm", + package_dir: "packages/example-wasm", + out_name: "example", + out_dir: "../../packages/example-wasm/dist", + }; + + let error = check_wasm_package_surface(&root, spec).expect_err("dist ignore file rejected"); + assert!(error.contains("would hide package payload")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn wasm_package_surface_requires_runtime_wasm_artifact() { + let root = test_root("wasm_runtime_artifact"); + let package_dir = root.join("packages").join("example-wasm"); + fs::create_dir_all(package_dir.join("dist")).expect("create dist"); + fs::write( + package_dir.join("package.json"), + package_json("example-wasm").replace("./dist/index", "./dist/example"), + ) + .expect("write package json"); + fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); + fs::write( + package_dir.join("dist").join("example.d.ts"), + dto_declaration("example.d.ts"), + ) + .expect("write d.ts"); + fs::write( + package_dir.join("dist").join("example_bg.wasm.d.ts"), + dto_declaration("example_bg.wasm.d.ts"), + ) + .expect("write wasm d.ts"); + let spec = WasmPackageSpec { + key: "example", + crate_name: "radroots_example_wasm", + crate_dir: "crates/example_wasm", + package_name: "@radroots/example-wasm", + package_dir: "packages/example-wasm", + out_name: "example", + out_dir: "../../packages/example-wasm/dist", + }; + + let error = check_wasm_package_surface(&root, spec).expect_err("missing wasm rejected"); + assert!(error.contains("missing wasm package runtime artifact")); + + let _ = fs::remove_dir_all(root); + } + + #[test] fn wasm_package_surface_rejects_subpath_exports() { let root = test_root("wasm_subpath_exports"); let package_dir = root.join("packages").join("example-wasm"); fs::create_dir_all(package_dir.join("dist")).expect("create dist"); fs::write( package_dir.join("package.json"), - r#"{ - "name": "@radroots/example-wasm", - "private": true, - "main": "./dist/example.js", - "types": "./dist/example.d.ts", - "exports": { + package_json("example-wasm") + .replace("./dist/index", "./dist/example") + .replace( + r#""exports": { + ".": { + "types": "./dist/example.d.ts", + "import": "./dist/example.js", + "default": "./dist/example.js" + } + }"#, + r#""exports": { ".": "./dist/example.js", "./extra": "./dist/extra.js" - } -}"#, + }"#, + ), ) .expect("write package json"); fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); @@ -644,4 +994,36 @@ mod tests { "// @generated by cargo xtask generate wasm via dto_bindgen\n// Do not edit by hand.\nexport type Generated = \"{name}\";\n" ) } + + fn package_json(name: &str) -> String { + format!( + r#"{{ + "name": "@radroots/{name}", + "version": "0.1.0", + "description": "Example package", + "license": "MIT OR Apache-2.0", + "homepage": "https://radroots.org", + "repository": {{ + "type": "git", + "url": "git+https://github.com/radrootslabs/sdk.git", + "directory": "packages/{name}" + }}, + "publishConfig": {{ + "access": "public" + }}, + "type": "module", + "sideEffects": false, + "files": ["dist"], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": {{ + ".": {{ + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }} + }} +}}"# + ) + } } diff --git a/tools/sdk_xtask_import/src/generate.rs b/tools/sdk_xtask_import/src/generate.rs @@ -10,6 +10,11 @@ pub fn generate_ts() -> Result<(), String> { .join(generated_file.relative_path); crate::fs::write_if_changed(&path, &generated_file.contents)?; } + let provenance_file = output.provenance_file(); + crate::fs::write_if_changed( + &root.join(&provenance_file.relative_path), + &provenance_file.contents, + )?; println!("generated TypeScript package {}", output.spec.package_name); } Ok(()) diff --git a/tools/sdk_xtask_import/src/manifest.rs b/tools/sdk_xtask_import/src/manifest.rs @@ -2,8 +2,8 @@ use serde_json::json; use crate::package_matrix::PackageSpec; -pub fn manifest_file_name() -> &'static str { - "sdk-manifest.json" +pub fn manifest_relative_path(spec: PackageSpec) -> String { + format!("contracts/provenance/typescript/{}.json", spec.key) } pub fn package_manifest(spec: PackageSpec) -> serde_json::Value { @@ -18,13 +18,16 @@ pub fn package_manifest(spec: PackageSpec) -> serde_json::Value { #[cfg(test)] mod tests { use crate::{ - manifest::{manifest_file_name, package_manifest}, + manifest::{manifest_relative_path, package_manifest}, package_matrix::package_specs, }; #[test] - fn manifest_name_is_stable() { - assert_eq!(manifest_file_name(), "sdk-manifest.json"); + fn manifest_path_is_outside_package_source() { + assert_eq!( + manifest_relative_path(package_specs()[0]), + "contracts/provenance/typescript/core.json" + ); } #[test] diff --git a/tools/sdk_xtask_import/src/output.rs b/tools/sdk_xtask_import/src/output.rs @@ -2,7 +2,7 @@ use dto_bindgen_backend_ts::{DtoTypesModule, TypeScriptImport, TypeScriptModule} use crate::{ dto_roots, - manifest::manifest_file_name, + manifest::manifest_relative_path, manifest::package_manifest, package_matrix::{PackageSpec, package_specs}, ts::{generated_constants_file, generated_header, generated_kinds_file, generated_types_file}, @@ -67,12 +67,15 @@ impl PackageOutput { contents: render_ts(kinds_ts, None), }); } - files.push(GeneratedFile { - relative_path: format!("src/generated/{}", manifest_file_name()), - contents: render_manifest(self.spec), - }); files } + + pub fn provenance_file(&self) -> GeneratedFile { + GeneratedFile { + relative_path: manifest_relative_path(self.spec), + contents: render_manifest(self.spec), + } + } } pub fn package_outputs() -> Result<Vec<PackageOutput>, String> { @@ -271,15 +274,16 @@ mod tests { .iter() .find(|file| file.relative_path == "src/generated/types.ts") .expect("types file"); - let manifest = files - .iter() - .find(|file| file.relative_path == "src/generated/sdk-manifest.json") - .expect("manifest file"); + let manifest = output.provenance_file(); assert_eq!( types.contents, "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n" ); + assert_eq!( + manifest.relative_path, + "contracts/provenance/typescript/core.json" + ); assert!(manifest.contents.contains("\"generated\": true")); assert!( !files diff --git a/tools/sdk_xtask_import/src/wasm.rs b/tools/sdk_xtask_import/src/wasm.rs @@ -1,6 +1,6 @@ use std::{ collections::BTreeSet, - env, fs, + env, fs, io, path::{Path, PathBuf}, process::Command, }; @@ -47,6 +47,7 @@ pub fn generate(args: &[String]) -> Result<(), String> { spec.key, spec.package_name, spec.key )); } + remove_wasm_pack_gitignore(&dist_dir, spec)?; write_declaration_files(&root, spec)?; check_wasm_package_surface(&root, spec)?; println!("generated wasm package {}", spec.package_name); @@ -87,6 +88,35 @@ fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> { ] } +fn remove_wasm_pack_gitignore(dist_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { + let ignore_path = dist_dir.join(".gitignore"); + let contents = match fs::read_to_string(&ignore_path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "failed to read wasm-pack ignore file for {}: {}: {error}", + spec.package_name, + ignore_path.display() + )); + } + }; + if contents.trim() != "*" { + return Err(format!( + "unexpected wasm-pack ignore file for {}: {}; refusing to remove it", + spec.package_name, + ignore_path.display() + )); + } + fs::remove_file(&ignore_path).map_err(|error| { + format!( + "failed to remove wasm-pack ignore file for {}: {}: {error}", + spec.package_name, + ignore_path.display() + ) + }) +} + fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { match args { [] => Ok(wasm_package_specs().to_vec()), @@ -240,8 +270,8 @@ mod tests { use crate::package_matrix::wasm_package_specs; use super::{ - resolve_path_tool_from_path, rustup_tool, selected_specs, target_list_contains, - wasm_pack_args, + remove_wasm_pack_gitignore, resolve_path_tool_from_path, rustup_tool, selected_specs, + target_list_contains, wasm_pack_args, }; #[test] @@ -268,6 +298,33 @@ mod tests { } #[test] + fn removes_wasm_pack_generated_gitignore() { + let root = test_root("wasm_pack_gitignore"); + let dist_dir = root.join("dist"); + fs::create_dir_all(&dist_dir).expect("create dist"); + fs::write(dist_dir.join(".gitignore"), "*\n").expect("write ignore"); + + remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]).expect("remove ignore"); + + assert!(!dist_dir.join(".gitignore").exists()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn refuses_unexpected_wasm_pack_gitignore_contents() { + let root = test_root("custom_gitignore"); + let dist_dir = root.join("dist"); + fs::create_dir_all(&dist_dir).expect("create dist"); + fs::write(dist_dir.join(".gitignore"), "!keep\n").expect("write ignore"); + + let error = remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]) + .expect_err("custom ignore rejected"); + + assert!(error.contains("unexpected wasm-pack ignore file")); + let _ = fs::remove_dir_all(root); + } + + #[test] fn path_tool_resolution_reports_missing_tools() { let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new("")) .expect_err("missing");