Files
pnpm/pacquet/crates/cli/tests/outdated.rs
Khải ac367fce91 chore(rust/clippy): pedantic, nursery, and some (#12209)
* 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 commit 1d617c3e1f.

* chore(git): revert "fix(fs): use cfg_attr expect instead of allow for Windows-unused mode args"

This reverts commit 155e4a3dde.

* chore(git): revert "style: enable clippy::allow_attributes and allow_attributes_without_reason"

This reverts commit a47d7926f2.

* 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>
2026-06-11 00:43:22 +02:00

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));
}