diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1821ce6..8b58c34c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,13 +127,13 @@ jobs: GH_TOKEN: ${{ github.token }} run: .github/scripts/ci-changes.sh - # ── Formatting, the changelog fragments, the docs' links, xtask's tests ──── - # The fragment and link checks and the tests build only xtask, which has no - # dependencies. On a pull request the fragment check sees the merge with - # the base, so a fragment under a milestone that was released after the - # pull request was written fails. xtask's tests run in `test` as well; here - # they also run for a change to the files they read outside xtask (the - # release group), which leaves the rest of the Rust jobs out. + # ── Formatting, changelog fragments, docs' links, unused code, xtask tests ── + # The fragment, link and unused-code checks and the tests build only xtask, + # which has no dependencies. On a pull request the fragment check sees the + # merge with the base, so a fragment under a milestone that was released + # after the pull request was written fails. xtask's tests run in `test` as + # well; here they also run for a change to the files they read outside + # xtask (the release group), which leaves the rest of the Rust jobs out. # No job waits for this one (`needs: [fmt]`): a job behind `needs` joins # the runner queue only once fmt is done, so when the runners are busy the # long jobs would queue twice (#337). @@ -158,13 +158,18 @@ jobs: # to a renamed file). - name: Documentation links (docs/SUMMARY.md) run: cargo xtask docs --check + # What rustc and clippy cannot see is unused: a pub item nothing else + # names, a dependency its crate never imports, a dev-dependency that + # repeats a dependency, a workspace dependency no member inherits. + - name: Unused code and dependencies (cargo xtask unused) + run: cargo xtask unused - name: cargo test -p xtask run: cargo test -p xtask - # ── Clippy, and the sqlx offline-cache drift guard ───────────────────────── - # Both are check builds; the drift guard runs here rather than in `test`, - # whose build it shares nothing with (--all-features), to keep it off the - # longest job's path. + # ── Clippy, the sqlx offline-cache drift guard, the API docs' links ──────── + # All are check builds; the drift guard and the docs run here rather than in + # `test`, whose build they share nothing with (--all-features), to keep them + # off the longest job's path. clippy: name: clippy runs-on: ubuntu-latest @@ -220,6 +225,11 @@ jobs: env: SQLX_OFFLINE: "" run: cargo sqlx prepare --check --workspace -- --all-targets --all-features + # rustdoc with its warnings denied: a doc link to a renamed or removed + # item fails. After the drift guard, whose check build of every feature + # leaves only the crates' docs to build. + - name: API docs (cargo xtask doc-check) + run: cargo xtask doc-check # ── Dependency advisories (RustSec) ──────────────────────────────────────── # Fails on any known vulnerability in Cargo.lock. Accepted advisories, each @@ -446,6 +456,9 @@ jobs: run: pnpm install --frozen-lockfile - name: Typecheck (tsc --noEmit) run: pnpm run tsc + # Files, exports and dependencies nothing uses (web/knip.jsonc). + - name: Unused code (knip) + run: pnpm run knip - name: Unit tests (Vitest) run: pnpm run test - name: Production build (vite build -> web/dist) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 478a6913..7d014156 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -138,6 +138,7 @@ URLs before handing off to Vite. cargo xtask web-test # Vitest + React Testing Library (unit/component) cargo xtask web-e2e # Playwright e2e (needs Docker; builds the UI first) cd web && pnpm run tsc # typecheck +cd web && pnpm run knip # files, exports and dependencies nothing uses cd web && pnpm run e2e:install # one-time: download the Playwright browser ``` @@ -793,6 +794,45 @@ dependencies that are slow without it, `rsa` and `argon2` (`Cargo.toml`, `[profile.dev.package.*]`): the mock IdPs generate RSA keys, and every test server hashes its bootstrap admin password. +## Unused code and stale doc links + +rustc warns about a private function nothing calls, but not about a `pub` +one, which another crate might call; neither Cargo nor `tsc` says when a +dependency is no longer imported; and a doc comment that links an item +renamed or removed since still compiles. Three checks catch what they leave, +and CI runs all three: + +- **`cargo xtask unused`** (the `fmt` job) fails on a `pub` item (a function, + type, trait, constant or static) whose name appears nowhere else in the + workspace's Rust sources; a dependency its crate's sources never name + (`name::…`, `use name`); a dev-dependency that repeats one of its crate's + dependencies without adding a feature; and an entry of + `[workspace.dependencies]` that no member inherits. It searches the sources + as text and errs towards silence: a name that also appears elsewhere (in a + comment, a string, as a local) counts as used. Delete what it reports. A dependency the code needs without naming it (one + that only turns on a feature of another) goes, with the reason, into its + crate's `Cargo.toml`: + + ```toml + [package.metadata.unused] + # + ignore = [""] + ``` + +- **`cd web && pnpm run knip`** (the `web` job) fails on a file of `web/` + nothing imports, an export no other module imports, and a package of + `package.json` nothing imports ([knip](https://knip.dev), configured in + `web/knip.jsonc`). An export only its own module uses is left alone. + +- **`cargo xtask doc-check`** (the `clippy` job) builds the crates' API docs + (`cargo doc --workspace --all-features --no-deps --document-private-items`) + with rustdoc's warnings denied, so a doc link (`` [`name`] ``) to an item + that is gone fails, as does an ambiguous one. Links from public to private + items are allowed, since the private items are documented too. + +None of them knows whether a comment's prose is still true: that stays with +the review. + ## Resetting & troubleshooting - `cargo xtask down` stops the containers but **keeps** the Postgres volume. To @@ -819,7 +859,8 @@ server hashes its bootstrap admin password. `up` · `down` · `dev` · `run` · `seed` · `test` · `e2e` · `check` · `fmt` · `clippy` · `build` · `migrate` · `prepare` (regenerate sqlx offline data) · -`prepare-check` · `psql` · `logs` · `ps` · `clean`. +`prepare-check` · `unused` · `doc-check` (both: see [Unused code and stale doc +links](#unused-code-and-stale-doc-links)) · `psql` · `logs` · `ps` · `clean`. Web UI: `web` (build) · `web-install` · `web-dev` (Vite HMR) · `web-test` (Vitest) · `web-e2e` (Playwright). diff --git a/crates/cache/src/lib.rs b/crates/cache/src/lib.rs index c62b3cc2..1c76fb2a 100644 --- a/crates/cache/src/lib.rs +++ b/crates/cache/src/lib.rs @@ -36,7 +36,7 @@ //! ## TTL approach //! //! moka's own per-entry TTL (via an `Expiry`) governs L1. We *also* stamp each -//! stored entry with an absolute `expires_at` [`Instant`] and treat an entry +//! stored entry with an absolute `expires_at` [`Instant`](std::time::Instant) and treat an entry //! whose deadline has passed as a **miss** on read. Belt-and-suspenders: it //! makes expiry deterministic for tests (which can't depend on moka's //! background eviction timing) while moka's expiry bounds memory. diff --git a/crates/core/src/auth/local_password.rs b/crates/core/src/auth/local_password.rs index 6514bd38..64e32d6d 100644 --- a/crates/core/src/auth/local_password.rs +++ b/crates/core/src/auth/local_password.rs @@ -32,7 +32,7 @@ //! [`ProvenIdentity::local`]. //! //! The provider only *vouches for* the username; mapping that identity back to -//! a concrete [`UserPrincipal`] (user id, admin flag) is the chain caller's job +//! a concrete [`UserPrincipal`](super::UserPrincipal) (user id, admin flag) is the chain caller's job //! (see [`super::chain`] mapping helpers). This keeps providers free of the //! `UserPrincipal` shape, so authorization can change without touching them. //! diff --git a/crates/core/src/authz/cache.rs b/crates/core/src/authz/cache.rs index 9662401c..4f5818c3 100644 --- a/crates/core/src/authz/cache.rs +++ b/crates/core/src/authz/cache.rs @@ -2,7 +2,7 @@ //! //! The [`Authz`](crate::auth::authz::Authz) seam answers `can_read` / //! `can_write` / `can_delete` from a principal's resolved -//! [`EffectiveSet`](super::EffectiveSet). Computing that set is a DB round trip +//! [`EffectiveSet`]. Computing that set is a DB round trip //! (`user_role → repository_privilege`), far too costly to run on every //! download/restore request. This module sits in front of that load with //! [`forge_cache::Cache`] — **in-process only** diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0144aa0d..06f428a5 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,7 +6,7 @@ //! * [`domain`] — neutral types ([`domain::ParsedUpload`]) produced by parsing //! an uploaded package, the boundary between a concrete format and the rest //! of the system. -//! * [`format`] — the object-safe [`format::Format`] seam every package format +//! * [`format`](mod@format) — the object-safe [`format::Format`] seam every package format //! implements (parse, version normalize/compare, id lowercasing). //! * [`auth`] — principal extraction (token/password/anonymous), client-IP //! resolution behind trusted proxies, and the authorization seam over the diff --git a/crates/core/src/oidc/claims.rs b/crates/core/src/oidc/claims.rs index 9ed2643f..7e12428d 100644 --- a/crates/core/src/oidc/claims.rs +++ b/crates/core/src/oidc/claims.rs @@ -8,7 +8,8 @@ //! //! The server crate owns the full `OidcConfig` (TOML/env). To avoid a //! `core → server` dependency inversion, the server maps the claim-relevant -//! fields into this [`ClaimMapping`] when it builds the [`super::OidcProvider`]. +//! fields into this [`ClaimMapping`] when it builds its OIDC runtime +//! (`OidcRuntime::build`). //! The mapping logic and its tests then live entirely in `core`. use super::jwks::IdTokenClaims; diff --git a/crates/core/src/oidc/jwks.rs b/crates/core/src/oidc/jwks.rs index e33f0bd7..be914858 100644 --- a/crates/core/src/oidc/jwks.rs +++ b/crates/core/src/oidc/jwks.rs @@ -4,7 +4,7 @@ //! rationale. Everything here is parameterised over an injected //! [`reqwest::Client`] (so callers pass the no-redirect, https-only client from //! [`crate::net::ssrf::outbound_metadata_client`]) and a [`forge_cache::Cache`], which is -//! what makes the whole module unit-testable with a [`wiremock`] HTTP server and +//! what makes the whole module unit-testable with a `wiremock` HTTP server and //! a self-signed token — no real IdP, no real network. //! //! Discovery documents and key sets decide which tokens verify, so they are diff --git a/crates/core/src/repo/group.rs b/crates/core/src/repo/group.rs index 6667f2e2..d8c4d287 100644 --- a/crates/core/src/repo/group.rs +++ b/crates/core/src/repo/group.rs @@ -4,7 +4,7 @@ //! an ordered list of **member** repositories (hosted or proxy). Members carry a //! per-group `priority` (unique within the group); the highest priority wins //! ties. Resolution is **identity-aware**: for a calling [`Principal`] `P`, only -//! members `P` may read (per [`authz::can_read`]) are visible. In particular a +//! members `P` may read (per [`Authz::can_read`]) are visible. In particular a //! mixed-anonymity group serves an anonymous caller only its `anonymous_read` //! members, while an authenticated caller sees them all. //! @@ -90,7 +90,7 @@ //! past it stops (fail-closed). //! //! Identity-aware filtering is applied at **every** level: a member (group or -//! leaf) is descended-into / included only if the principal [`can_read`] it +//! leaf) is descended-into / included only if the principal can read it //! ([`Authz::can_read`]). The prefer-hosted ownership check above is the one //! deliberate exception: it only ever *removes* proxy candidates, so it //! consults every hosted leaf. Being able to read an outer group grants **no** diff --git a/crates/core/src/repo/mod.rs b/crates/core/src/repo/mod.rs index a0f00b80..8c993fa1 100644 --- a/crates/core/src/repo/mod.rs +++ b/crates/core/src/repo/mod.rs @@ -475,7 +475,7 @@ pub struct SearchParams { pub include_semver2: bool, /// Let a proxy forward the search to its upstream's search service, so /// packages it has not cached yet are found too (see - /// [`proxy`](crate::repo::proxy)). The NuGet search route sets it; the + /// [`proxy`]). The NuGet search route sets it; the /// browse UI, which lists what Forge holds, does not. Defaults to `true`. pub include_upstream: bool, } diff --git a/crates/core/src/retention/mod.rs b/crates/core/src/retention/mod.rs index 6deae522..114b0d3d 100644 --- a/crates/core/src/retention/mod.rs +++ b/crates/core/src/retention/mod.rs @@ -2,7 +2,7 @@ //! //! This module is the **format-agnostic** core of retention: it defines the //! serde [`RetentionPolicy`] stored in `repository.retention_policy` (with -//! [`validate_policy`]) and a **pure** evaluator ([`evaluate`] / [`evaluate_proxy`]) +//! [`validate_policy`]) and a **pure** evaluator ([`evaluate()`] / [`evaluate_proxy`]) //! that, given a policy and a component/proxy snapshot, computes the set of //! versions to delete or unlist. Versions are ordered through the //! [`crate::format::Format`] seam, never by importing a concrete format. diff --git a/crates/core/src/retention/model.rs b/crates/core/src/retention/model.rs index 8677c62a..55f27368 100644 --- a/crates/core/src/retention/model.rs +++ b/crates/core/src/retention/model.rs @@ -3,7 +3,7 @@ //! The shape: //! a policy is a list of **rules**; a component is acted on if **any** rule //! selects it (union), but `keep_last_n` / `keep_n_most_downloaded` protections -//! always win (a protected version is never deleted — see [`crate::retention::evaluate`]). +//! always win (a protected version is never deleted — see [`crate::retention::evaluate()`]). //! Each rule's protections are computed over the versions that rule's own //! filters admit (see [`Criteria`]); the protected sets are then unioned. //! diff --git a/crates/core/src/retention/worker.rs b/crates/core/src/retention/worker.rs index e8e6fa8e..351f3cfa 100644 --- a/crates/core/src/retention/worker.rs +++ b/crates/core/src/retention/worker.rs @@ -11,7 +11,8 @@ //! 1. parses + validates the claimed repo's `retention_policy`; //! 2. snapshots its candidate components (and, for proxy repos, cached //! versions); -//! 3. runs the **pure** [`evaluate`] / [`evaluate_proxy`]; +//! 3. runs the **pure** [`evaluate`](super::evaluate()) / +//! [`evaluate_proxy`](super::evaluate_proxy); //! 4. [`apply`]s the decisions — each in its own tx, decref + `blob_outbox` //! only (no direct S3 delete); //! 5. records the run summary + schedules the next run on the ledger: at the diff --git a/crates/db/src/authz_dao.rs b/crates/db/src/authz_dao.rs index 75a94f6f..c763407b 100644 --- a/crates/db/src/authz_dao.rs +++ b/crates/db/src/authz_dao.rs @@ -36,7 +36,7 @@ pub const READER_ROLE: &str = "reader"; /// Shared shape of the effective-set query rows (user + anonymous variants). /// The two `sqlx::query!` calls generate distinct anonymous structs with the -/// same columns; [`into_query_row!`] converts each into this adapter so +/// same columns; `into_query_row!` converts each into this adapter so /// [`map_grant_row`] is reused across both call sites. struct EffectiveQueryRow { level: String, diff --git a/crates/db/src/validated_credential_dao.rs b/crates/db/src/validated_credential_dao.rs index 105f14bf..88729d6e 100644 --- a/crates/db/src/validated_credential_dao.rs +++ b/crates/db/src/validated_credential_dao.rs @@ -480,8 +480,9 @@ pub async fn list_auth_successes(pool: &PgPool, username: &str) -> sqlx::Result< /// Revoke every cached verdict for a username (optionally scoped to one /// provider): the admin "force re-auth user X" / deactivate-user path. Returns /// the number of rows deleted (verdicts plus any in-flight singleflight -/// claims, whose winners are thereby fenced — see [`finalize_claim`]). The caller must also drop the matching cache -/// entries via the [`forge_cache`] invalidation backplane. +/// claims, whose winners are thereby fenced — see [`finalize_claim`]). The +/// caller must also drop the matching cache entries via the `forge-cache` +/// invalidation backplane. pub async fn revoke_by_username( pool: &PgPool, username: &str, diff --git a/crates/e2e/src/mock_oidc.rs b/crates/e2e/src/mock_oidc.rs index c255a72b..f2f7ad9a 100644 --- a/crates/e2e/src/mock_oidc.rs +++ b/crates/e2e/src/mock_oidc.rs @@ -290,10 +290,10 @@ admin_claim = "roles" claims } - /// Rotate the signing key: snapshot the **old** signer (so a test can still - /// sign a token with the now-retired key), publish only the **new** key in - /// the JWKS, and make it the active signer. Returns the retired signer's - /// behaviour through [`Self::set_next_id_token_with_retired_key`]. + /// Rotate the signing key: publish only the **new** key in the JWKS and make + /// it the active signer, so the old `kid` is a miss in the JWKS from now on. + /// A token signed by a key the JWKS does not hold is + /// [`Self::set_next_id_token_with_unknown_key`]'s. pub async fn rotate_key(&self, new_kid: &str) { let new_signer = Signer::new(new_kid); let mut inner = self.inner.lock().await; diff --git a/crates/formats/docker/src/lib.rs b/crates/formats/docker/src/lib.rs index 4c4744a6..c065d45b 100644 --- a/crates/formats/docker/src/lib.rs +++ b/crates/formats/docker/src/lib.rs @@ -1,7 +1,7 @@ //! Docker / OCI registries for Forge, as a format plugin: the //! distribution-spec v1.1 API under `/v2`, over the host's image store. //! -//! * [`path`], [`digest`], [`reference`]: the protocol's paths and the +//! * [`path`], [`digest`], [`reference`](mod@reference): the protocol's paths and the //! grammar of its names, references and digests; //! * [`manifest`]: Docker v2 schema 2 manifests and manifest lists, OCI //! image manifests and indexes, artifacts with `artifactType` and diff --git a/crates/formats/nuget/src/nuspec.rs b/crates/formats/nuget/src/nuspec.rs index 8fb32852..d995e1ef 100644 --- a/crates/formats/nuget/src/nuspec.rs +++ b/crates/formats/nuget/src/nuspec.rs @@ -1,4 +1,5 @@ -//! `.nupkg` (zip) and `.nuspec` (XML) parsing into a [`ParsedNupkg`]. +//! `.nupkg` (zip) and `.nuspec` (XML) parsing into a +//! [`ParsedNupkg`](crate::model::ParsedNupkg). //! //! A `.nupkg` is an OPC zip. We locate the single top-level `*.nuspec` //! manifest, read it under strict zip-bomb limits, and parse it @@ -7,7 +8,7 @@ //! //! Only the manifest is decompressed; the ``, ``, and //! `` groups are extracted. The raw manifest bytes are carried in -//! [`ParsedNupkg::nuspec_bytes`] so the server can store and re-serve them. +//! [`ParsedNupkg::nuspec_bytes`](crate::model::ParsedNupkg::nuspec_bytes) so the server can store and re-serve them. use std::io::{BufReader, Read, SeekFrom}; @@ -20,7 +21,7 @@ use crate::model::{ }; use crate::version; -/// Parse a `.nupkg` from a seekable reader into a [`ParsedNupkg`]. +/// Parse a `.nupkg` from a seekable reader into a [`ParsedNupkg`](ParsedUpload). pub fn parse_nupkg( file: &mut dyn ReadSeek, limits: &NupkgLimits, diff --git a/crates/server/src/browse.rs b/crates/server/src/browse.rs index 429e0494..3fe5082a 100644 --- a/crates/server/src/browse.rs +++ b/crates/server/src/browse.rs @@ -13,7 +13,8 @@ //! * version visibility, group member resolution, listed/prerelease filtering, //! and proxy **package-delay** gating all come for free from //! [`AppState::read_ops_for`] (the package detail's -//! [`AppState::cached_read_ops_for`]) + [`RepoReadOps`] — the same ops the +//! [`AppState::cached_read_ops_for`]) + +//! [`RepoReadOps`](forge_core::repo::RepoReadOps) — the same ops the //! repository dispatcher uses, so browse can never surface a version the //! protocol would hide. //! @@ -251,7 +252,8 @@ pub struct BrowseResult { /// `GET /browse/packages` — cross-repository search. /// /// Iterates every repository the principal may read (honoring -/// `anonymous_read`), runs [`RepoReadOps::search`] on each, merges the hits, +/// `anonymous_read`), runs +/// [`RepoReadOps::search`](forge_core::repo::RepoReadOps::search) on each, merges the hits, /// then sorts + paginates the merged set. A `repo` filter restricts to a single /// repository, which then pages in its backend. `total` is the sum of the /// backends' `total_hits` (already filtered by the caller's package selector). diff --git a/crates/server/src/me.rs b/crates/server/src/me.rs index 507f1aa8..bdb25698 100644 --- a/crates/server/src/me.rs +++ b/crates/server/src/me.rs @@ -1,9 +1,10 @@ //! `GET /admin/me` — the authenticated principal's identity + capability flags. //! -//! This endpoint is mounted **outside** the admin module's [`require_admin`] -//! gate: it is reachable by any *authenticated* user (admin or not) so the SPA -//! can decide which navigation/management screens to show. It still requires an -//! authenticated principal — an anonymous or unauthenticated caller gets `401`. +//! This endpoint is mounted **outside** the admin module's +//! [`require_admin`](crate::admin::require_admin) gate: it is reachable by any +//! *authenticated* user (admin or not) so the SPA can decide which +//! navigation/management screens to show. It still requires an authenticated +//! principal — an anonymous or unauthenticated caller gets `401`. //! //! ## Capabilities //! `is_admin` and the capability list come from the **effective RBAC** verdict diff --git a/web/knip.jsonc b/web/knip.jsonc new file mode 100644 index 00000000..4b6a54ae --- /dev/null +++ b/web/knip.jsonc @@ -0,0 +1,14 @@ +// knip: files, exports and dependencies nothing uses (`pnpm run knip`, CI's +// web job). Its plugins find the entry files in index.html and the Vite, +// Vitest and Playwright configs. +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + // The TanStack Router plugin loads the route tree from src/routes.ts by + // its path (vite.config.ts). + "entry": ["src/routes.ts"], + // The stylesheets too, for the packages they import (`@import + // 'tailwindcss'`). + "project": ["src/**/*.{ts,tsx,css}", "e2e/**/*.ts"], + // An export the module itself uses is no dead code, only a wider scope. + "ignoreExportsUsedInFile": true +} diff --git a/web/package.json b/web/package.json index eec8337a..eaeb00b7 100644 --- a/web/package.json +++ b/web/package.json @@ -17,7 +17,8 @@ "e2e:ui": "playwright test --ui", "e2e:list": "playwright test --list", "e2e:tsc": "tsc -p e2e/tsconfig.json", - "e2e:install": "playwright install --with-deps chromium" + "e2e:install": "playwright install --with-deps chromium", + "knip": "knip" }, "dependencies": { "@fontsource-variable/ibm-plex-sans": "5.3.0", @@ -41,6 +42,7 @@ "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.2", "jsdom": "30.1.3", + "knip": "6.43.0", "tailwindcss": "4.3.3", "typescript": "7.0.2", "vite": "8.3.5", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 4ec3ec7b..949aa8b6 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -39,16 +39,16 @@ importers: version: 1.64.1 '@tailwindcss/vite': specifier: 4.3.3 - version: 4.3.3(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) '@tanstack/router-plugin': specifier: 1.168.48 - version: 1.168.48(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) + version: 1.168.48(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) '@tanstack/virtual-file-routes': specifier: 1.162.0 version: 1.162.0 '@testing-library/jest-dom': specifier: 7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0))) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))) '@testing-library/react': specifier: 16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -66,10 +66,13 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 6.1.2 - version: 6.1.2(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) + version: 6.1.2(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) jsdom: specifier: 30.1.3 version: 30.1.3 + knip: + specifier: 6.43.0 + version: 6.43.0 tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -78,10 +81,10 @@ importers: version: 7.0.2 vite: specifier: 8.3.5 - version: 8.3.5(@types/node@26.6.5)(jiti@2.7.0) + version: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) + version: 5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) packages: @@ -211,6 +214,15 @@ packages: resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} engines: {node: '>=20.19.0'} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@exodus/bytes@1.16.0': resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -242,9 +254,241 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.2.6': + resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || 2.0.0-beta.2 + '@emnapi/runtime': ^1.7.1 || 2.0.0-beta.2 + + '@oxc-parser/binding-android-arm-eabi@0.155.0': + resolution: {integrity: sha512-A6xv7hDtnTm+xlZ7utn6rRQpRMJHRhVr3gjRGqlx0RUX84mNk0pAsy6YN/wWhuOTkaZhRrrzaLxf0NfbH77j2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.155.0': + resolution: {integrity: sha512-T1wWwH9/JI5g7Z4kEQwDOwhToxdDhRyOTklH+uuNpJ16m3MZFmGl+l2rKc0NHQdbVRG2LTOBsNjpW3h1BWytPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.155.0': + resolution: {integrity: sha512-ZoRB3qJxJ8DWA41jHja+YeB0lLfjBy4T+Kr15vnN1k65X3zbJt3rPZftEwN4Ju4tmA4K/T9dWWD7Z8ewQLnrVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.155.0': + resolution: {integrity: sha512-vxtL86BtM+IgrpejA86nbTk5j0HKTIMeuaJ1fQciw6p9RfavPtIPjI7gN34owap0yG9txXBaAWpGdxokNwdqIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.155.0': + resolution: {integrity: sha512-tN6Ru5e0UVnWaIuiH8jEwnvxuxg7FvhWwSd3oe5oDhOD4ObsSr9/N6WfHLjilUWlPPj65ptwrSDH0Y7lzwTzmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.155.0': + resolution: {integrity: sha512-h4ozA5n6L6lwolsz9iwunEhKWnIVbmFRots5QxEix0H6nJ5ICpuDa7PuShuhfpIDFe3+Qvyscioyiv+9g+4Syg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.155.0': + resolution: {integrity: sha512-058/+LardpfuUlsKIvBGk/CYoakEVcMgqYx8GgyUZeOxFWTojYLlCc1Gb60/y21v3esDhHidgiY/Zbq9LpBhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.155.0': + resolution: {integrity: sha512-nrOt2Y3xIAzu+lSPzH0864ieO+iCCXgoo9qFlGANE8Xh0cQ+RP8Wstfp+/wXN2YCyb98cAGzX0VruKYw7it/jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.155.0': + resolution: {integrity: sha512-KYAayyN5hQbJPtGKtRxlyc8XYmyntOplyXSQzTDrmbz7l/GCrzQbSswvyJFBTH1LGtsZrbXOAfN52i/Kz6kLKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.155.0': + resolution: {integrity: sha512-CSuSY5hfjtsBnHXydOj8jiNfS1qHbdPZuPz1YL1cSgK7VmNYP+QlZIbG0BBYYBgX/vYEgZwbG9mBdW4YairZ5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.155.0': + resolution: {integrity: sha512-7KCfOQm+Nz2N8trAXbQkal4TQ5756WVEmhm35y816YHvyFnEfVsICsiV5k0pEszNf/fRm4cm2i6i18SJ6/iCWg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.155.0': + resolution: {integrity: sha512-JSlhgoK+/g1TGJI5NuADx2+6syhxnQNLCWLXyO5QLFyetomcr/vIxSdiNlmyAv963YXUzCuwAwRG6npZ5xyJ1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.155.0': + resolution: {integrity: sha512-WFIX+dPokWp64mi434Md3U3lJaTwNxAphvGjpltoyQjGUsdXavOIRrnp9dOS34Hd75HOAwDxAI+YwWOwqmJbtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.155.0': + resolution: {integrity: sha512-g6xSDC/JpbnVOz5uafMGyK3txBVRFMQTx8opv/yHXGxqV3ZMVl3bf7W8XrSnF68dmy3gM4dgReNryuE53+WkeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.155.0': + resolution: {integrity: sha512-6/4xlxKlZJWrQ0uwDFPc0H9+xLu7068CKhX3vIDW3SbvP/lVgnVuT5v6dyugeWskWpuxqF/WJ4WKwhxjU3pG2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.155.0': + resolution: {integrity: sha512-tltklL0J0XjDxIRTBXWOBhvc2mitcmES45pIxhwDWfxRyqz7Vkeu0NekTZEIxGE4f/lXgq3vZcjjffteQOVorg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.155.0': + resolution: {integrity: sha512-alAb9TzjatUEsOeoncAziUnfdTDxfyswhalFZ9sC6plUgATIUx2NIZ0+bMn6aJ28AduS1OpG/OAisr/YISn4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.155.0': + resolution: {integrity: sha512-yc5cbSfcGmRv/KpxDFuyk8adEBaWd/9HilWvBbQ4bFbus1V2PUh2mGB2Cfq9rqZTqLeiMT8x0skV+LKp9hOMgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.155.0': + resolution: {integrity: sha512-8heq6jGq+rfpTDRDjoQiBHw0maQZf3IXkS99pdHQZPoTL5kyF5qyPS0RidUlSdnWDIqxXb5nux7GBD9cT+imUw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.155.0': resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@playwright/test@1.64.1': resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} engines: {node: '>=20'} @@ -547,6 +791,9 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -849,6 +1096,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -858,6 +1108,11 @@ packages: picomatch: optional: true + formatly@0.11.4: + resolution: {integrity: sha512-TeCC10lP8KCgoX+Fe5lryILIT0/Dy+nP0OSVnsuB5CwXoLuOdqxCYlPFF1inR8S/zTy1ec7TjhS2TeRKWzzImA==} + engines: {node: '>=18.14.0'} + hasBin: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -867,6 +1122,9 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -911,6 +1169,11 @@ packages: engines: {node: '>=6'} hasBin: true + knip@6.43.0: + resolution: {integrity: sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1099,6 +1362,16 @@ packages: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} + oxc-parser@0.155.0: + resolution: {integrity: sha512-C/ReSf1JUFukK/zvk6jhcNibSssvas5+ktIzK7fbKVd3CedWe59C3QwHEOrEZj/4Rt9+xg0Jeis/Jv1F/KM1mw==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -1163,6 +1436,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rolldown@1.2.15: resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1189,6 +1465,10 @@ packages: resolution: {integrity: sha512-CqbIVfk/7GL3ITwZI55lcPbpQbjHZ2DD5cUj4iH7E9YsCfHNJV1mx7yNp86Z84w5IpmkZZS/2s5vPOWmVWSdQg==} engines: {node: '>=10'} + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} @@ -1200,6 +1480,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1234,11 +1518,18 @@ packages: resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} engines: {node: ^22.14.0 || >=24.0.0} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} hasBin: true + unbash@6.0.1: + resolution: {integrity: sha512-0db9eS5iqybux41zLkX69nk90KTci7F7eZ+gXzEG/Q7E0tAqEBhRh5fkZvk1N7jT7QkGWqq34i8SFGPFEe1V8A==} + engines: {node: '>=14'} + undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} @@ -1381,6 +1672,10 @@ packages: resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -1411,6 +1706,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} @@ -1565,6 +1865,22 @@ snapshots: '@csstools/css-tokenizer@4.0.2': {} + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@exodus/bytes@1.16.0': {} '@fontsource-variable/ibm-plex-sans@5.3.0': {} @@ -1590,8 +1906,133 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@oxc-parser/binding-android-arm-eabi@0.155.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.155.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.155.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.155.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.155.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.155.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.155.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.155.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.155.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.155.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.155.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.155.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.155.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.155.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.155.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.155.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.155.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.155.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.155.0': + optional: true + '@oxc-project/types@0.155.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@playwright/test@1.64.1': dependencies: playwright: 1.64.1 @@ -1704,12 +2145,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.3(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0) + vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) '@tanstack/history@1.162.5': {} @@ -1758,7 +2199,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.48(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0))': + '@tanstack/router-plugin@1.168.48(@tanstack/react-router@1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7 '@babel/template': 7.29.7 @@ -1767,11 +2208,11 @@ snapshots: '@tanstack/router-generator': 1.167.46 '@tanstack/router-utils': 1.162.4 chokidar: 5.0.0 - unplugin: 3.4.0(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) + unplugin: 3.4.0(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) zod: 4.6.7 optionalDependencies: '@tanstack/react-router': 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) - vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0) + vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -1810,7 +2251,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)))': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)))': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.1 @@ -1820,7 +2261,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) + vitest: 5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: @@ -1836,6 +2277,11 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': @@ -1919,19 +2365,19 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react@6.1.2(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0))': + '@vitejs/plugin-react@6.1.2(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0) + vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) - '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0))': + '@vitest/mocker@5.0.5(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.5 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0) + vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) '@vitest/spy@5.0.5': {} @@ -2037,15 +2483,28 @@ snapshots: expect-type@1.4.0: {} + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 + formatly@0.11.4: + dependencies: + fd-package-json: 2.0.0 + package-manager-detector: 1.9.0 + fsevents@2.3.3: optional: true gensync@1.0.0-beta.2: {} + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + graceful-fs@4.2.11: {} html-encoding-sniffer@7.0.0: @@ -2093,6 +2552,21 @@ snapshots: json5@2.2.3: {} + knip@6.43.0: + dependencies: + formatly: 0.11.4 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + oxc-parser: 0.155.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.9.1 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 6.0.1 + yaml: 2.9.1 + zod: 4.6.7 + lightningcss-android-arm64@1.32.0: optional: true @@ -2219,6 +2693,54 @@ snapshots: obug@3.0.0: {} + oxc-parser@0.155.0: + dependencies: + '@oxc-project/types': 0.155.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.155.0 + '@oxc-parser/binding-android-arm64': 0.155.0 + '@oxc-parser/binding-darwin-arm64': 0.155.0 + '@oxc-parser/binding-darwin-x64': 0.155.0 + '@oxc-parser/binding-freebsd-x64': 0.155.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.155.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.155.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.155.0 + '@oxc-parser/binding-linux-arm64-musl': 0.155.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.155.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.155.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.155.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.155.0 + '@oxc-parser/binding-linux-x64-gnu': 0.155.0 + '@oxc-parser/binding-linux-x64-musl': 0.155.0 + '@oxc-parser/binding-openharmony-arm64': 0.155.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.155.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.155.0 + '@oxc-parser/binding-win32-x64-msvc': 0.155.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + + package-manager-detector@1.9.0: {} + parse5@8.0.1: dependencies: entities: 8.1.0 @@ -2269,6 +2791,8 @@ snapshots: require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} + rolldown@1.2.15: dependencies: '@oxc-project/types': 0.155.0 @@ -2304,6 +2828,8 @@ snapshots: seroval@1.6.12: {} + smol-toml@1.9.1: {} + source-map-js@1.2.2: {} std-env@4.3.0: {} @@ -2312,6 +2838,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@5.0.3: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -2339,6 +2867,9 @@ snapshots: dependencies: punycode: 2.3.1 + tslib@2.8.1: + optional: true + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -2362,18 +2893,20 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + unbash@6.0.1: {} + undici-types@8.9.0: {} undici@8.12.2: {} - unplugin@3.4.0(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)): + unplugin@3.4.0(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 optionalDependencies: rolldown: 1.2.15 - vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0) + vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) update-browserslist-db@1.3.4(browserslist@4.29.4): dependencies: @@ -2385,7 +2918,7 @@ snapshots: dependencies: react: 19.3.1 - vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0): + vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -2396,11 +2929,12 @@ snapshots: '@types/node': 26.6.5 fsevents: 2.3.3 jiti: 2.7.0 + yaml: 2.9.1 - vitest@5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)): + vitest@5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)) + '@vitest/mocker': 5.0.5(vite@8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2411,7 +2945,7 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0) + vite: 8.3.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.5 @@ -2423,6 +2957,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} + webidl-conversions@8.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -2445,4 +2981,6 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.1: {} + zod@4.6.7: {} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cedba7f5..673591ee 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,6 +9,7 @@ mod changelog; mod docs; mod proc; mod release; +mod unused; use proc::{Cmd, RunningCmd}; use std::env; @@ -77,6 +78,7 @@ fn main() -> ExitCode { "check" => check(), "fmt" => fmt(), "clippy" => clippy(), + "doc-check" => doc_check(), "test" => test(), "e2e" => e2e(), "migrate" => migrate(), @@ -90,6 +92,7 @@ fn main() -> ExitCode { "docs" => docs::docs(rest), "changelog" => changelog::changelog(rest), "release" => release::release(rest), + "unused" => unused::unused(rest), other => { eprintln!("xtask: unknown command `{other}`\n"); print_help(); @@ -146,11 +149,16 @@ Rust build & quality: check fmt --check + clippy -D warnings. fmt Verify formatting (cargo fmt --all --check). clippy Lint with warnings denied. + doc-check Build the crates' API docs with rustdoc's warnings denied, so a doc + link to a renamed or removed item fails (CI). test Run unit + integration tests (needs Docker for testcontainers). e2e Run the end-to-end suite (forge-e2e feature; needs Docker). migrate Apply all migrations against the live DB (sqlx migrate run). prepare Migrate, then regenerate the sqlx offline cache (needs `up` + sqlx-cli). prepare-check Migrate, then verify the committed .sqlx/ cache is in sync (CI's drift guard). + unused Fail on what rustc and clippy cannot see is unused: a pub item nothing + else names, a dependency its crate never imports, a dev-dependency + repeating a dependency, a workspace dependency no member inherits (CI). clean Remove build artifacts (cargo clean). Docker image (single container: web SPA + Rust server): @@ -712,6 +720,26 @@ fn clippy() -> Result<(), String> { .run() } +/// The crates' API docs, built with rustdoc's warnings denied: a doc link to +/// an item that was renamed or removed fails. The private items are +/// documented too, since most links are between them, so a link from a +/// public item to a private one is fine. `--all-features` takes in forge-e2e, +/// which is all behind its feature. +fn doc_check() -> Result<(), String> { + cargo(&[ + "doc", + "--workspace", + "--all-features", + "--no-deps", + "--document-private-items", + ]) + .env( + "RUSTDOCFLAGS", + "-D warnings -A rustdoc::private_intra_doc_links", + ) + .run() +} + fn test() -> Result<(), String> { cargo(&["test", "--workspace"]).run() } diff --git a/xtask/src/unused.rs b/xtask/src/unused.rs new file mode 100644 index 00000000..761c618b --- /dev/null +++ b/xtask/src/unused.rs @@ -0,0 +1,784 @@ +//! `cargo xtask unused`: what nothing uses, and neither rustc nor clippy +//! reports. +//! +//! rustc warns about a private item nothing calls, but not about a `pub` one: +//! it compiles one crate at a time, and another crate may call it. Nor does +//! Cargo say when a dependency is no longer imported. This check reads the +//! workspace's manifests and Rust sources as text and reports: +//! +//! * a `pub` item (a function, type, trait, constant or static) whose name +//! appears nowhere else in the workspace's Rust sources, so that nothing, +//! not even a test, uses it; +//! * a dependency that its member's sources never name: no `name::…`, +//! `use name` or `extern crate name`, the name's `-` read as `_` or dropped +//! (`md-5` is imported as `md5`); +//! * a dev-dependency that repeats one of its member's dependencies without +//! adding a feature, since the tests have the dependencies anyway; +//! * an entry of `[workspace.dependencies]` that no member inherits. +//! +//! Being a text search, it errs towards silence: a name mentioned anywhere +//! else, in a comment, a string or as an unrelated local, counts as used. A +//! dependency the code needs without naming it (one that turns on a feature +//! of another) is listed, with the reason, in its member's manifest: +//! +//! ```toml +//! [package.metadata.unused] +//! # +//! ignore = ["name"] +//! ``` + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fs; +use std::path::Path; + +/// The workspace manifest: its members and `[workspace.dependencies]`. +const ROOT_MANIFEST: &str = "Cargo.toml"; +/// The table of a member's manifest that lists the dependencies to keep. +const METADATA: &str = "package.metadata.unused"; + +/// `cargo xtask unused`. +pub fn unused(args: &[String]) -> Result<(), String> { + if !args.is_empty() { + return Err("usage: cargo xtask unused".into()); + } + let root = read(ROOT_MANIFEST)?; + let mut members = Vec::new(); + for dir in workspace_members(&root) { + let manifest = format!("{dir}/Cargo.toml"); + let mut sources = Vec::new(); + rust_sources(Path::new(&dir), &mut sources)?; + members.push(Member { + text: read(&manifest)?, + manifest, + sources, + }); + } + + let mut findings = unused_items(members.iter().flat_map(|m| &m.sources)); + for member in &members { + findings.extend(member_findings(member)); + } + findings.extend(uninherited(&root, members.iter().map(|m| m.text.as_str()))); + + if findings.is_empty() { + println!( + "{} member(s): every pub item and every dependency is used.", + members.len() + ); + return Ok(()); + } + for finding in &findings { + eprintln!("{finding}"); + } + Err(format!( + "{} finding(s): remove what nothing uses (a dependency the code needs \ + without naming it goes under [{METADATA}] ignore, see xtask/src/unused.rs)", + findings.len() + )) +} + +fn read(path: &str) -> Result { + fs::read_to_string(path).map_err(|e| format!("unused: read {path}: {e}")) +} + +/// A workspace member: its manifest and its Rust sources. +struct Member { + manifest: String, + text: String, + /// `(path, text)` of every `.rs` file under the member's directory. + sources: Vec<(String, String)>, +} + +/// Every `.rs` file under `dir`, but not under a `target` directory. +fn rust_sources(dir: &Path, out: &mut Vec<(String, String)>) -> Result<(), String> { + let entries = fs::read_dir(dir).map_err(|e| format!("unused: list {}: {e}", dir.display()))?; + let mut paths: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect(); + paths.sort(); + for path in paths { + if path.is_dir() { + if path.file_name().is_some_and(|n| n != "target") { + rust_sources(&path, out)?; + } + } else if path.extension().is_some_and(|e| e == "rs") { + let name = path.to_string_lossy().into_owned(); + out.push((name.clone(), read(&name)?)); + } + } + Ok(()) +} + +// ── pub items ────────────────────────────────────────────────────────────── + +/// The `pub` items whose name no other place in `sources` mentions. +fn unused_items<'a>(sources: impl Iterator + Clone) -> Vec { + let mut mentions: HashMap<&str, usize> = HashMap::new(); + for (_, text) in sources.clone() { + for word in words(text) { + *mentions.entry(word).or_default() += 1; + } + } + let mut out = Vec::new(); + for (path, text) in sources { + for (line, kind, name) in pub_items(&code_only(text)) { + if mentions.get(name.as_str()).copied().unwrap_or(0) <= 1 { + out.push(format!( + "{path}:{line}: pub {kind} `{name}`: nothing else in the workspace names it" + )); + } + } + } + out +} + +fn is_ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} + +fn is_ident(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// The identifier-like words of `text`, comments and strings included. +fn words(text: &str) -> impl Iterator { + let mut rest = text; + std::iter::from_fn(move || loop { + let start = rest.find(is_ident_start)?; + // A word does not start inside another (`2nd`, `x86_64`). + let before = rest[..start].chars().next_back(); + let len = rest[start..] + .find(|c| !is_ident(c)) + .unwrap_or(rest.len() - start); + let word = &rest[start..start + len]; + rest = &rest[start + len..]; + if !before.is_some_and(|c| c.is_ascii_digit()) { + return Some(word); + } + }) +} + +/// `text` with its comments and string and character literals blanked out +/// (newlines kept), so that code in a doc comment or a test's string is not +/// read as a definition. +fn code_only(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut out = String::with_capacity(text.len()); + let blank = |out: &mut String, c: char| out.push(if c == '\n' { '\n' } else { ' ' }); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let next = chars.get(i + 1).copied(); + let prev_ident = i > 0 && is_ident(chars[i - 1]); + if c == '/' && next == Some('/') { + while i < chars.len() && chars[i] != '\n' { + blank(&mut out, chars[i]); + i += 1; + } + } else if c == '/' && next == Some('*') { + let mut depth = 0; + while i < chars.len() { + if chars[i] == '/' && chars.get(i + 1) == Some(&'*') { + depth += 1; + out.push_str(" "); + i += 2; + } else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') { + depth -= 1; + out.push_str(" "); + i += 2; + if depth == 0 { + break; + } + } else { + blank(&mut out, chars[i]); + i += 1; + } + } + } else if let Some(hashes) = (!prev_ident).then(|| raw_string_start(&chars, i)).flatten() { + // r"…", r#"…"#, br"…": no escapes; ends at `"` and the hashes. + let open = hashes + 2 + usize::from(c == 'b'); + for &c in &chars[i..i + open] { + blank(&mut out, c); + } + i += open; + while i < chars.len() { + if chars[i] == '"' + && chars[i + 1..] + .iter() + .take(hashes) + .filter(|&&c| c == '#') + .count() + == hashes + { + for &c in &chars[i..(i + 1 + hashes).min(chars.len())] { + blank(&mut out, c); + } + i += 1 + hashes; + break; + } + blank(&mut out, chars[i]); + i += 1; + } + } else if c == '"' { + blank(&mut out, c); + i += 1; + while i < chars.len() && chars[i] != '"' { + if chars[i] == '\\' { + blank(&mut out, chars[i]); + i += 1; + } + if i < chars.len() { + blank(&mut out, chars[i]); + i += 1; + } + } + if i < chars.len() { + blank(&mut out, chars[i]); + i += 1; + } + } else if c == '\'' && (next == Some('\\') || chars.get(i + 2) == Some(&'\'')) { + // A character literal ('x', '\n', '\''), not a lifetime ('a). + blank(&mut out, c); + i += 1; + if chars.get(i) == Some(&'\\') { + blank(&mut out, '\\'); + i += 1; + } + while i < chars.len() { + let done = chars[i] == '\''; + blank(&mut out, chars[i]); + i += 1; + if done { + break; + } + } + } else { + out.push(c); + i += 1; + } + } + out +} + +/// The number of `#` of a raw string literal starting at `chars[i]` (`r"`, +/// `r#"`, `br"`), if one does. +fn raw_string_start(chars: &[char], i: usize) -> Option { + let mut j = i; + if chars.get(j) == Some(&'b') { + j += 1; + } + if chars.get(j) != Some(&'r') { + return None; + } + j += 1; + let hashes = chars[j..].iter().take_while(|&&c| c == '#').count(); + (chars.get(j + hashes) == Some(&'"')).then_some(hashes) +} + +/// The `pub` items `code` defines (comments and strings blanked out): +/// `(line, kind, name)`, lines counted from 1. +fn pub_items(code: &str) -> Vec<(usize, &'static str, String)> { + let mut out = Vec::new(); + for (n, line) in code.lines().enumerate() { + let Some(rest) = line.trim_start().strip_prefix("pub") else { + continue; + }; + let rest = if let Some(scoped) = rest.strip_prefix('(') { + // pub(crate), pub(super), pub(in path) + match scoped.find(')') { + Some(end) => &scoped[end + 1..], + None => continue, + } + } else if rest.starts_with(char::is_whitespace) { + rest + } else { + continue; // `public`, `pub_items` + }; + let mut tokens = rest.split_whitespace().peekable(); + let mut kind = None; + while let Some(token) = tokens.next() { + match token { + // `extern "C"`: the ABI string is blanked out already. + "async" | "unsafe" | "extern" => {} + "const" + if tokens + .peek() + .is_some_and(|t| matches!(*t, "fn" | "async" | "unsafe" | "extern")) => {} + "fn" | "struct" | "enum" | "union" | "trait" | "type" | "const" | "static" => { + kind = Some(token); + break; + } + _ => break, + } + } + let Some(kind) = kind else { continue }; + let mut name = tokens.next(); + if kind == "static" && name == Some("mut") { + name = tokens.next(); + } + let Some(name) = name else { continue }; + let name = name.strip_prefix("r#").unwrap_or(name); + let len = name.find(|c| !is_ident(c)).unwrap_or(name.len()); + if len > 0 && name.starts_with(is_ident_start) { + let kind = match kind { + "fn" => "fn", + "struct" => "struct", + "enum" => "enum", + "union" => "union", + "trait" => "trait", + "type" => "type", + "const" => "const", + _ => "static", + }; + out.push((n + 1, kind, name[..len].to_string())); + } + } + out +} + +// ── Dependencies ─────────────────────────────────────────────────────────── + +/// A `key = value` of a manifest, the value without comments and with its +/// lines joined; `line` counts from 1. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Entry { + table: String, + key: String, + value: String, + line: usize, +} + +/// The entries of `manifest`, in order, each with the table it is in. +fn entries(manifest: &str) -> Vec { + let mut out = Vec::new(); + let mut table = String::new(); + let mut lines = manifest.lines().enumerate(); + while let Some((n, line)) = lines.next() { + let line = strip_comment(line); + let trimmed = line.trim(); + if let Some(header) = trimmed.strip_prefix('[') { + table = header + .trim_matches(|c| c == '[' || c == ']') + .trim() + .to_string(); + continue; + } + let Some((key, value)) = split_once_outside_quotes(trimmed, '=') else { + continue; + }; + let mut value = value.trim().to_string(); + while depth(&value) > 0 { + let Some((_, more)) = lines.next() else { break }; + value.push(' '); + value.push_str(strip_comment(more).trim()); + } + out.push(Entry { + table: table.clone(), + key: key.trim().trim_matches('"').to_string(), + value, + line: n + 1, + }); + } + out +} + +/// `line` up to a `#` that is not in a string. +fn strip_comment(line: &str) -> &str { + let mut quote = None; + for (i, c) in line.char_indices() { + match (quote, c) { + (None, '#') => return &line[..i], + (None, '"' | '\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + _ => {} + } + } + line +} + +/// How many `{`/`[` of `value` are still open (outside strings). +fn depth(value: &str) -> i32 { + let mut quote = None; + let mut depth = 0; + for c in value.chars() { + match (quote, c) { + (None, '"' | '\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (None, '{' | '[') => depth += 1, + (None, '}' | ']') => depth -= 1, + _ => {} + } + } + depth +} + +/// `s` split at the first `sep` that is outside strings and brackets. +fn split_once_outside_quotes(s: &str, sep: char) -> Option<(&str, &str)> { + split_outside_quotes(s, sep, 1) + .first() + .filter(|first| first.len() < s.len()) + .map(|first| (*first, &s[first.len() + 1..])) +} + +/// `s` split at each `sep` outside strings and brackets, at most `max` +/// times (0: no limit). +fn split_outside_quotes(s: &str, sep: char, max: usize) -> Vec<&str> { + let mut out = Vec::new(); + let (mut quote, mut depth, mut start) = (None, 0, 0); + for (i, c) in s.char_indices() { + match (quote, c) { + (None, '"' | '\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + (None, '{' | '[') => depth += 1, + (None, '}' | ']') => depth -= 1, + (None, _) if c == sep && depth == 0 && (max == 0 || out.len() < max) => { + out.push(&s[start..i]); + start = i + 1; + } + _ => {} + } + } + out.push(&s[start..]); + out +} + +/// A dependency's spec as `key → value` (`"1"` is `version = "1"`), values +/// without whitespace outside strings, and its features apart. +fn spec(value: &str) -> (BTreeMap, BTreeSet) { + let value = value.trim(); + let mut fields = BTreeMap::new(); + let mut features = BTreeSet::new(); + let Some(inner) = value.strip_prefix('{').and_then(|v| v.strip_suffix('}')) else { + fields.insert("version".to_string(), value.to_string()); + return (fields, features); + }; + for field in split_outside_quotes(inner, ',', 0) { + let Some((key, value)) = split_once_outside_quotes(field, '=') else { + continue; + }; + let key = key.trim().to_string(); + let value: String = value.trim().to_string(); + if key == "features" { + features.extend(string_list(&value)); + } else { + fields.insert(key, without_space(&value)); + } + } + (fields, features) +} + +/// The strings of an array value, `["a", "b"]`. +fn string_list(value: &str) -> Vec { + let list = value.trim().trim_start_matches('[').trim_end_matches(']'); + split_outside_quotes(list, ',', 0) + .into_iter() + .map(|s| s.trim().trim_matches('"').to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +fn without_space(value: &str) -> String { + let mut quote = None; + value + .chars() + .filter(|&c| { + match (quote, c) { + (None, '"' | '\'') => quote = Some(c), + (Some(q), _) if c == q => quote = None, + _ => {} + } + quote.is_some() || !c.is_whitespace() + }) + .collect() +} + +/// Whether `table` lists dependencies: `dependencies`, `dev-dependencies`, +/// `build-dependencies`, or one of them for a target only. +fn is_dependency_table(table: &str) -> bool { + let last = table.rsplit('.').next().unwrap_or(table); + matches!( + last, + "dependencies" | "dev-dependencies" | "build-dependencies" + ) && (!table.contains('.') || table.starts_with("target.")) +} + +/// What is wrong with the dependencies of `member`. +fn member_findings(member: &Member) -> Vec { + let entries = entries(&member.text); + let ignored: BTreeSet = entries + .iter() + .filter(|e| e.table == METADATA && e.key == "ignore") + .flat_map(|e| string_list(&e.value)) + .collect(); + let deps: Vec<&Entry> = entries + .iter() + .filter(|e| is_dependency_table(&e.table)) + .collect(); + let sources: Vec<&str> = member + .sources + .iter() + .map(|(_, text)| text.as_str()) + .collect(); + let mut out = Vec::new(); + let mut reported = BTreeSet::new(); + for dep in &deps { + if ignored.contains(&dep.key) || reported.contains(&dep.key) { + continue; + } + let named = idents(&dep.key) + .iter() + .any(|ident| sources.iter().any(|source| names_crate(source, ident))); + if !named { + reported.insert(dep.key.clone()); + out.push(format!( + "{}:{}: [{}] `{}`: no source of the crate names it", + member.manifest, dep.line, dep.table, dep.key + )); + } + } + for dev in deps.iter().filter(|e| e.table == "dev-dependencies") { + let Some(normal) = deps + .iter() + .find(|e| e.table == "dependencies" && e.key == dev.key) + else { + continue; + }; + let (normal_fields, normal_features) = spec(&normal.value); + let (dev_fields, dev_features) = spec(&dev.value); + if normal_fields == dev_fields && dev_features.is_subset(&normal_features) { + out.push(format!( + "{}:{}: [dev-dependencies] `{}` repeats [dependencies] `{}` (line {}): \ + the tests have it already", + member.manifest, dev.line, dev.key, normal.key, normal.line + )); + } + } + out +} + +/// The names the code may import dependency `key` as: `-` as `_`, or +/// dropped (`md-5` is `md5`). +fn idents(key: &str) -> Vec { + let mut out = vec![key.replace('-', "_")]; + if key.contains('-') { + out.push(key.replace('-', "")); + } + out +} + +/// Whether `source` names the crate `ident`: a path (`ident::…`), a `use` +/// or an `extern crate`. +fn names_crate(source: &str, ident: &str) -> bool { + let mut from = 0; + while let Some(at) = source[from..].find(ident) { + let start = from + at; + let end = start + ident.len(); + from = end; + let before = source[..start].chars().next_back(); + let after = source[end..].chars().next(); + if before.is_some_and(is_ident) || after.is_some_and(is_ident) { + continue; + } + if source[end..].starts_with("::") { + return true; + } + let preceding = source[..start].trim_end(); + let word_start = preceding.rfind(|c| !is_ident(c)).map_or(0, |i| i + 1); + if matches!(&preceding[word_start..], "use" | "crate") { + return true; + } + } + false +} + +/// The `[workspace.dependencies]` of `root` that no member inherits. +fn uninherited<'a>(root: &str, members: impl Iterator) -> Vec { + let mut inherited = BTreeSet::new(); + for manifest in members { + for entry in entries(manifest) { + if is_dependency_table(&entry.table) + && spec(&entry.value) + .0 + .get("workspace") + .is_some_and(|v| v == "true") + { + inherited.insert(entry.key); + } + } + } + entries(root) + .into_iter() + .filter(|e| e.table == "workspace.dependencies" && !inherited.contains(&e.key)) + .map(|e| { + format!( + "{ROOT_MANIFEST}:{}: [workspace.dependencies] `{}`: no member inherits it", + e.line, e.key + ) + }) + .collect() +} + +/// The member directories `[workspace] members` lists. +fn workspace_members(root: &str) -> Vec { + entries(root) + .into_iter() + .find(|e| e.table == "workspace" && e.key == "members") + .map(|e| string_list(&e.value)) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn src(path: &str, text: &str) -> (String, String) { + (path.to_string(), text.to_string()) + } + + #[test] + fn a_pub_item_nothing_else_names_is_reported() { + let sources = [ + src( + "a.rs", + "pub fn used() {}\npub(crate) async fn lonely() {}\npub struct Kept;\n", + ), + src("b.rs", "fn main() { a::used(); let _ = Kept; }\n"), + ]; + assert_eq!( + unused_items(sources.iter()), + ["a.rs:2: pub fn `lonely`: nothing else in the workspace names it"] + ); + } + + #[test] + fn a_mention_in_a_comment_or_string_counts_as_a_use() { + let sources = [ + src("a.rs", "pub const LIMIT: u32 = 1;\npub fn helper() {}\n"), + src("b.rs", "// see LIMIT\nconst S: &str = \"helper\";\n"), + ]; + assert!(unused_items(sources.iter()).is_empty()); + } + + #[test] + fn definitions_in_comments_and_strings_are_not_items() { + let code = code_only(concat!( + "/// ```\n/// pub fn in_doc() {}\n/// ```\n", + "const A: &str = \"pub fn in_string() {}\";\n", + "const B: &str = r#\"pub fn in_raw() {}\"#;\n", + "/* pub fn in_block() {} */\n", + "fn f<'a>(c: char) -> bool { c == '\"' }\n", + "pub fn real() {}\n", + )); + let names: Vec<_> = pub_items(&code) + .into_iter() + .map(|(line, _, name)| (line, name)) + .collect(); + assert_eq!(names, [(8, "real".to_string())]); + } + + #[test] + fn every_kind_of_pub_item_is_read() { + let code = "pub const fn a() {}\npub const B: u8 = 0;\npub static mut C: u8 = 0;\n\ + pub(super) unsafe fn d() {}\npub enum E {}\npub trait F {}\npub type G = u8;\n\ + pub mod h {}\npub use i::J;\npub struct r#K;\npublic();\n"; + let items: Vec<_> = pub_items(code) + .into_iter() + .map(|(_, kind, name)| (kind, name)) + .collect(); + let expected = [ + ("fn", "a"), + ("const", "B"), + ("static", "C"), + ("fn", "d"), + ("enum", "E"), + ("trait", "F"), + ("type", "G"), + ("struct", "K"), + ]; + assert_eq!(items, expected.map(|(kind, name)| (kind, name.to_string()))); + } + + #[test] + fn manifest_entries_span_lines_and_drop_comments() { + let manifest = "[package]\nname = \"x\" # the name\n\n[dependencies]\n\ + a = { version = \"1\", features = [\n \"b\", # why\n \"c\",\n] }\n\ + [target.'cfg(unix)'.dependencies]\nd = \"2\"\n"; + let deps: Vec<_> = entries(manifest) + .into_iter() + .filter(|e| is_dependency_table(&e.table)) + .map(|e| (e.key, e.line)) + .collect(); + assert_eq!(deps, [("a".to_string(), 5), ("d".to_string(), 10)]); + let a = &entries(manifest)[1]; + let (fields, features) = spec(&a.value); + assert_eq!(fields.get("version").map(String::as_str), Some("\"1\"")); + assert_eq!(features.into_iter().collect::>(), ["b", "c"]); + } + + #[test] + fn a_crate_is_named_by_a_path_a_use_or_an_extern_crate() { + assert!(names_crate("let v = serde_json::json!({});", "serde_json")); + assert!(names_crate("use tokio;", "tokio")); + assert!(names_crate("pub use rand ;", "rand")); + assert!(names_crate("extern crate alloc;", "alloc")); + assert!(!names_crate("let url = base.url;", "url")); + assert!(!names_crate("my_tokio::spawn()", "tokio")); + assert!(idents("md-5") + .iter() + .any(|i| names_crate("md5::compute(b)", i))); + } + + fn member(manifest: &str, source: &str) -> Member { + Member { + manifest: "m/Cargo.toml".into(), + text: manifest.into(), + sources: vec![src("m/src/lib.rs", source)], + } + } + + #[test] + fn an_unnamed_dependency_is_reported_unless_ignored() { + let manifest = "[package.metadata.unused]\nignore = [\"feature-only\"]\n\n\ + [dependencies]\nserde = \"1\"\nhex = \"0.4\"\nfeature-only = \"1\"\n\ + [dev-dependencies]\ntempfile = \"3\"\n"; + let source = "use serde::Serialize;\n"; + assert_eq!( + member_findings(&member(manifest, source)), + [ + "m/Cargo.toml:6: [dependencies] `hex`: no source of the crate names it", + "m/Cargo.toml:9: [dev-dependencies] `tempfile`: no source of the crate names it", + ] + ); + } + + #[test] + fn a_dev_dependency_that_adds_nothing_is_reported() { + let manifest = "[dependencies]\n\ + a = { workspace = true, features = [\"x\", \"y\"] }\n\ + b = { path = \"../b\" }\nc = \"1\"\n\ + [dev-dependencies]\n\ + a = { workspace = true, features = [\"y\"] }\n\ + b = { path = \"../b\", features = [\"testing\"] }\n\ + c = { version = \"1\" }\n"; + let source = "use a; use b; use c;"; + assert_eq!( + member_findings(&member(manifest, source)), + [ + "m/Cargo.toml:6: [dev-dependencies] `a` repeats [dependencies] `a` (line 2): \ + the tests have it already", + "m/Cargo.toml:8: [dev-dependencies] `c` repeats [dependencies] `c` (line 4): \ + the tests have it already", + ] + ); + } + + #[test] + fn workspace_members_and_their_inherited_dependencies() { + let root = "[workspace]\nmembers = [\n \"crates/a\", # first\n \"xtask\",\n]\n\n\ + [workspace.dependencies]\nserde = \"1\"\nunused-one = \"2\"\n"; + assert_eq!(workspace_members(root), ["crates/a", "xtask"]); + let member = "[dependencies]\nserde = { workspace = true }\n"; + assert_eq!( + uninherited(root, [member].into_iter()), + ["Cargo.toml:9: [workspace.dependencies] `unused-one`: no member inherits it"] + ); + } +}