pub mod workspace;
pub use workspace::{JailBuildPermission, WorkspaceConfig};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
/// Deserialize `engines` tolerant to legacy non-map forms, e.g.
/// `extsprintf@1.4.1` ships `"engines": ["node >=0.6.0"]` and some
/// old packument entries (such as `qs`) ship a bare string.
/// Modern npm ignores that shape (engine-strict only consults the map
/// form), so normalize to an empty map rather than failing the whole
/// manifest — a hard error there takes down every install that touches
/// one of these ancient packages, even when the user's target engine
/// wouldn't have matched any constraint anyway.
///
/// An explicit `null` is also tolerated (same as "field absent"),
/// matching the tolerance our other dep-map parsers apply.
///
/// Exposed (`pub`) so the lockfile parser can apply the same tolerance
/// — npm v2/v3 lockfiles preserve the array shape verbatim from the
/// originating `package.json`, so a strict map-only deserializer there
/// trips on the same ancient packages and blocks `aube ci` outright.
pub fn engines_tolerant<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<serde_json::Value> = Option::deserialize(de)?;
Ok(match value {
None
| Some(serde_json::Value::Null)
| Some(serde_json::Value::Array(_))
| Some(serde_json::Value::String(_)) => BTreeMap::new(),
Some(serde_json::Value::Object(m)) => m
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(s) => Some((k, s)),
_ => None,
})
.collect(),
Some(other) => {
// Null / Array / String / Object are handled above, so
// `other` can only be another scalar here.
return Err(serde::de::Error::custom(format!(
"engines: expected a map, got {}",
match other {
serde_json::Value::Number(_) => "number",
serde_json::Value::Bool(_) => "boolean",
_ => unreachable!("engines: unexpected value variant"),
}
)));
}
})
}
/// Deserialize `scripts` tolerant to non-string values. `firefox-profile`
/// (and a handful of other legacy packages) ships junk like
/// `"scripts": { "blanket": { "pattern": [...] } }` — tool-specific
/// config that npm's CLI treats as "not a runnable script" and ignores.
/// A strict `Record<string, string>` deserialization trips on the object
/// entry and fails the whole install. Drop non-string entries so the
/// real scripts still round-trip.
pub fn scripts_tolerant<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<serde_json::Value> = Option::deserialize(de)?;
Ok(match value {
None | Some(serde_json::Value::Null) => BTreeMap::new(),
Some(serde_json::Value::Object(m)) => m
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(s) => Some((k, s)),
_ => None,
})
.collect(),
Some(_) => BTreeMap::new(),
})
}
/// Tolerant dep-map deserializer. Same shape as scripts_tolerant
/// but used for dependencies / devDependencies / peerDependencies /
/// optionalDependencies. Real world manifests written by tools
/// sometimes emit `"peerDependencies": null` when a package has
/// none, and strict Record<string, string> deserialization rejects
/// that. npm and pnpm both tolerate null. Drop non-string values
/// (numbers, arrays, objects) silently since nothing sensible maps
/// those to a version range.
pub fn deps_tolerant<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<serde_json::Value> = Option::deserialize(de)?;
Ok(match value {
None | Some(serde_json::Value::Null) => BTreeMap::new(),
Some(serde_json::Value::Object(m)) => m
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(s) => Some((k, s)),
_ => None,
})
.collect(),
Some(_) => BTreeMap::new(),
})
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore_dependencies: Vec<String>,
}
/// Parsed `package.json`.
///
/// Deserializes via [`PackageJsonRaw`] (`#[serde(from = ...)]`) so a
/// manifest carrying *both* `bundledDependencies` and the deprecated
/// `bundleDependencies` alias parses without tripping serde's duplicate
/// field check. Some real-world publishes (e.g. `@lingui/message-utils`)
/// ship both.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", from = "PackageJsonRaw")]
pub struct PackageJson {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(
default,
deserialize_with = "deps_tolerant",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub dependencies: BTreeMap<String, String>,
#[serde(
default,
deserialize_with = "deps_tolerant",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub dev_dependencies: BTreeMap<String, String>,
#[serde(
default,
deserialize_with = "deps_tolerant",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub peer_dependencies: BTreeMap<String, String>,
#[serde(
default,
deserialize_with = "deps_tolerant",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub optional_dependencies: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_config: Option<UpdateConfig>,
/// `bundledDependencies` from package.json. Names listed here are
/// shipped *inside* the package tarball itself, under the package's
/// own `node_modules/`. The resolver must not recurse into them, and
/// Node's directory walk serves them straight out of the extracted
/// tree. On deserialize we also accept the deprecated
/// `bundleDependencies` spelling and prefer the canonical when both
/// are present (handled in [`PackageJsonRaw`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundled_dependencies: Option<BundledDependencies>,
#[serde(
default,
deserialize_with = "scripts_tolerant",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub scripts: BTreeMap<String, String>,
/// `engines` field — declared runtime version constraints, e.g.
/// `{"node": ">=18.0.0"}`. Checked against the current runtime during
/// `aube install`; a mismatch warns by default and fails under
/// `engine-strict`. See `engines_tolerant` for the legacy-shape
/// handling.
#[serde(
default,
deserialize_with = "engines_tolerant",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub engines: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspaces: Option<Workspaces>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
/// Deserialize-only mirror of [`PackageJson`] that splits the
/// `bundled_dependencies` field into two name-distinct slots so a
/// manifest carrying *both* `bundledDependencies` and `bundleDependencies`
/// doesn't trip serde's duplicate field check the way `#[serde(alias)]`
/// does. The canonical spelling wins on merge.
///
/// **Maintenance invariant:** every non-`bundled_dependencies` field
/// here must mirror its counterpart on [`PackageJson`] *byte-for-byte*
/// in serde attributes (`rename`, `deserialize_with`, `default`,
/// `flatten`, etc.). The `From` impl below catches missing fields at
/// compile time, but **attribute drift is silent** — e.g. forgetting
/// `deserialize_with = "deps_tolerant"` here would make the deserialize
/// path strict on dep-map shapes the public type silently tolerates.
/// When adding or modifying a field on `PackageJson`, update this
/// struct in lockstep.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PackageJsonRaw {
name: Option<String>,
version: Option<String>,
#[serde(default, deserialize_with = "deps_tolerant")]
dependencies: BTreeMap<String, String>,
#[serde(default, deserialize_with = "deps_tolerant")]
dev_dependencies: BTreeMap<String, String>,
#[serde(default, deserialize_with = "deps_tolerant")]
peer_dependencies: BTreeMap<String, String>,
#[serde(default, deserialize_with = "deps_tolerant")]
optional_dependencies: BTreeMap<String, String>,
#[serde(default)]
update_config: Option<UpdateConfig>,
#[serde(default, rename = "bundledDependencies")]
bundled_dependencies: Option<BundledDependencies>,
#[serde(default, rename = "bundleDependencies")]
bundle_dependencies_alias: Option<BundledDependencies>,
#[serde(default, deserialize_with = "scripts_tolerant")]
scripts: BTreeMap<String, String>,
#[serde(default, deserialize_with = "engines_tolerant")]
engines: BTreeMap<String, String>,
#[serde(default)]
workspaces: Option<Workspaces>,
#[serde(flatten)]
extra: BTreeMap<String, serde_json::Value>,
}
impl From<PackageJsonRaw> for PackageJson {
fn from(raw: PackageJsonRaw) -> Self {
Self {
name: raw.name,
version: raw.version,
dependencies: raw.dependencies,
dev_dependencies: raw.dev_dependencies,
peer_dependencies: raw.peer_dependencies,
optional_dependencies: raw.optional_dependencies,
update_config: raw.update_config,
bundled_dependencies: raw.bundled_dependencies.or(raw.bundle_dependencies_alias),
scripts: raw.scripts,
engines: raw.engines,
workspaces: raw.workspaces,
extra: raw.extra,
}
}
}
/// `bundledDependencies` shape from package.json. npm/pnpm accept
/// either an array of dep names or a boolean (`true` meaning "bundle
/// everything in `dependencies`"). We preserve both so the resolver
/// can compute the exact name set.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum BundledDependencies {
List(Vec<String>),
All(bool),
}
impl BundledDependencies {
/// The set of dep names that should be treated as bundled, given
/// the package's own `dependencies` map (needed for the `true`
/// form, which means "bundle every production dep").
pub fn names<'a>(&'a self, dependencies: &'a BTreeMap<String, String>) -> Vec<&'a str> {
match self {
BundledDependencies::List(v) => v.iter().map(String::as_str).collect(),
BundledDependencies::All(true) => dependencies.keys().map(String::as_str).collect(),
BundledDependencies::All(false) => Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Workspaces {
/// Bare single-pattern form. npm accepts
/// `"workspaces": "packages/*"` even though the docs only show
/// the array form. Some bun projects in the wild use it too.
/// Without this, those manifests fail to parse and user gets a
/// cryptic serde error pointing at the string.
String(String),
Array(Vec<String>),
Object {
// `packages` stays required (no `#[serde(default)]`) so that a
// typo like `"pacakges"` fails deserialization instead of
// silently producing an empty vec. Bun's object form always
// includes `packages`, so this doesn't lock out the catalog use
// case.
packages: Vec<String>,
#[serde(default)]
nohoist: Vec<String>,
/// Bun-style default catalog nested under `workspaces.catalog`.
/// Aube reads it in addition to `pnpm-workspace.yaml`'s `catalog:`
/// so bun projects that migrated config into package.json keep
/// working.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
catalog: BTreeMap<String, String>,
/// Bun-style named catalogs nested under `workspaces.catalogs`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
catalogs: BTreeMap<String, BTreeMap<String, String>>,
},
}
impl Workspaces {
pub fn patterns(&self) -> &[String] {
match self {
Workspaces::String(s) => std::slice::from_ref(s),
Workspaces::Array(v) => v,
Workspaces::Object { packages, .. } => packages,
}
}
/// Bun-style default catalog (`workspaces.catalog`). Empty when the
/// `workspaces` field is an array or the object form has no catalog.
pub fn catalog(&self) -> &BTreeMap<String, String> {
static EMPTY: std::sync::OnceLock<BTreeMap<String, String>> = std::sync::OnceLock::new();
match self {
Workspaces::String(_) | Workspaces::Array(_) => EMPTY.get_or_init(BTreeMap::new),
Workspaces::Object { catalog, .. } => catalog,
}
}
/// Bun-style named catalogs (`workspaces.catalogs`).
pub fn catalogs(&self) -> &BTreeMap<String, BTreeMap<String, String>> {
static EMPTY: std::sync::OnceLock<BTreeMap<String, BTreeMap<String, String>>> =
std::sync::OnceLock::new();
match self {
Workspaces::String(_) | Workspaces::Array(_) => EMPTY.get_or_init(BTreeMap::new),
Workspaces::Object { catalogs, .. } => catalogs,
}
}
}
/// Process-wide cache of parsed `package.json` files keyed by absolute
/// path. Hit by `aube run` 2-3 times per invocation (prompt path, type
/// parse, External catch-all). Miss falls through to a fresh read +
/// parse and inserts into the cache.
static PACKAGE_JSON_CACHE: aube_util::cache::ProcessCache<PathBuf, PackageJson> =
aube_util::cache::ProcessCache::new();
impl PackageJson {
pub fn from_path(path: &Path) -> Result<Self, Error> {
let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Manifest, "from_path")
.with_meta_fn(|| {
// Emit only the file name to avoid leaking absolute paths
// (and by extension home directory layout / employer-internal
// build roots) into traces shared in bug reports.
let display = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "package.json".to_string());
format!(r#"{{"path":{}}}"#, aube_util::diag::jstr(&display))
});
let content =
std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))?;
Self::parse(path, content)
}
/// Cached variant of [`Self::from_path`]. First caller per-path
/// pays the read + parse; later callers receive an `Arc` clone.
/// Errors are NOT cached (the next caller retries).
pub fn from_path_cached(path: &Path) -> Result<std::sync::Arc<Self>, Error> {
let key = path.to_path_buf();
if let Some(hit) = PACKAGE_JSON_CACHE.get(&key) {
return Ok(hit);
}
let parsed = Self::from_path(path)?;
Ok(PACKAGE_JSON_CACHE.get_or_compute(key, || parsed))
}
/// Parse an in-memory `package.json` string. On failure, produces a
/// [`Error::Parse`] with the source content and a span so `miette`'s
/// `fancy` handler renders a pointer at the offending byte.
pub fn parse(path: &Path, content: String) -> Result<Self, Error> {
parse_json(path, content)
}
/// Iterate over the `pnpm` and `aube` config objects in
/// `package.json`, yielding whichever are present in precedence
/// order (pnpm first, aube last). Callers that merge into a map
/// with later-wins semantics get `aube.*` overriding `pnpm.*` on
/// key conflict; callers that union lists get both sources
/// included. Aube mirrors every `pnpm.*` config key under an
/// `aube.*` alias so projects can declare aube-native config
/// without piggy-backing on the pnpm namespace.
fn pnpm_aube_objects(
&self,
) -> impl Iterator<Item = &serde_json::Map<String, serde_json::Value>> {
["pnpm", "aube"]
.into_iter()
.filter_map(|k| self.extra.get(k).and_then(|v| v.as_object()))
}
/// Extract the `pnpm.allowBuilds` / `aube.allowBuilds` object from
/// the raw `package.json` payload, if present. Returns a map keyed
/// by the raw pattern string (e.g. `"esbuild"`,
/// `"@swc/core@1.3.0"`) with `bool` values preserved as `bool` and
/// any other shape captured verbatim so the caller can warn about
/// it. `aube.*` wins over `pnpm.*` on key conflict.
///
/// The key is held in `extra` rather than as a named field because
/// it's nested under a `pnpm`/`aube` object.
pub fn pnpm_allow_builds(&self) -> BTreeMap<String, AllowBuildRaw> {
let mut out = BTreeMap::new();
for ns in self.pnpm_aube_objects() {
if let Some(map) = ns.get("allowBuilds").and_then(|v| v.as_object()) {
for (k, v) in map {
out.insert(k.clone(), AllowBuildRaw::from_json(v));
}
}
}
out
}
/// Extract `pnpm.onlyBuiltDependencies` / `aube.onlyBuiltDependencies`
/// as a flat list of package names allowed to run lifecycle
/// scripts. This is pnpm's canonical allowlist key (used by nearly
/// every real-world pnpm project) and coexists with `allowBuilds`
/// — all sources merge into the same `BuildPolicy`. Non-string
/// entries are dropped silently to match pnpm's tolerance for
/// malformed configs. Entries from `aube.*` are appended after
/// `pnpm.*` and deduped while preserving insertion order.
pub fn pnpm_only_built_dependencies(&self) -> Vec<String> {
let mut out = Vec::new();
for ns in self.pnpm_aube_objects() {
if let Some(arr) = ns.get("onlyBuiltDependencies").and_then(|v| v.as_array()) {
push_unique_strs(&mut out, arr);
}
}
out
}
/// Extract `pnpm.neverBuiltDependencies` /
/// `aube.neverBuiltDependencies` — the canonical denylist for
/// lifecycle scripts. Entries override any allowlist match in
/// `onlyBuiltDependencies` / `allowBuilds` since explicit denies
/// always win in `BuildPolicy::decide`. Entries union across both
/// namespaces with insertion order preserved.
pub fn pnpm_never_built_dependencies(&self) -> Vec<String> {
let mut out = Vec::new();
for ns in self.pnpm_aube_objects() {
if let Some(arr) = ns.get("neverBuiltDependencies").and_then(|v| v.as_array()) {
push_unique_strs(&mut out, arr);
}
}
out
}
/// Extract the top-level `trustedDependencies` array — Bun's
/// allowlist for lifecycle scripts. Treated as an additional
/// allow-source alongside `pnpm.onlyBuiltDependencies`, so bun
/// projects migrating to aube do not have to rewrite their manifest
/// to get scripts running. Non-string entries are dropped; a denylist
/// match in `neverBuiltDependencies` still wins at `decide()` time.
pub fn trusted_dependencies(&self) -> Vec<String> {
let mut out = Vec::new();
if let Some(arr) = self
.extra
.get("trustedDependencies")
.and_then(|v| v.as_array())
{
push_unique_strs(&mut out, arr);
}
out
}
/// Extract `pnpm.catalog` / `aube.catalog` — a default catalog
/// defined inline in package.json under the `pnpm`/`aube` object.
/// pnpm itself reads catalogs only from `pnpm-workspace.yaml`, but
/// aube also honors this location so single-package projects can
/// declare catalogs without maintaining a separate workspace
/// file. `aube.catalog` wins over `pnpm.catalog` on key conflict.
pub fn pnpm_catalog(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for ns in self.pnpm_aube_objects() {
if let Some(map) = ns.get("catalog").and_then(|v| v.as_object()) {
for (k, v) in map {
if let Some(s) = v.as_str() {
out.insert(k.clone(), s.to_string());
}
}
}
}
out
}
/// Extract `pnpm.catalogs` / `aube.catalogs` — named catalogs
/// nested under the `pnpm`/`aube` object. Pairs with
/// [`pnpm_catalog`] for a fully-package.json-local catalog
/// declaration. Named catalogs merge per-key across namespaces
/// (same rule as `pnpm_catalog`): `aube.catalogs.<name>.<pkg>`
/// wins over `pnpm.catalogs.<name>.<pkg>`, while entries declared
/// only on one side are preserved.
pub fn pnpm_catalogs(&self) -> BTreeMap<String, BTreeMap<String, String>> {
let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for ns in self.pnpm_aube_objects() {
if let Some(outer) = ns.get("catalogs").and_then(|v| v.as_object()) {
for (name, inner) in outer {
let Some(inner) = inner.as_object() else {
continue;
};
let catalog = out.entry(name.clone()).or_default();
for (k, v) in inner {
if let Some(s) = v.as_str() {
catalog.insert(k.clone(), s.to_string());
}
}
}
}
}
out
}
/// Extract `pnpm.ignoredOptionalDependencies` /
/// `aube.ignoredOptionalDependencies` — a list of dep names that
/// should be stripped from every manifest's `optionalDependencies`
/// before resolution. Mirrors pnpm's read-package hook at
/// `@pnpm/hooks.read-package-hook::createOptionalDependenciesRemover`.
/// Non-string entries are ignored. Entries from both namespaces
/// union into the returned set.
pub fn pnpm_ignored_optional_dependencies(&self) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for ns in self.pnpm_aube_objects() {
if let Some(arr) = ns
.get("ignoredOptionalDependencies")
.and_then(|v| v.as_array())
{
out.extend(arr.iter().filter_map(|v| v.as_str().map(String::from)));
}
}
out
}
/// Extract Bun's top-level `patchedDependencies` as a map of
/// `name@version` -> patch file path (relative to the project root).
/// Empty when the field is missing or malformed.
pub fn bun_patched_dependencies(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
if let Some(map) = self
.extra
.get("patchedDependencies")
.and_then(|v| v.as_object())
{
for (k, v) in map {
if let Some(s) = v.as_str() {
out.insert(k.clone(), s.to_string());
}
}
}
out
}
/// Extract `pnpm.patchedDependencies` / `aube.patchedDependencies`
/// as a map of `name@version` -> patch file path (relative to the
/// project root). Empty when the field is missing or malformed.
/// `aube.*` wins over `pnpm.*` on key conflict.
pub fn pnpm_patched_dependencies(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for ns in self.pnpm_aube_objects() {
if let Some(map) = ns.get("patchedDependencies").and_then(|v| v.as_object()) {
for (k, v) in map {
if let Some(s) = v.as_str() {
out.insert(k.clone(), s.to_string());
}
}
}
}
out
}
/// Return the set of dependency names marked
/// `dependenciesMeta.<name>.injected = true`. When present, pnpm
/// installs a hard copy of the resolved package (typically a
/// workspace sibling) instead of a symlink, so the consumer sees
/// the packed form — peer deps resolve against the consumer's
/// tree rather than the source package's devDependencies. Aube's
/// injection step reads this set after linking and rewrites each
/// top-level symlink to point at a freshly materialized copy
/// under `.aube/<name>@<version>+inject_<hash>/node_modules/<name>`.
pub fn dependencies_meta_injected(&self) -> BTreeSet<String> {
let Some(meta) = self
.extra
.get("dependenciesMeta")
.and_then(|v| v.as_object())
else {
return BTreeSet::new();
};
meta.iter()
.filter_map(|(k, v)| {
let injected = v.get("injected").and_then(|b| b.as_bool()).unwrap_or(false);
injected.then(|| k.clone())
})
.collect()
}
/// Return `{pnpm,aube}.supportedArchitectures.{os,cpu,libc}` as
/// three string arrays. Missing fields become empty vecs. Used by
/// the resolver to widen the set of platforms considered
/// installable for optional dependencies — e.g. resolving a
/// lockfile for a different target than the host running `aube
/// install`. Entries from `aube.*` are appended after `pnpm.*` and
/// deduped while preserving insertion order.
pub fn pnpm_supported_architectures(&self) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut os = Vec::new();
let mut cpu = Vec::new();
let mut libc = Vec::new();
for ns in self.pnpm_aube_objects() {
let Some(sa) = ns.get("supportedArchitectures").and_then(|v| v.as_object()) else {
continue;
};
if let Some(arr) = sa.get("os").and_then(|v| v.as_array()) {
push_unique_strs(&mut os, arr);
}
if let Some(arr) = sa.get("cpu").and_then(|v| v.as_array()) {
push_unique_strs(&mut cpu, arr);
}
if let Some(arr) = sa.get("libc").and_then(|v| v.as_array()) {
push_unique_strs(&mut libc, arr);
}
}
(os, cpu, libc)
}
/// Collect dependency overrides from every supported source on the
/// root manifest, merged in precedence order: yarn-style
/// `resolutions` (lowest), then `pnpm.overrides`, then
/// `aube.overrides`, then top-level `overrides` (highest). Keys
/// round-trip as their raw selector strings: bare name (`foo`),
/// parent-chain (`parent>foo`), version-suffixed (`foo@<2`,
/// `parent@1>foo`), and yarn wildcards (`**/foo`, `parent/foo`).
/// Structural validation lives in `aube_resolver::override_rule`;
/// this layer just filters out malformed keys and non-string
/// values. Workspace-level overrides from `pnpm-workspace.yaml`
/// are merged on top of this map by the caller.
pub fn overrides_map(&self) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, String> = BTreeMap::new();
let insert = |out: &mut BTreeMap<String, String>,
obj: &serde_json::Map<String, serde_json::Value>| {
for (k, v) in obj {
if let Some(s) = v.as_str()
&& is_valid_selector_key(k)
{
out.insert(k.clone(), s.to_string());
}
}
};
// yarn `resolutions` (lowest priority)
if let Some(obj) = self.extra.get("resolutions").and_then(|v| v.as_object()) {
insert(&mut out, obj);
}
// `pnpm.overrides` then `aube.overrides` (later wins)
for ns in self.pnpm_aube_objects() {
if let Some(obj) = ns.get("overrides").and_then(|v| v.as_object()) {
insert(&mut out, obj);
}
}
// Top-level `overrides` (npm / pnpm) — highest priority
if let Some(obj) = self.extra.get("overrides").and_then(|v| v.as_object()) {
insert(&mut out, obj);
}
out
}
/// Look up a package name in `dependencies`, then `devDependencies`,
/// then `optionalDependencies`, returning the declared version range.
/// Mirrors the lookup order pnpm/npm use for `$name` override
/// references. `peerDependencies` is intentionally excluded — a peer
/// range isn't a dependency the root pins and reusing it as an
/// override target would confuse rather than help.
pub fn direct_dependency_range(&self, name: &str) -> Option<&str> {
self.dependencies
.get(name)
.or_else(|| self.dev_dependencies.get(name))
.or_else(|| self.optional_dependencies.get(name))
.map(String::as_str)
}
/// Resolve `$name` override values in place against this manifest's
/// direct dependencies, per pnpm/npm's documented sibling-reference
/// syntax. Entries whose `$name` target isn't declared in
/// `dependencies` / `devDependencies` / `optionalDependencies` are
/// removed from the map; their raw selector keys are returned so the
/// caller can surface a diagnostic. Non-`$` values pass through
/// unchanged.
pub fn resolve_override_refs(&self, overrides: &mut BTreeMap<String, String>) -> Vec<String> {
let mut unresolved = Vec::new();
overrides.retain(|key, value| {
let Some(name) = value.strip_prefix('$') else {
return true;
};
match self.direct_dependency_range(name) {
Some(range) => {
*value = range.to_owned();
true
}
None => {
unresolved.push(key.clone());
false
}
}
});
unresolved
}
/// Extract `packageExtensions` from root package.json. Supports
/// top-level `packageExtensions`, `pnpm.packageExtensions`, and
/// `aube.packageExtensions`. Precedence (low → high):
/// `pnpm.packageExtensions`, `aube.packageExtensions`, top-level
/// `packageExtensions` — later writes win for duplicate selectors.
pub fn package_extensions(&self) -> BTreeMap<String, serde_json::Value> {
let mut out = BTreeMap::new();
for ns in self.pnpm_aube_objects() {
if let Some(obj) = ns.get("packageExtensions").and_then(|v| v.as_object()) {
for (k, v) in obj {
out.insert(k.clone(), v.clone());
}
}
}
if let Some(obj) = self
.extra
.get("packageExtensions")
.and_then(|v| v.as_object())
{
for (k, v) in obj {
out.insert(k.clone(), v.clone());
}
}
out
}
/// Extract package deprecation mute ranges. Supports top-level
/// `allowedDeprecatedVersions`, `pnpm.allowedDeprecatedVersions`,
/// and `aube.allowedDeprecatedVersions`; later sources win for
/// duplicate keys. Non-string values are ignored.
pub fn allowed_deprecated_versions(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let insert = |out: &mut BTreeMap<String, String>,
obj: &serde_json::Map<String, serde_json::Value>| {
for (k, v) in obj {
if let Some(s) = v.as_str() {
out.insert(k.clone(), s.to_string());
}
}
};
for ns in self.pnpm_aube_objects() {
if let Some(obj) = ns
.get("allowedDeprecatedVersions")
.and_then(|v| v.as_object())
{
insert(&mut out, obj);
}
}
if let Some(obj) = self
.extra
.get("allowedDeprecatedVersions")
.and_then(|v| v.as_object())
{
insert(&mut out, obj);
}
out
}
/// Extract `{pnpm,aube}.peerDependencyRules.ignoreMissing` as a
/// flat list of glob patterns. Non-string entries are dropped.
/// Mirrors pnpm's `peerDependencyRules` escape hatch — patterns
/// silence "missing required peer dependency" warnings when the
/// peer name matches. Entries from both namespaces union in the
/// returned list.
pub fn pnpm_peer_dependency_rules_ignore_missing(&self) -> Vec<String> {
self.pnpm_peer_dependency_rules_string_list("ignoreMissing")
}
/// Extract `{pnpm,aube}.peerDependencyRules.allowAny` as a flat
/// list of glob patterns. Peers whose name matches a pattern have
/// their semver check bypassed — any resolved version is accepted.
pub fn pnpm_peer_dependency_rules_allow_any(&self) -> Vec<String> {
self.pnpm_peer_dependency_rules_string_list("allowAny")
}
/// Extract `{pnpm,aube}.peerDependencyRules.allowedVersions` as a
/// map of selector -> additional semver range. Selectors are
/// either a bare peer name (e.g. `react`) meaning "applies to
/// every consumer of this peer", or `parent>peer` (e.g.
/// `styled-components>react`) meaning "only when declared by this
/// parent". Values widen the declared peer range: a peer resolving
/// inside *either* the declared range or this override is treated
/// as satisfied. Non-string entries are ignored. `aube.*` wins
/// over `pnpm.*` on key conflict.
pub fn pnpm_peer_dependency_rules_allowed_versions(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for ns in self.pnpm_aube_objects() {
let Some(rules) = ns.get("peerDependencyRules").and_then(|v| v.as_object()) else {
continue;
};
let Some(obj) = rules.get("allowedVersions").and_then(|v| v.as_object()) else {
continue;
};
for (k, v) in obj {
if let Some(s) = v.as_str() {
out.insert(k.clone(), s.to_string());
}
}
}
out
}
fn pnpm_peer_dependency_rules_string_list(&self, field: &str) -> Vec<String> {
let mut out = Vec::new();
for ns in self.pnpm_aube_objects() {
let Some(rules) = ns.get("peerDependencyRules").and_then(|v| v.as_object()) else {
continue;
};
let Some(arr) = rules.get(field).and_then(|v| v.as_array()) else {
continue;
};
push_unique_strs(&mut out, arr);
}
out
}
/// Extract `updateConfig.ignoreDependencies` from package.json
/// across all supported locations: top-level `updateConfig`,
/// `pnpm.updateConfig.ignoreDependencies`, and
/// `aube.updateConfig.ignoreDependencies`. All entries are merged
/// and deduped.
pub fn update_ignore_dependencies(&self) -> Vec<String> {
let mut out = Vec::new();
for ns in self.pnpm_aube_objects() {
if let Some(arr) = ns
.get("updateConfig")
.and_then(|v| v.as_object())
.and_then(|u| u.get("ignoreDependencies"))
.and_then(|v| v.as_array())
{
out.extend(arr.iter().filter_map(|v| v.as_str().map(String::from)));
}
}
if let Some(update_config) = &self.update_config {
out.extend(update_config.ignore_dependencies.iter().cloned());
}
out.sort();
out.dedup();
out
}
pub fn all_dependencies(&self) -> impl Iterator<Item = (&str, &str)> {
self.dependencies
.iter()
.chain(self.dev_dependencies.iter())
.map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn production_dependencies(&self) -> impl Iterator<Item = (&str, &str)> {
self.dependencies
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
}
}
/// Raw value shape for a single `allowBuilds` entry, preserved as-is
/// from the source JSON/YAML. Interpretation (allow / deny / warn
/// about unsupported shape) lives in `aube-scripts::policy`, keeping
/// this crate purely about parsing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AllowBuildRaw {
Bool(bool),
Other(String),
}
impl AllowBuildRaw {
fn from_json(v: &serde_json::Value) -> Self {
match v {
serde_json::Value::Bool(b) => Self::Bool(*b),
// Strings are stored verbatim — without `as_str` we'd get
// `Value::to_string`'s JSON-encoded form (`"foo"` with the
// outer quotes baked into the payload), which would defeat
// a downstream equality check against a known placeholder
// and would also make any warning surface show extra
// quotes the user didn't write.
serde_json::Value::String(s) => Self::Other(s.clone()),
other => Self::Other(other.to_string()),
}
}
}
/// Surface-level structural check on an override key. We accept any
/// non-empty key that isn't obviously a JSON typo — the resolver's
/// `override_rule` parser does the real work and silently drops keys
/// it can't interpret. Keeping the manifest filter loose means a pnpm
/// user with an unfamiliar-but-valid selector (e.g. `a@1>b@<2`)
/// reaches the resolver unchanged.
fn is_valid_selector_key(k: &str) -> bool {
!k.is_empty()
}
/// Append the string entries of `arr` to `dst`, skipping duplicates
/// already present and dropping non-string values. Preserves the
/// insertion order of first appearance — callers rely on this to keep
/// `pnpm.*` entries ahead of `aube.*` entries when both namespaces
/// contribute to the same list.
fn push_unique_strs(dst: &mut Vec<String>, arr: &[serde_json::Value]) {
for v in arr {
if let Some(s) = v.as_str()
&& !dst.iter().any(|existing| existing == s)
{
dst.push(s.to_string());
}
}
}
/// Union of `package.json`'s `{pnpm,aube}.supportedArchitectures.*` and
/// `pnpm-workspace.yaml`'s `supportedArchitectures.*`. pnpm v10 treats
/// the workspace yaml as the canonical home for shared platform
/// widening — a team generating a cross-platform lockfile on Linux CI
/// sets it there once rather than in every importer's manifest.
/// Insertion order: manifest first, workspace appended, duplicates
/// dropped (same dedupe rule `pnpm_supported_architectures` already
/// uses between the `pnpm.*` and `aube.*` namespaces).
pub fn effective_supported_architectures(
manifest: &PackageJson,
workspace: &workspace::WorkspaceConfig,
) -> (Vec<String>, Vec<String>, Vec<String>) {
let (mut os, mut cpu, mut libc) = manifest.pnpm_supported_architectures();
if let Some(ws) = &workspace.supported_architectures {
let extend_unique = |dst: &mut Vec<String>, src: &[String]| {
for s in src {
if !dst.iter().any(|existing| existing == s) {
dst.push(s.clone());
}
}
};
extend_unique(&mut os, &ws.os);
extend_unique(&mut cpu, &ws.cpu);
extend_unique(&mut libc, &ws.libc);
}
(os, cpu, libc)
}
/// Union of `package.json`'s `{pnpm,aube}.ignoredOptionalDependencies`
/// and `pnpm-workspace.yaml`'s `ignoredOptionalDependencies`. Same
/// layering rule as [`effective_supported_architectures`]: workspace
/// yaml is pnpm v10's canonical location for shared settings, so the
/// two sources union rather than override.
pub fn effective_ignored_optional_dependencies(
manifest: &PackageJson,
workspace: &workspace::WorkspaceConfig,
) -> BTreeSet<String> {
let mut out = manifest.pnpm_ignored_optional_dependencies();
out.extend(workspace.ignored_optional_dependencies.iter().cloned());
out
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("failed to read {0}: {1}")]
Io(std::path::PathBuf, std::io::Error),
#[error(transparent)]
#[diagnostic(transparent)]
Parse(Box<ParseError>),
#[error("failed to parse {0}: {1}")]
#[diagnostic(code(ERR_AUBE_MANIFEST_YAML_PARSE))]
YamlParse(std::path::PathBuf, String),
}
/// JSON parse failure with enough info for `miette`'s `fancy` handler to
/// render a pointer at the offending byte. Boxed into [`Error::Parse`] so
/// the enum's `Err` size stays small (clippy's `result_large_err`).
///
/// `Diagnostic` is implemented by hand rather than via `miette::Diagnostic`
/// derive because `miette-derive` 7.6 expands into a destructuring that
/// triggers `unused_assignments` under `RUSTFLAGS=-D warnings` on rustc
/// 1.93 (our MSRV).
#[derive(Debug, thiserror::Error)]
#[error("failed to parse {path}: {message}")]
pub struct ParseError {
pub path: std::path::PathBuf,
pub message: String,
pub src: miette::NamedSource<String>,
pub span: miette::SourceSpan,
}
impl miette::Diagnostic for ParseError {
fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
Some(Box::new(aube_codes::errors::ERR_AUBE_MANIFEST_PARSE))
}
fn source_code(&self) -> Option<&dyn miette::SourceCode> {
Some(&self.src)
}
fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
Some(Box::new(std::iter::once(
miette::LabeledSpan::new_with_span(Some(self.message.clone()), self.span),
)))
}
}
impl ParseError {
/// Build a `ParseError` from a `serde_json::Error`, computing the
/// byte offset so miette can render a pointer into `content`.
/// Shared across crates (see `aube_lockfile::Error::parse_json_err`)
/// so there's a single implementation of the line/col → byte-offset
/// conversion and span clamping.
pub fn from_json_err(path: &Path, content: String, err: &serde_json::Error) -> Self {
let offset = line_col_to_byte_offset(&content, err.line(), err.column());
// Clamp the span length so it never extends past the content
// end. A trailing-newline-EOF error reports a position at or
// past `content.len()`; a fixed length of 1 would push the
// range one byte past the source and miette's renderer would
// fail to slice it. A zero-length span at `content.len()` is
// what miette expects for "end-of-input" labels.
let len = if offset >= content.len() { 0 } else { 1 };
Self::new(path, content, err.to_string(), offset, len)
}
/// Build a `ParseError` from a `yaml_serde::Error`.
/// `yaml_serde::Location::index` is already a byte offset, so no
/// line/col conversion is needed. Errors without a location
/// (notably those bubbling from `yaml_serde::from_value`) collapse
/// to an empty span at offset 0 — miette still renders the file
/// name + message, just without a pointer.
pub fn from_yaml_err(path: &Path, content: String, err: &yaml_serde::Error) -> Self {
let (offset, len) = match err.location() {
Some(loc) => {
let idx = loc.index().min(content.len());
let len = if idx >= content.len() { 0 } else { 1 };
(idx, len)
}
None => (0, 0),
};
Self::new(path, content, err.to_string(), offset, len)
}
fn new(path: &Path, content: String, message: String, offset: usize, len: usize) -> Self {
ParseError {
path: path.to_path_buf(),
message,
src: miette::NamedSource::new(path.display().to_string(), content),
span: miette::SourceSpan::new(offset.into(), len),
}
}
}
/// Parse a JSON document from `content`, returning an [`Error::Parse`] on
/// failure with the source content + span attached so miette's fancy
/// handler can render a pointer into the offending file.
pub fn parse_json<T: serde::de::DeserializeOwned>(
path: &Path,
content: String,
) -> Result<T, Error> {
// Strip leading UTF-8 BOM (U+FEFF, bytes EF BB BF). Notepad on
// Windows writes BOM by default. VS Code can be configured to do
// the same. serde_json does not tolerate BOM, errors at "line 1
// column 1". npm and pnpm both tolerate it. Without this strip,
// opening package.json in Notepad, saving, then running aube
// returns a cryptic parse error. Cheap fix, no downside.
let content = if let Some(stripped) = content.strip_prefix('\u{FEFF}') {
stripped.to_owned()
} else {
content
};
if let Ok(v) = sonic_rs::from_slice(content.as_bytes()) {
return Ok(v);
}
match serde_json::from_str(&content) {
Ok(v) => Ok(v),
Err(e) => {
let trimmed = content.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with("/*") {
return Err(Error::parse_msg(
path,
content,
"package.json cannot contain JSON comments. \
Remove any `//` or `/* */` lines. aube does not support JSONC for package.json"
.to_string(),
));
}
Err(Error::parse(path, content, &e))
}
}
}
/// Parse a YAML document from `content`, returning an [`Error::Parse`] on
/// failure with the source content + span attached. `yaml_serde` reports
/// errors with a `Location { index, line, column }` we can feed straight
/// into a miette span; type-mismatch errors raised after `from_str`
/// succeeds (e.g. via `from_value`) have no location and render without
/// a pointer but still carry the file name.
pub fn parse_yaml<T: serde::de::DeserializeOwned>(
path: &Path,
content: String,
) -> Result<T, Error> {
match yaml_serde::from_str(&content) {
Ok(v) => Ok(v),
Err(e) => Err(Error::parse_yaml_err(path, content, &e)),
}
}
impl Error {
/// Build an [`Error::Parse`] from a `serde_json::Error`. Delegates
/// to [`ParseError::from_json_err`] — the crate-shared constructor
/// other crates (`aube-lockfile`) also reuse for their JSON parse
/// paths.
pub fn parse(path: &Path, content: String, err: &serde_json::Error) -> Self {
Error::Parse(Box::new(ParseError::from_json_err(path, content, err)))
}
/// Build an [`Error::Parse`] from a `yaml_serde::Error`. Delegates
/// to [`ParseError::from_yaml_err`].
pub fn parse_yaml_err(path: &Path, content: String, err: &yaml_serde::Error) -> Self {
Error::Parse(Box::new(ParseError::from_yaml_err(path, content, err)))
}
/// Build an [`Error::Parse`] with a plain message and a span
/// pointing at the start of the file. Used for hand-crafted
/// pre-parse diagnostics like the JSONC comment detector,
/// where we want a clear message without serde's usual
/// cryptic one.
pub fn parse_msg(path: &Path, content: String, message: String) -> Self {
let len = content.len();
let src = miette::NamedSource::new(path.display().to_string(), content);
let span = miette::SourceSpan::new(0.into(), len.min(1));
Error::Parse(Box::new(ParseError {
path: path.to_path_buf(),
message,
src,
span,
}))
}
}
/// Convert serde_json's 1-based line/column into a byte offset into
/// `content`. Out-of-range values clamp to the end so we never panic
/// on a degenerate error position.
fn line_col_to_byte_offset(content: &str, line: usize, column: usize) -> usize {
if line == 0 {
return 0;
}
let mut offset = 0usize;
for (i, l) in content.split_inclusive('\n').enumerate() {
if i + 1 == line {
return (offset + column.saturating_sub(1)).min(content.len());
}
offset += l.len();
}
content.len()
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> PackageJson {
serde_json::from_str(json).unwrap()
}
/// Pre-npm-2.x publishes (e.g. `extsprintf@1.4.1`, `coffee-script@1.3.3`)
/// ship `"engines": ["node >=0.6.0"]` as an array rather than a map.
/// Modern npm ignores the legacy shape; we do the same rather than
/// fail the whole manifest and take down every install that touches
/// one of these ancient packages.
#[test]
fn engines_legacy_array_form_parses_as_empty_map() {
let p = parse(r#"{"name":"x","engines":["node >=0.6.0"]}"#);
assert!(p.engines.is_empty());
}
/// Some old npm packument entries (e.g. `qs`) ship `engines` as a
/// bare string. There is no reliable key to preserve, so treat it
/// like the legacy array form and ignore it.
#[test]
fn engines_legacy_string_form_parses_as_empty_map() {
let p = parse(r#"{"name":"x","engines":"node >=0.6.0"}"#);
assert!(p.engines.is_empty());
}
#[test]
fn engines_null_is_treated_as_empty() {
let p = parse(r#"{"name":"x","engines":null}"#);
assert!(p.engines.is_empty());
}
#[test]
fn engines_modern_map_form_still_parses() {
let p = parse(r#"{"name":"x","engines":{"node":">=18.0.0","npm":">=9"}}"#);
assert_eq!(p.engines.get("node").unwrap(), ">=18.0.0");
assert_eq!(p.engines.get("npm").unwrap(), ">=9");
}
#[test]
fn engines_missing_field_is_empty() {
let p = parse(r#"{"name":"x"}"#);
assert!(p.engines.is_empty());
}
/// Sanity-check the line/column → byte-offset conversion. An
/// off-by-one here silently slides miette's pointer to the wrong
/// byte, defeating the whole reason the source span exists.
#[test]
fn line_col_offset_single_line_col_one() {
assert_eq!(line_col_to_byte_offset("{}", 1, 1), 0);
}
#[test]
fn line_col_offset_multiline_line_two() {
// "a\nbc\n" — line 2, column 1 is the 'b' at byte 2.
assert_eq!(line_col_to_byte_offset("a\nbc\n", 2, 1), 2);
assert_eq!(line_col_to_byte_offset("a\nbc\n", 2, 2), 3);
}
/// `line == 0` can happen if serde_json hits EOF before any input;
/// treat as "beginning of file" rather than panic.
#[test]
fn line_col_offset_line_zero_returns_zero() {
assert_eq!(line_col_to_byte_offset("any", 0, 5), 0);
}
/// A column past the end of its line (or past EOF) clamps to the
/// last valid offset so we never build a SourceSpan that would
/// crash miette's renderer.
#[test]
fn line_col_offset_column_past_end_clamps() {
let s = "ab";
assert_eq!(line_col_to_byte_offset(s, 1, 999), s.len());
}
/// A line past the last line falls through the loop and clamps to
/// the file end.
#[test]
fn line_col_offset_line_past_end_clamps() {
let s = "a\nb";
assert_eq!(line_col_to_byte_offset(s, 10, 1), s.len());
}
/// A file whose last line has no trailing `\n` is the common case;
/// make sure columns on that final line still resolve correctly.
#[test]
fn line_col_offset_no_trailing_newline() {
let s = "a\nbc";
assert_eq!(line_col_to_byte_offset(s, 2, 2), 3);
}
/// `serde_json` reports "EOF while parsing" with a position at or
/// past `content.len()` (e.g. `{"name":` → column 8 on a 8-byte
/// buffer). The span must never extend past the end of source or
/// `miette`'s renderer chokes trying to slice it — clamp the span
/// length to 0 at EOF.
#[test]
fn parse_error_eof_span_stays_in_bounds() {
let path = Path::new("pkg.json");
let content = r#"{"name":"#.to_string();
let json_err: serde_json::Error = serde_json::from_str::<serde_json::Value>(&content)
.expect_err("truncated JSON must fail");
let Error::Parse(pe) = Error::parse(path, content.clone(), &json_err) else {
panic!("Error::parse must produce Parse variant");
};
let offset: usize = pe.span.offset();
let len: usize = pe.span.len();
assert!(
offset + len <= content.len(),
"span [{offset}, {}) exceeds content.len() {}",
offset + len,
content.len()
);
}
/// A malformed YAML document should surface through `parse_yaml`
/// as an `Error::Parse` carrying a `NamedSource` pointed at the
/// supplied path and a span inside the content buffer.
#[test]
fn parse_yaml_attaches_source_span() {
let path = Path::new("pnpm-workspace.yaml");
// Tab as the first indent char is a spec-level YAML error;
// yaml_serde reports a location for it.
let content = "packages:\n\t- pkg\n".to_string();
let res: Result<yaml_serde::Value, Error> = parse_yaml(path, content.clone());
let Err(Error::Parse(pe)) = res else {
panic!("parse_yaml must produce Parse variant on malformed input");
};
let offset: usize = pe.span.offset();
let len: usize = pe.span.len();
assert!(offset + len <= content.len());
assert_eq!(pe.path, path);
}
/// `yaml_serde::from_value` errors have no `location()`. The helper
/// should still produce an `Error::Parse` (with a zero-length span)
/// so the file name survives into miette's render.
#[test]
fn parse_yaml_err_without_location_falls_back_to_empty_span() {
let path = Path::new("pnpm-workspace.yaml");
let content = String::new();
let yaml_err: yaml_serde::Error =
yaml_serde::from_value::<BTreeMap<String, String>>(yaml_serde::Value::Bool(true))
.expect_err("bool cannot coerce to a map");
assert!(yaml_err.location().is_none());
let Error::Parse(pe) = Error::parse_yaml_err(path, content, &yaml_err) else {
panic!("parse_yaml_err must produce Parse variant");
};
assert_eq!(pe.span.offset(), 0);
assert_eq!(pe.span.len(), 0);
}
#[test]
fn engines_map_drops_non_string_values() {
// Stay consistent with how our dep-map parsers treat redacted
// / non-string entries — drop, not fail.
let p = parse(r#"{"name":"x","engines":{"node":">=18","weird":null,"n":42}}"#);
assert_eq!(p.engines.get("node").unwrap(), ">=18");
assert!(!p.engines.contains_key("weird"));
assert!(!p.engines.contains_key("n"));
}
/// `firefox-profile@4.7.0` (and other legacy packages) ship tool
/// config nested under `scripts`, e.g. `scripts.blanket = {...}`.
/// npm ignores non-string entries instead of failing the install,
/// and so do we — drop them and keep the real scripts.
#[test]
fn scripts_non_string_entries_are_dropped() {
let p = parse(
r#"{
"name":"firefox-profile",
"scripts": {
"test": "grunt travis",
"blanket": { "pattern": ["/lib/firefox_profile"] }
}
}"#,
);
assert_eq!(
p.scripts.get("test").map(String::as_str),
Some("grunt travis")
);
assert!(!p.scripts.contains_key("blanket"));
}
#[test]
fn scripts_null_is_treated_as_empty() {
let p = parse(r#"{"name":"x","scripts":null}"#);
assert!(p.scripts.is_empty());
}
#[test]
fn scripts_non_object_value_is_treated_as_empty() {
// Mirrors `engines_tolerant`'s legacy-shape handling: if the
// field exists but isn't a map, treat it as absent rather than
// failing the parse.
let p = parse(r#"{"name":"x","scripts":"oops"}"#);
assert!(p.scripts.is_empty());
}
#[test]
fn selector_key_filter_accepts_valid_forms() {
assert!(is_valid_selector_key("lodash"));
assert!(is_valid_selector_key("@babel/core"));
assert!(is_valid_selector_key("foo>bar"));
assert!(is_valid_selector_key("**/foo"));
assert!(is_valid_selector_key("lodash@<4.17.21"));
assert!(is_valid_selector_key("a@1>b@<2"));
}
#[test]
fn selector_key_filter_rejects_empty() {
assert!(!is_valid_selector_key(""));
}
#[test]
fn overrides_map_collects_top_level() {
let p = parse(r#"{"overrides": {"lodash": "4.17.21"}}"#);
assert_eq!(p.overrides_map().get("lodash").unwrap(), "4.17.21");
}
#[test]
fn overrides_map_top_level_wins_over_pnpm_and_resolutions() {
let p = parse(
r#"{
"resolutions": {"lodash": "1.0.0"},
"pnpm": {"overrides": {"lodash": "2.0.0"}},
"overrides": {"lodash": "3.0.0"}
}"#,
);
assert_eq!(p.overrides_map().get("lodash").unwrap(), "3.0.0");
}
#[test]
fn overrides_map_merges_disjoint_keys() {
let p = parse(
r#"{
"resolutions": {"a": "1"},
"pnpm": {"overrides": {"b": "2"}},
"overrides": {"c": "3"}
}"#,
);
let m = p.overrides_map();
assert_eq!(m.get("a").unwrap(), "1");
assert_eq!(m.get("b").unwrap(), "2");
assert_eq!(m.get("c").unwrap(), "3");
}
#[test]
fn overrides_map_preserves_advanced_selector_keys() {
// Advanced selectors round-trip as raw keys; the resolver
// parses them later.
let p = parse(
r#"{
"overrides": {
"lodash": "4.17.21",
"foo>bar": "1.0.0",
"**/baz": "1.0.0",
"qux@<2": "1.0.0"
}
}"#,
);
let m = p.overrides_map();
assert_eq!(m.len(), 4);
assert!(m.contains_key("lodash"));
assert!(m.contains_key("foo>bar"));
assert!(m.contains_key("**/baz"));
assert!(m.contains_key("qux@<2"));
}
#[test]
fn overrides_map_supports_npm_alias_value() {
let p = parse(r#"{"overrides": {"foo": "npm:bar@^2"}}"#);
assert_eq!(p.overrides_map().get("foo").unwrap(), "npm:bar@^2");
}
#[test]
fn package_extensions_top_level_wins_over_pnpm() {
let p = parse(
r#"{
"pnpm": {"packageExtensions": {"foo": {"dependencies": {"a": "1"}}}},
"packageExtensions": {"foo": {"dependencies": {"a": "2"}}}
}"#,
);
assert_eq!(
p.package_extensions()
.get("foo")
.and_then(|v| v.pointer("/dependencies/a"))
.and_then(|v| v.as_str()),
Some("2")
);
}
#[test]
fn update_ignore_dependencies_merges_top_level_and_pnpm() {
let p = parse(
r#"{
"pnpm": {"updateConfig": {"ignoreDependencies": ["a"]}},
"updateConfig": {"ignoreDependencies": ["b"]}
}"#,
);
assert_eq!(p.update_ignore_dependencies(), vec!["a", "b"]);
}
#[test]
fn overrides_map_skips_object_values() {
// npm allows nested override objects; we don't support those yet,
// so they should be silently dropped rather than panicking.
let p = parse(r#"{"overrides": {"foo": {"bar": "1.0.0"}}}"#);
assert!(p.overrides_map().is_empty());
}
#[test]
fn resolve_override_refs_substitutes_from_dependencies() {
let p = parse(
r#"{
"dependencies": {"semver": "^7.5.2"},
"overrides": {"semver@<7.5.2": "$semver"}
}"#,
);
let mut m = p.overrides_map();
let unresolved = p.resolve_override_refs(&mut m);
assert!(unresolved.is_empty());
assert_eq!(m.get("semver@<7.5.2").unwrap(), "^7.5.2");
}
#[test]
fn resolve_override_refs_checks_dev_and_optional() {
let p = parse(
r#"{
"devDependencies": {"a": "1.0.0"},
"optionalDependencies": {"b": "2.0.0"},
"overrides": {"a": "$a", "b": "$b"}
}"#,
);
let mut m = p.overrides_map();
let unresolved = p.resolve_override_refs(&mut m);
assert!(unresolved.is_empty());
assert_eq!(m.get("a").unwrap(), "1.0.0");
assert_eq!(m.get("b").unwrap(), "2.0.0");
}
#[test]
fn resolve_override_refs_drops_unresolved() {
let p = parse(
r#"{
"dependencies": {"semver": "^7.5.2"},
"overrides": {
"semver@<7.5.2": "$semver",
"cacheable-request@<10": "$cacheable-request"
}
}"#,
);
let mut m = p.overrides_map();
let unresolved = p.resolve_override_refs(&mut m);
assert_eq!(unresolved, vec!["cacheable-request@<10".to_string()]);
assert_eq!(m.get("semver@<7.5.2").unwrap(), "^7.5.2");
assert!(!m.contains_key("cacheable-request@<10"));
}
#[test]
fn resolve_override_refs_passes_non_dollar_through() {
let p = parse(
r#"{
"dependencies": {"foo": "1.0.0"},
"overrides": {"foo": "2.0.0", "bar": "3.0.0"}
}"#,
);
let mut m = p.overrides_map();
let unresolved = p.resolve_override_refs(&mut m);
assert!(unresolved.is_empty());
assert_eq!(m.get("foo").unwrap(), "2.0.0");
assert_eq!(m.get("bar").unwrap(), "3.0.0");
}
#[test]
fn resolve_override_refs_ignores_peer_dependencies() {
// npm/pnpm resolve `$name` against direct deps only. Peer
// dependency ranges are contracts, not pins, so they shouldn't
// silently flow into override values.
let p = parse(
r#"{
"peerDependencies": {"react": "^18"},
"overrides": {"react": "$react"}
}"#,
);
let mut m = p.overrides_map();
let unresolved = p.resolve_override_refs(&mut m);
assert_eq!(unresolved, vec!["react".to_string()]);
assert!(m.is_empty());
}
#[test]
fn parses_bundled_dependencies_list() {
let p = parse(r#"{"name":"x","bundledDependencies":["foo","bar"]}"#);
let deps = BTreeMap::new();
let names = p.bundled_dependencies.as_ref().unwrap().names(&deps);
assert_eq!(names, vec!["foo", "bar"]);
}
#[test]
fn accepts_legacy_bundle_dependencies_alias() {
let p = parse(r#"{"name":"x","bundleDependencies":["foo"]}"#);
assert!(matches!(
p.bundled_dependencies,
Some(BundledDependencies::List(_))
));
}
/// Regression: some publishes (e.g. `@lingui/message-utils@5.2.0`+)
/// ship both `bundledDependencies` and the deprecated
/// `bundleDependencies` alias in the same object. serde's default
/// `alias` rejects that as a duplicate field; we accept it and
/// prefer the canonical spelling.
#[test]
fn accepts_both_bundle_and_bundled_dependencies() {
let p = parse(
r#"{"name":"x","bundledDependencies":["canonical"],"bundleDependencies":["legacy"]}"#,
);
let deps = BTreeMap::new();
let names = p.bundled_dependencies.as_ref().unwrap().names(&deps);
assert_eq!(names, vec!["canonical"]);
}
/// Same regression with the keys in the other order, since serde
/// field-collection is order-sensitive when alias collisions are
/// involved.
#[test]
fn accepts_both_bundle_and_bundled_dependencies_reverse_order() {
let p = parse(
r#"{"name":"x","bundleDependencies":["legacy"],"bundledDependencies":["canonical"]}"#,
);
let deps = BTreeMap::new();
let names = p.bundled_dependencies.as_ref().unwrap().names(&deps);
assert_eq!(names, vec!["canonical"]);
}
#[test]
fn bundle_true_means_all_production_deps() {
let p =
parse(r#"{"name":"x","dependencies":{"a":"1","b":"2"},"bundledDependencies":true}"#);
let names = p
.bundled_dependencies
.as_ref()
.unwrap()
.names(&p.dependencies);
assert_eq!(names, vec!["a", "b"]);
}
#[test]
fn peer_dependency_rules_accessors_read_nested_pnpm_block() {
let p = parse(
r#"{
"name":"x",
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": ["react", "react-dom"],
"allowAny": ["@types/*"],
"allowedVersions": {
"react": "^18.0.0",
"styled-components>react": "^17.0.0",
"ignored": 42
}
}
}
}"#,
);
assert_eq!(
p.pnpm_peer_dependency_rules_ignore_missing(),
vec!["react".to_string(), "react-dom".to_string()],
);
assert_eq!(
p.pnpm_peer_dependency_rules_allow_any(),
vec!["@types/*".to_string()],
);
let allowed = p.pnpm_peer_dependency_rules_allowed_versions();
assert_eq!(allowed.get("react").map(String::as_str), Some("^18.0.0"));
assert_eq!(
allowed.get("styled-components>react").map(String::as_str),
Some("^17.0.0"),
);
assert!(!allowed.contains_key("ignored"));
}
#[test]
fn peer_dependency_rules_accessors_empty_when_missing() {
let p = parse(r#"{"name":"x"}"#);
assert!(p.pnpm_peer_dependency_rules_ignore_missing().is_empty());
assert!(p.pnpm_peer_dependency_rules_allow_any().is_empty());
assert!(p.pnpm_peer_dependency_rules_allowed_versions().is_empty());
}
// --- aube.* namespace parity --------------------------------------
#[test]
fn aube_namespace_read_when_pnpm_missing() {
let p = parse(
r#"{
"aube": {
"onlyBuiltDependencies": ["esbuild"],
"neverBuiltDependencies": ["sharp"],
"ignoredOptionalDependencies": ["fsevents"],
"patchedDependencies": {"lodash@4.17.21": "patches/lodash.patch"},
"catalog": {"react": "^18.0.0"},
"catalogs": {"legacy": {"react": "^17.0.0"}},
"supportedArchitectures": {"os": ["linux", "win32"], "cpu": ["x64"]},
"overrides": {"lodash": "4.17.21"},
"packageExtensions": {"foo": {"dependencies": {"a": "1"}}},
"allowedDeprecatedVersions": {"request": "*"},
"peerDependencyRules": {
"ignoreMissing": ["react-native"],
"allowAny": ["@types/*"],
"allowedVersions": {"react": "^18.0.0"}
},
"updateConfig": {"ignoreDependencies": ["typescript"]},
"allowBuilds": {"esbuild": true}
}
}"#,
);
assert_eq!(p.pnpm_only_built_dependencies(), vec!["esbuild"]);
assert_eq!(p.pnpm_never_built_dependencies(), vec!["sharp"]);
assert!(p.pnpm_ignored_optional_dependencies().contains("fsevents"));
assert_eq!(
p.pnpm_patched_dependencies().get("lodash@4.17.21").unwrap(),
"patches/lodash.patch",
);
assert_eq!(p.pnpm_catalog().get("react").unwrap(), "^18.0.0");
assert_eq!(
p.pnpm_catalogs()
.get("legacy")
.and_then(|c| c.get("react"))
.unwrap(),
"^17.0.0",
);
let (os, cpu, libc) = p.pnpm_supported_architectures();
assert_eq!(os, vec!["linux", "win32"]);
assert_eq!(cpu, vec!["x64"]);
assert!(libc.is_empty());
assert_eq!(p.overrides_map().get("lodash").unwrap(), "4.17.21");
assert!(p.package_extensions().contains_key("foo"));
assert_eq!(p.allowed_deprecated_versions().get("request").unwrap(), "*",);
assert_eq!(
p.pnpm_peer_dependency_rules_ignore_missing(),
vec!["react-native".to_string()],
);
assert_eq!(
p.pnpm_peer_dependency_rules_allow_any(),
vec!["@types/*".to_string()],
);
assert_eq!(
p.pnpm_peer_dependency_rules_allowed_versions()
.get("react")
.unwrap(),
"^18.0.0",
);
assert_eq!(p.update_ignore_dependencies(), vec!["typescript"]);
assert!(matches!(
p.pnpm_allow_builds().get("esbuild"),
Some(AllowBuildRaw::Bool(true)),
));
}
#[test]
fn pnpm_allow_builds_round_trips_string_values_unwrapped() {
// Regression: `serde_json::Value::to_string()` on a string value
// produces JSON-encoded output (`"\"foo\""` with the outer
// quotes baked in). `AllowBuildRaw::from_json` must store the
// inner string verbatim so a downstream equality check against
// a known placeholder works and any user-visible warning shows
// the value the user actually wrote — not a re-quoted form.
let p = parse(
r#"{
"pnpm": {
"allowBuilds": {
"esbuild": "set this to true or false"
}
}
}"#,
);
let map = p.pnpm_allow_builds();
assert_eq!(
map.get("esbuild"),
Some(&AllowBuildRaw::Other(
"set this to true or false".to_string()
)),
);
}
#[test]
fn trusted_dependencies_reads_top_level_bun_format() {
let p = parse(
r#"{
"trustedDependencies": ["esbuild", "sharp", "esbuild"]
}"#,
);
assert_eq!(p.trusted_dependencies(), vec!["esbuild", "sharp"]);
}
#[test]
fn trusted_dependencies_absent_returns_empty() {
let p = parse(r#"{}"#);
assert!(p.trusted_dependencies().is_empty());
}
#[test]
fn trusted_dependencies_wrong_shape_returns_empty() {
let p = parse(r#"{"trustedDependencies": {"esbuild": true}}"#);
assert!(p.trusted_dependencies().is_empty());
}
#[test]
fn bun_patched_dependencies_reads_top_level_field() {
let p = parse(
r#"{
"patchedDependencies": {
"is-number@7.0.0": "patches/is-number.patch",
"ignored@1.0.0": false
}
}"#,
);
let got = p.bun_patched_dependencies();
assert_eq!(
got.get("is-number@7.0.0").unwrap(),
"patches/is-number.patch"
);
assert!(!got.contains_key("ignored@1.0.0"));
}
#[test]
fn aube_overrides_pnpm_on_key_conflict() {
// For map-valued configs, `aube.*` wins on key conflict while
// disjoint keys from either namespace merge.
let p = parse(
r#"{
"pnpm": {
"catalog": {"react": "^17.0.0", "lodash": "^4.0.0"},
"patchedDependencies": {"foo@1": "pnpm.patch"},
"allowedDeprecatedVersions": {"request": "^2.0.0"},
"overrides": {"lodash": "pnpm-value"}
},
"aube": {
"catalog": {"react": "^18.0.0"},
"patchedDependencies": {"foo@1": "aube.patch"},
"allowedDeprecatedVersions": {"request": "^3.0.0"},
"overrides": {"lodash": "aube-value"}
}
}"#,
);
let catalog = p.pnpm_catalog();
assert_eq!(catalog.get("react").unwrap(), "^18.0.0");
assert_eq!(catalog.get("lodash").unwrap(), "^4.0.0");
assert_eq!(
p.pnpm_patched_dependencies().get("foo@1").unwrap(),
"aube.patch",
);
assert_eq!(
p.allowed_deprecated_versions().get("request").unwrap(),
"^3.0.0",
);
assert_eq!(p.overrides_map().get("lodash").unwrap(), "aube-value");
}
#[test]
fn top_level_overrides_still_beat_aube_namespace() {
// Top-level `overrides` is the npm-standard surface and
// remains the highest-priority source.
let p = parse(
r#"{
"pnpm": {"overrides": {"lodash": "1"}},
"aube": {"overrides": {"lodash": "2"}},
"overrides": {"lodash": "3"}
}"#,
);
assert_eq!(p.overrides_map().get("lodash").unwrap(), "3");
}
#[test]
fn aube_supported_architectures_merges_with_pnpm() {
let p = parse(
r#"{
"pnpm": {"supportedArchitectures": {"os": ["linux"], "cpu": ["x64"]}},
"aube": {"supportedArchitectures": {"os": ["win32"], "libc": ["glibc"]}}
}"#,
);
let (os, cpu, libc) = p.pnpm_supported_architectures();
assert_eq!(os, vec!["linux", "win32"]);
assert_eq!(cpu, vec!["x64"]);
assert_eq!(libc, vec!["glibc"]);
}
#[test]
fn aube_list_configs_union_with_pnpm() {
let p = parse(
r#"{
"pnpm": {
"onlyBuiltDependencies": ["esbuild"],
"neverBuiltDependencies": ["sharp"],
"ignoredOptionalDependencies": ["fsevents"],
"peerDependencyRules": {
"ignoreMissing": ["react"],
"allowAny": ["@types/a"]
}
},
"aube": {
"onlyBuiltDependencies": ["swc"],
"neverBuiltDependencies": ["node-gyp"],
"ignoredOptionalDependencies": ["dtrace-provider"],
"peerDependencyRules": {
"ignoreMissing": ["react-native"],
"allowAny": ["@types/b"]
}
}
}"#,
);
assert_eq!(p.pnpm_only_built_dependencies(), vec!["esbuild", "swc"]);
assert_eq!(p.pnpm_never_built_dependencies(), vec!["sharp", "node-gyp"]);
let ignored = p.pnpm_ignored_optional_dependencies();
assert!(ignored.contains("fsevents"));
assert!(ignored.contains("dtrace-provider"));
assert_eq!(
p.pnpm_peer_dependency_rules_ignore_missing(),
vec!["react".to_string(), "react-native".to_string()],
);
assert_eq!(
p.pnpm_peer_dependency_rules_allow_any(),
vec!["@types/a".to_string(), "@types/b".to_string()],
);
}
#[test]
fn effective_supported_architectures_unions_manifest_and_workspace() {
let p = parse(
r#"{
"pnpm": {
"supportedArchitectures": {
"os": ["current", "linux"],
"cpu": ["x64"]
}
}
}"#,
);
let ws: workspace::WorkspaceConfig = yaml_serde::from_str(
r#"
supportedArchitectures:
os: ["win32"]
cpu: ["x64", "arm64"]
libc: ["glibc"]
"#,
)
.unwrap();
let (os, cpu, libc) = effective_supported_architectures(&p, &ws);
// Manifest first, workspace appended, duplicates dropped.
assert_eq!(os, vec!["current", "linux", "win32"]);
assert_eq!(cpu, vec!["x64", "arm64"]);
assert_eq!(libc, vec!["glibc"]);
}
#[test]
fn effective_supported_architectures_works_without_either_source() {
let p = parse(r#"{}"#);
let ws = workspace::WorkspaceConfig::default();
let (os, cpu, libc) = effective_supported_architectures(&p, &ws);
assert!(os.is_empty() && cpu.is_empty() && libc.is_empty());
}
#[test]
fn effective_ignored_optional_dependencies_unions_manifest_and_workspace() {
let p = parse(
r#"{
"pnpm": { "ignoredOptionalDependencies": ["fsevents"] }
}"#,
);
let ws: workspace::WorkspaceConfig = yaml_serde::from_str(
r#"
ignoredOptionalDependencies:
- dtrace-provider
- fsevents
"#,
)
.unwrap();
let merged = effective_ignored_optional_dependencies(&p, &ws);
assert!(merged.contains("fsevents"));
assert!(merged.contains("dtrace-provider"));
assert_eq!(merged.len(), 2);
}
#[test]
fn aube_catalogs_merge_per_key_within_named_catalog() {
// Same semantics as `pnpm_catalog`: aube wins per-key, and
// entries only declared on one side are preserved instead of
// being dropped when the catalog name exists on both sides.
let p = parse(
r#"{
"pnpm": {
"catalogs": {
"default": {"react": "^17.0.0", "lodash": "^4.0.0"},
"legacy": {"webpack": "^4.0.0"}
}
},
"aube": {
"catalogs": {
"default": {"react": "^18.0.0", "vite": "^5.0.0"}
}
}
}"#,
);
let cats = p.pnpm_catalogs();
let default = cats.get("default").expect("default catalog present");
assert_eq!(default.get("react").unwrap(), "^18.0.0");
assert_eq!(default.get("lodash").unwrap(), "^4.0.0");
assert_eq!(default.get("vite").unwrap(), "^5.0.0");
let legacy = cats.get("legacy").expect("legacy catalog preserved");
assert_eq!(legacy.get("webpack").unwrap(), "^4.0.0");
}
#[test]
fn aube_list_configs_dedupe_duplicates_across_namespaces() {
// Union semantics imply dedup: a name listed in both
// namespaces appears once, with first-seen ordering preserved.
let p = parse(
r#"{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp"],
"neverBuiltDependencies": ["evil"],
"peerDependencyRules": {
"ignoreMissing": ["react"],
"allowAny": ["@types/a"]
}
},
"aube": {
"onlyBuiltDependencies": ["esbuild", "swc"],
"neverBuiltDependencies": ["evil", "node-gyp"],
"peerDependencyRules": {
"ignoreMissing": ["react", "react-native"],
"allowAny": ["@types/a", "@types/b"]
}
}
}"#,
);
assert_eq!(
p.pnpm_only_built_dependencies(),
vec!["esbuild", "sharp", "swc"],
);
assert_eq!(p.pnpm_never_built_dependencies(), vec!["evil", "node-gyp"]);
assert_eq!(
p.pnpm_peer_dependency_rules_ignore_missing(),
vec!["react".to_string(), "react-native".to_string()],
);
assert_eq!(
p.pnpm_peer_dependency_rules_allow_any(),
vec!["@types/a".to_string(), "@types/b".to_string()],
);
}
#[test]
fn aube_update_config_merges_with_pnpm_and_top_level() {
let p = parse(
r#"{
"pnpm": {"updateConfig": {"ignoreDependencies": ["a"]}},
"aube": {"updateConfig": {"ignoreDependencies": ["b"]}},
"updateConfig": {"ignoreDependencies": ["c"]}
}"#,
);
assert_eq!(p.update_ignore_dependencies(), vec!["a", "b", "c"]);
}
}