mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-30 02:45:11 -04:00
* chore: enable clippy::pedantic lint group for pacquet workspace * style(pacquet): comply with clippy::pedantic Apply clippy's machine-applicable pedantic fixes across the workspace (inlined format args, removed needless borrows/closures, added must_use, etc.), fix a few doc-comment backtick nits, and drop pointless #[inline(always)] on trivial accessors. Opt specific pedantic lints back out in [workspace.lints.clippy] with documented justifications, grouped into false positives, library-API hygiene that doesn't fit an internal CLI, suggestions that conflict with the cardinal rule of porting pnpm 1:1, and opinionated style. * style: taplo-format Cargo.toml lint table * style(pnpr): comply with clippy::pedantic in merged auth backend code Re-apply pedantic compliance to the networked-SQLite auth backend that landed on main (#12186, #12199/#12206): doc-comment backticks, #[must_use] on constructors and status_code, i64::from over `as`, map_or, and a method-reference closure. * docs(clippy): trim and inline the pedantic allow-list comments * docs(clippy): note perfectionist supersedes many_single_char_names * docs(clippy): note pnpm-mirroring rationale on structure/naming lints * docs(clippy): mark unused_async as deferred pending audit * style: enable clippy::match_wildcard_for_single_variants * refactor: enable clippy::unused_self Convert two self-less private methods (overrides pick_most_specific, tarball head_only_result) to associated functions. * refactor: enable clippy::ref_option Widen engine_json to Option<&str>; #[expect] the two serde serialize_with helpers, which serde must call as f(&field, ser). * perf: enable clippy::trivially_copy_pass_by_ref Pass the 1-byte Copy types NodeLinker and FilterWorkspaceProjectsOptions by value; #[expect] the serde skip_serializing_if helper is_false. * perf: enable clippy::assigning_clones Use clone_from for seven field assignments to reuse allocations. * style: enable clippy::manual_let_else Convert 27 match/if-let guards to let-else; preserve the non-UTF-8 skip rationale comment in the directory walker. * style: enable clippy::default_trait_access Name the concrete type on Default::default() call sites; #[expect] two struct-literal test fixtures where naming each field type would force ~20 imports. * refactor: enable clippy::format_push_string Replace push_str(&format!(...)) with write!/writeln! into the target String (local 'use std::fmt::Write as _'); writeln! preserves the exact LF/CRLF shell-shim output. * refactor: enable clippy::needless_pass_by_value Take by reference where the argument is only read (incl. dropping some redundant clones in resolve_peers' recursion). Where converting would cascade badly, #[expect] with a reason: functions that destructure/consume the arg (build_resolve_result, PrefetchingResolver, S3Store::new), the by-value `impl IntoIterator + Clone` in build_direct_deps_by_importer, and the serde/test helpers whose owned fixtures keep call sites clean. * fix(perfectionist): satisfy dylint after format_push_string changes Add trailing commas to the multi-line writeln! shell-shim templates (macro_trailing_comma) and merge the new `fmt::Write as _` imports into each file's existing `use std::{...}` block (import_granularity). * docs(clippy): explain missing_errors_doc suppression; mark missing_panics_doc deferred * fix(perfectionist): collapse fmt::{self, Write as _} in work_env imports The format_push_string Write import landed as a sibling fmt:: path next to the existing fmt import; merge them so import_granularity passes. * style: enable clippy::return_self_not_must_use Add #[must_use] to the WorkspaceTreeCtx builder methods, matching the #[must_use] already on the parallel TreeCtx builders. * perf: enable clippy::large_stack_arrays Heap-allocate the 64 KiB read buffer in verify_file_integrity with a Vec instead of placing it on the stack. * chore(clippy): enable clippy::nursery group Enable the nursery lint group on the pacquet/pnpr workspace and bring the code into compliance. Fixed in code: - iter_on_single_items: [x].into_iter()/.iter() -> std::iter::once - equatable_if_let: pattern match -> equality check (the install_accelerator rewrite wraps in a multi-line matches!, which gets a trailing comma for perfectionist::macro_trailing_comma) - needless_pass_by_ref_mut: load_pending_row/apply_write_msg take &StoreIndex Opted back out in Cargo.toml, each with a documented justification: use_self, too_long_first_doc_paragraph, missing_const_for_fn, option_if_let_else, significant_drop_tightening, redundant_pub_crate, derive_partial_eq_without_eq, branches_sharing_code, useless_let_if_seq, single_option_map, iter_with_drain, literal_string_with_formatting_args, collection_is_never_read. Dropped the now-redundant individual nursery warns (needless_collect, or_fun_call, redundant_clone) the group now covers, plus the default-on unnecessary_lazy_evaluations. Kept clone_on_ref_ptr and if_then_some_else_none (restriction lints not enabled by any group). * style: bring merged main code into clippy pedantic compliance The 17 commits merged from main predate this branch's pedantic/nursery lint config, so their new code tripped pedantic lints. Apply the machine-applicable fixes (uninlined_format_args, if_not_else, elidable_lifetime_names, must_use_candidate, single_match_else, map_unwrap_or, default_trait_access, assigning_clones, doc_markdown, ...) and re-add the documented #[expect(needless_pass_by_value)] on S3Store::new that this branch had carried on the now-replaced file. * style: bring merged main code into clippy pedantic compliance The 28 commits merged from main predate this branch's lint config, so their new code tripped pedantic lints. Apply the machine-applicable fixes (uninlined_format_args, manual_let_else, needless_raw_string_hashes, redundant_closure_for_method_calls, map_unwrap_or, elidable_lifetime_names, doc_markdown, ...) plus a few by hand: - derive Copy on LinkSlotsParallel (all fields are Copy/refs) to clear needless_pass_by_value without a signature change - deduplicate_all takes &[Vec<DepPath>] (it only borrows the duplicates) - pick_most_specific becomes an associated fn (it never used self) - default_trait_access -> concrete types; assigning_clones -> clone_from; format_push_string -> write! - #[expect] with reasons where a fix would churn main's feature code: needless_pass_by_value on the recursive resolve_node and a test helper, and float_cmp on two deterministic-fixture assertions * style: enable clippy::allow_attributes and allow_attributes_without_reason Both are restriction lints (not implied by any group), enabled alongside the existing clone_on_ref_ptr / if_then_some_else_none. Convert every #[allow(...)] (including one nested in cfg_attr) to #[expect(...)]; all already carried a reason, so allow_attributes_without_reason is satisfied. Drop two now-redundant suppressions surfaced by the conversion: a duplicated #[expect(too_many_arguments)] on fetch_and_extract_zip_once (a prior merge left both an allow and an expect), and the #[expect(dead_code)] on MissingPeerInfo's fields (the #[derive(Debug, Clone)] already reads them, so dead_code never fired). clone_on_ref_ptr was already enabled. mod_module_files is intentionally NOT enabled: it mandates mod.rs, the opposite of the flat module.rs pattern this project requires (CODE_STYLE_GUIDE.md, enforced by perfectionist::flat_module_pattern). * style: enable clippy::mod_module_files to enforce the flat module layout mod_module_files bans mod.rs files, enforcing the flat module.rs pattern this project already uses (0 mod.rs in the tree, so no violations). Update CODE_STYLE_GUIDE.md to cite it as the enforcer; perfectionist's flat_module_pattern is being retired in favor of this Clippy rule. * fix(perfectionist): trailing comma on wrapped assert_eq! in workspace_yaml tests The default_trait_access fix lengthened the assert_eq! so fmt wrapped it to multi-line, which perfectionist::macro_trailing_comma requires to end with a trailing comma. * fix(fs): use cfg_attr expect instead of allow for Windows-unused mode args With clippy::allow_attributes enabled, the #[cfg_attr(windows, allow(unused))] on make_file_executable and the ensure_file/write_atomic mode params fails Windows CI. Switch to #[cfg_attr(windows, expect(unused, reason = ...))]; on Windows the lint fires (Unix mode unused there) so the expectation is fulfilled, and the attribute stays inert on Unix. * fix(fs): drop the Windows unused suppression on ensure_file's mode arg ensure_file forwards mode to verify_or_rewrite unconditionally, so it is used on Windows too; the #[cfg_attr(windows, expect(unused))] was therefore unfulfilled and failed Windows CI under -D warnings. write_atomic and make_file_executable keep their expect — they use mode/file only under #[cfg(unix)], so the lint fires (and the expectation holds) on Windows. * chore(git): revert "fix(fs): drop the Windows unused suppression on ensure_file's mode arg" This reverts commit1d617c3e1f. * chore(git): revert "fix(fs): use cfg_attr expect instead of allow for Windows-unused mode args" This reverts commit155e4a3dde. * chore(git): revert "style: enable clippy::allow_attributes and allow_attributes_without_reason" This reverts commita47d7926f2. * style: bring merged main code into clippy compliance + fix merge mismatch - Add & at the two run_postinstall_hooks / run_project_lifecycle_scripts call sites: this branch widened lifecycle.rs to take &RunPostinstallHooks, but main's by-value call sites came in via the conflict resolution. - pedantic fixes on main's new code: must_use_candidate, unnested_or_patterns, manual_let_else, default_trait_access, iter_on_single_items, and trivially_copy_pass_by_ref (map_node_linker takes NodeLinker by value). --------- Co-authored-by: Claude <noreply@anthropic.com>
309 lines
12 KiB
Rust
309 lines
12 KiB
Rust
use assert_cmd::prelude::*;
|
|
use command_extra::CommandExtra;
|
|
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
|
|
use std::{ffi::OsStr, fs, path::Path, process::Command};
|
|
use tempfile::TempDir;
|
|
|
|
const DEP: &str = "@pnpm.e2e/dep-of-pkg-with-1-dep";
|
|
const FOO: &str = "@pnpm.e2e/foo";
|
|
const DEPRECATED: &str = "@pnpm.e2e/deprecated";
|
|
|
|
fn setup() -> (TempDir, std::path::PathBuf, AddMockedRegistry) {
|
|
let CommandTempCwd { root, workspace, npmrc_info, .. } =
|
|
CommandTempCwd::init().add_mocked_registry();
|
|
(root, workspace, npmrc_info)
|
|
}
|
|
|
|
fn pacquet(workspace: &Path, args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> Command {
|
|
Command::cargo_bin("pacquet")
|
|
.expect("find the pacquet binary")
|
|
.with_current_dir(workspace)
|
|
.with_args(args)
|
|
}
|
|
|
|
fn write_manifest(workspace: &Path, dependencies: &str) {
|
|
let manifest = format!(
|
|
r#"{{ "name": "test-outdated", "version": "1.0.0", "dependencies": {dependencies} }}"#,
|
|
);
|
|
fs::write(workspace.join("package.json"), manifest).expect("write package.json");
|
|
}
|
|
|
|
/// `outdated` reports a dependency whose `latest` tag is newer than the
|
|
/// installed (in-range) version, and exits non-zero.
|
|
#[test]
|
|
fn outdated_reports_newer_version() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
// `^100.0.0` installs the highest in-range version (100.1.0); the
|
|
// `latest` tag is 101.0.0, so the dependency is outdated.
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "^100.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output = pacquet(&workspace, ["outdated"]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(1), "outdated deps present should exit 1");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains(DEP), "report should mention the package: {stdout}");
|
|
assert!(stdout.contains("100.1.0"), "report should show the current version: {stdout}");
|
|
assert!(stdout.contains("101.0.0"), "report should show the latest version: {stdout}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// `--compatible` compares against the highest in-range version, so a
|
|
/// dependency already at the top of its range is not reported even when a
|
|
/// newer major exists.
|
|
#[test]
|
|
fn outdated_compatible_ignores_out_of_range_releases() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "^100.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
// Default run is outdated (101.0.0 latest)...
|
|
assert_eq!(
|
|
pacquet(&workspace, ["outdated"]).output().unwrap().status.code(),
|
|
Some(1),
|
|
"default outdated should flag the out-of-range 101.0.0",
|
|
);
|
|
|
|
// ...but --compatible only considers in-range versions; 100.1.0 is
|
|
// already the highest in `^100.0.0`, so nothing is outdated.
|
|
let output =
|
|
pacquet(&workspace, ["outdated", "--compatible"]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(0), "compatible run should be up to date");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(!stdout.contains(DEP), "compatible run should report nothing: {stdout}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// `--format json` emits a parseable object keyed by package name.
|
|
#[test]
|
|
fn outdated_json_format() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "^100.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output = pacquet(&workspace, ["outdated", "--format", "json"])
|
|
.output()
|
|
.expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(stdout.trim()).expect("outdated --format json should emit valid JSON");
|
|
let entry = &value[DEP];
|
|
assert_eq!(entry["current"], "100.1.0");
|
|
assert_eq!(entry["latest"], "101.0.0");
|
|
assert_eq!(entry["dependencyType"], "dependencies");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// A dependency pinned to its `latest` tag is not outdated; the report is
|
|
/// empty and the exit code is zero.
|
|
#[test]
|
|
fn outdated_up_to_date_exits_zero() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "101.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output = pacquet(&workspace, ["outdated"]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(0), "up-to-date deps should exit 0");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(!stdout.contains(DEP), "no outdated dep should be reported: {stdout}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// A package selector restricts the report to matching dependencies.
|
|
#[test]
|
|
fn outdated_pattern_filters_dependencies() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "^100.0.0", "{FOO}": "^1.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output = pacquet(&workspace, ["outdated", FOO]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains(FOO), "selected package should be reported: {stdout}");
|
|
assert!(!stdout.contains(DEP), "unselected package should be excluded: {stdout}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// A package at its newest version but marked deprecated is still
|
|
/// reported as outdated.
|
|
#[test]
|
|
fn outdated_reports_deprecated_package() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEPRECATED}": "1.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output = pacquet(&workspace, ["outdated"]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(1), "deprecated dep should be flagged");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains(DEPRECATED), "deprecated package should be reported: {stdout}");
|
|
assert!(stdout.contains("Deprecated"), "should mark the version deprecated: {stdout}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// Without a lockfile, `outdated` fails with
|
|
/// `ERR_PNPM_OUTDATED_NO_LOCKFILE` rather than silently reporting nothing.
|
|
#[test]
|
|
fn outdated_without_lockfile_errors() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "^100.0.0" }}"#));
|
|
|
|
let output = pacquet(&workspace, ["outdated"]).output().expect("run pacquet outdated");
|
|
assert!(!output.status.success(), "outdated without a lockfile should fail");
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(stderr.contains("No lockfile in directory"), "stderr should explain why: {stderr}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// `--recursive` is rejected until workspace-wide inspection is ported.
|
|
#[test]
|
|
fn outdated_recursive_is_rejected() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "^100.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output =
|
|
pacquet(&workspace, ["outdated", "--recursive"]).output().expect("run pacquet outdated");
|
|
assert!(!output.status.success(), "recursive outdated should be rejected");
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(stderr.contains("not supported yet"), "stderr should say it is unsupported: {stderr}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// A dependency-free manifest is reported as up to date (exit 0) even
|
|
/// when there is no lockfile — the no-lockfile error only fires for a
|
|
/// manifest that actually declares dependencies. Ports pnpm's "should
|
|
/// return empty when there is no lockfile and no dependencies".
|
|
#[test]
|
|
fn outdated_no_dependencies_no_lockfile_is_empty() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
fs::write(workspace.join("package.json"), r#"{ "name": "test-outdated", "version": "1.0.0" }"#)
|
|
.expect("write package.json");
|
|
|
|
let output = pacquet(&workspace, ["outdated"]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(0), "no dependencies should exit 0");
|
|
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty(), "report should be empty");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// `--format json` emits `{}` (exit 0) when nothing is outdated. Ports
|
|
/// pnpm's "format json when there are no outdated dependencies".
|
|
#[test]
|
|
fn outdated_json_empty_when_up_to_date() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "101.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output = pacquet(&workspace, ["outdated", "--format", "json"])
|
|
.output()
|
|
.expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(0));
|
|
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "{}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// `--no-table` (list format) prints names and `=>` arrows instead of a
|
|
/// box-drawing table. Ports pnpm's "no table".
|
|
#[test]
|
|
fn outdated_list_format() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEP}": "^100.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output =
|
|
pacquet(&workspace, ["outdated", "--no-table"]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains(DEP), "list should mention the package: {stdout}");
|
|
assert!(stdout.contains("=>"), "list uses the `=>` arrow: {stdout}");
|
|
assert!(!stdout.contains('┌'), "list format must not draw a table: {stdout}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// `--long` adds the deprecation reason to the report. Ports pnpm's
|
|
/// "--long with only deprecated packages".
|
|
#[test]
|
|
fn outdated_long_shows_deprecation_details() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "{DEPRECATED}": "1.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output =
|
|
pacquet(&workspace, ["outdated", "--long"]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("This package is deprecated"),
|
|
"--long should print the deprecation reason: {stdout}",
|
|
);
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// An npm-aliased dependency is reported under its real registry name,
|
|
/// not the alias. Ports pnpm's "`outdated()` aliased dependency".
|
|
#[test]
|
|
fn outdated_npm_alias_reports_real_name() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
write_manifest(&workspace, &format!(r#"{{ "positive": "npm:{FOO}@^1.0.0" }}"#));
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let output = pacquet(&workspace, ["outdated"]).output().expect("run pacquet outdated");
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.contains(FOO), "report should use the real package name: {stdout}");
|
|
|
|
drop((root, anchor));
|
|
}
|
|
|
|
/// `--prod` and `--dev` restrict the report to the matching dependency
|
|
/// group. Ports pnpm's "showing only prod or dev dependencies".
|
|
#[test]
|
|
fn outdated_prod_dev_filtering() {
|
|
let (root, workspace, anchor) = setup();
|
|
|
|
fs::write(
|
|
workspace.join("package.json"),
|
|
format!(
|
|
r#"{{ "name": "test-outdated", "version": "1.0.0", "dependencies": {{ "{DEP}": "^100.0.0" }}, "devDependencies": {{ "{FOO}": "^1.0.0" }} }}"#,
|
|
),
|
|
)
|
|
.expect("write package.json");
|
|
pacquet(&workspace, ["install"]).assert().success();
|
|
|
|
let prod = pacquet(&workspace, ["outdated", "--prod"]).output().expect("run pacquet outdated");
|
|
let prod_out = String::from_utf8_lossy(&prod.stdout);
|
|
assert!(prod_out.contains(DEP), "--prod includes the prod dep: {prod_out}");
|
|
assert!(!prod_out.contains(FOO), "--prod excludes the dev dep: {prod_out}");
|
|
|
|
let dev = pacquet(&workspace, ["outdated", "--dev"]).output().expect("run pacquet outdated");
|
|
let dev_out = String::from_utf8_lossy(&dev.stdout);
|
|
assert!(dev_out.contains(FOO), "--dev includes the dev dep: {dev_out}");
|
|
assert!(!dev_out.contains(DEP), "--dev excludes the prod dep: {dev_out}");
|
|
|
|
drop((root, anchor));
|
|
}
|