Skip to main content

aube_resolver/
error.rs

1use crate::ResolveTask;
2use crate::semver_util::highest_stable_version;
3use crate::trust::{MissingTimeDetails, TrustDowngradeDetails};
4use aube_codes::errors::*;
5use aube_registry::Packument;
6
7#[derive(Debug, thiserror::Error)]
8pub enum Error {
9    #[error("no version of {} matches range `{}`", .0.name, .0.range)]
10    NoMatch(Box<NoMatchDetails>),
11    #[error(
12        "no version of {} matching {} is older than {} minute(s) (minimumReleaseAgeStrict=true)",
13        .0.name, .0.range, .0.minutes
14    )]
15    AgeGate(Box<AgeGateDetails>),
16    #[error("registry error for {0}: {1}")]
17    Registry(String, String),
18    #[error(
19        "{}: catalog reference `{}` does not resolve — catalog `{}` is not defined (add it to `catalog:` / `catalogs.{}:` in pnpm-workspace.yaml, or under `workspaces.catalog` / `pnpm.catalog` in package.json)",
20        .0.name, .0.spec, .0.catalog, .0.catalog
21    )]
22    UnknownCatalog(Box<CatalogDetails>),
23    #[error(
24        "{}: catalog reference `{}` does not resolve — catalog `{}` has no entry for `{}`",
25        .0.name, .0.spec, .0.catalog, .0.name
26    )]
27    UnknownCatalogEntry(Box<CatalogDetails>),
28    #[error(
29        "blocked exotic transitive dependency {}@{} from {} (blockExoticSubdeps=true; set blockExoticSubdeps=false to allow trusted git/file/tarball subdeps)",
30        .0.name, .0.spec, .0.parent
31    )]
32    BlockedExoticSubdep(Box<ExoticSubdepDetails>),
33    #[error(
34        "trust downgrade for {}@{} (trustPolicy=no-downgrade): earlier published version {} had {} but this version has {}",
35        .0.name, .0.picked_version, .0.prior_version, .0.prior_evidence.label(),
36        .0.current_evidence.map_or("no trust evidence", |e| e.label())
37    )]
38    TrustDowngrade(Box<TrustDowngradeDetails>),
39    #[error(
40        "trust check failed for {}@{} (trustPolicy=no-downgrade): registry packument has no `time` entry for the picked version",
41        .0.name, .0.version
42    )]
43    TrustCheckMissingTime(Box<MissingTimeDetails>),
44    #[error(
45        "peer-context fixed-point did not converge after {0} iterations. mutually recursive peers, lockfile would be incomplete"
46    )]
47    PeerContextDivergence(usize),
48}
49
50/// Context attached to a `NoMatch` error so the miette `help()` output can
51/// show importer path, parent chain, and what versions the packument
52/// actually contains. Boxed into the enum variant to keep `Error`'s size
53/// under `clippy::result_large_err`.
54#[derive(Debug)]
55pub struct NoMatchDetails {
56    pub name: String,
57    pub range: String,
58    pub importer: String,
59    pub ancestors: Vec<(String, String)>,
60    pub original_spec: Option<String>,
61    /// Up to 5 most-recent version strings from the packument. Stable
62    /// versions are preferred; when the packument contains only
63    /// prereleases we fall back to showing those so the diagnostic
64    /// doesn't misreport the packument as empty.
65    pub available: Vec<String>,
66    /// Total number of versions in the packument, including prereleases
67    /// and unparseable keys. Used by the help text to distinguish a
68    /// genuinely empty packument (wrong registry, missing package) from
69    /// one that only publishes prereleases.
70    pub total_versions: usize,
71    /// True when every shown entry in `available` is a prerelease — the
72    /// user asked for a stable range but the registry only has alpha /
73    /// beta / rc builds. Help text steers them toward `name@next` or a
74    /// prerelease range.
75    pub only_prereleases: bool,
76}
77
78#[derive(Debug)]
79pub struct AgeGateDetails {
80    pub name: String,
81    pub range: String,
82    pub minutes: u64,
83    pub importer: String,
84    pub ancestors: Vec<(String, String)>,
85    /// Version strings that satisfied the range but were blocked by
86    /// the age gate, sorted newest-first. Empty when the cutoff was
87    /// tighter than every published version.
88    pub gated: Vec<String>,
89}
90
91#[derive(Debug)]
92pub struct CatalogDetails {
93    pub name: String,
94    pub spec: String,
95    pub catalog: String,
96    /// For `UnknownCatalog`: the catalog names that *are* defined.
97    /// For `UnknownCatalogEntry`: the package names defined under
98    /// `catalog`. Empty when the catalog map itself is empty, or
99    /// when the error is a chained-catalog case (see `chained_value`).
100    pub available: Vec<String>,
101    /// Set only for the chained-catalog case: the entry exists, but
102    /// its value is itself another `catalog:` reference. Carries the
103    /// offending value (e.g. `catalog:other`) so the help text can
104    /// explain the chain rule rather than pretending the entry is
105    /// missing.
106    pub chained_value: Option<String>,
107}
108
109#[derive(Debug)]
110pub struct ExoticSubdepDetails {
111    pub name: String,
112    pub spec: String,
113    pub parent: String,
114    pub ancestors: Vec<(String, String)>,
115    pub importer: String,
116}
117
118impl miette::Diagnostic for Error {
119    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
120        Some(Box::new(match self {
121            Self::NoMatch(_) => ERR_AUBE_NO_MATCHING_VERSION,
122            Self::AgeGate(_) => ERR_AUBE_NO_MATURE_MATCHING_VERSION,
123            Self::Registry(_, _) => ERR_AUBE_REGISTRY_ERROR,
124            Self::UnknownCatalog(_) => ERR_AUBE_UNKNOWN_CATALOG,
125            Self::UnknownCatalogEntry(_) => ERR_AUBE_UNKNOWN_CATALOG_ENTRY,
126            Self::BlockedExoticSubdep(_) => ERR_AUBE_BLOCKED_EXOTIC_SUBDEP,
127            Self::TrustDowngrade(_) => ERR_AUBE_TRUST_DOWNGRADE,
128            Self::TrustCheckMissingTime(_) => ERR_AUBE_TRUST_MISSING_TIME,
129            Self::PeerContextDivergence(_) => ERR_AUBE_PEER_CONTEXT_NOT_CONVERGED,
130        }))
131    }
132
133    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
134        match self {
135            Self::NoMatch(d) => Some(Box::new(format_no_match_help(d))),
136            Self::AgeGate(d) => Some(Box::new(format_age_gate_help(d))),
137            Self::Registry(name, msg) => Some(Box::new(format_registry_help(name, msg))),
138            Self::UnknownCatalog(d) => Some(Box::new(format_unknown_catalog_help(d))),
139            Self::UnknownCatalogEntry(d) => Some(Box::new(format_unknown_catalog_entry_help(d))),
140            Self::BlockedExoticSubdep(d) => Some(Box::new(format_exotic_subdep_help(d))),
141            Self::TrustDowngrade(d) => Some(Box::new(format_trust_downgrade_help(d))),
142            Self::TrustCheckMissingTime(d) => Some(Box::new(format_trust_missing_time_help(d))),
143            Self::PeerContextDivergence(_) => None,
144        }
145    }
146}
147
148fn format_trust_downgrade_help(d: &TrustDowngradeDetails) -> String {
149    format!(
150        "this is a supply-chain trust failure, not an ordinary version-resolution error. \
151         An earlier release carried {prior_evidence}, but {name}@{ver} carries {current_evidence}.\n\
152         \n\
153         This can signal a compromised publisher or tampered release. It can also be benign \
154         release-process drift: a maintainer manually published, backported outside the trusted \
155         workflow, skipped provenance for convenience, or used a registry that stripped metadata.\n\
156         \n\
157         Before bypassing:\n\
158         1. Inspect the package's npm release, source tag/commit, publisher identity, and tarball; \
159         compare the metadata with npmjs.org, and confirm the change is expected and nothing \
160         appears tampered with.\n\
161         2. Report inconsistent evidence to the relevant upstream owner. Package-release drift \
162         belongs with the maintainer; metadata present on npmjs.org but missing from a proxy or \
163         mirror belongs with that registry operator.\n\
164         3. Only after review, pin a version that retains evidence or add the narrow \
165         `{name}@{ver}` exception to `trustPolicyExclude`. A bare `{name}` exempts every version; \
166         `trustPolicy = off` disables this protection for the entire install.\n\
167         \n\
168         Details and known built-in exceptions: https://aube.jdx.dev/trust-policy-exceptions",
169        prior_evidence = d.prior_evidence.label(),
170        current_evidence = d
171            .current_evidence
172            .map_or("no trust evidence", |e| e.label()),
173        name = d.name,
174        ver = d.picked_version,
175    )
176}
177
178fn format_trust_missing_time_help(d: &MissingTimeDetails) -> String {
179    format!(
180        "trustPolicy=no-downgrade compares against per-version publish times in the packument. \
181         The registry serving {name} omitted `time[{ver}]` — check the registry config in .npmrc, \
182         or set `trustPolicy = off` to skip the check.",
183        name = d.name,
184        ver = d.version,
185    )
186}
187
188/// Build a `NoMatchDetails` snapshot from the task that failed and the
189/// packument it was looked up against. Captures importer, parent chain,
190/// the original package.json spec (if rewritten by catalog/override/
191/// alias), and a sample of the highest non-prerelease versions so the
192/// diagnostic can tell the user how close they were.
193pub(crate) fn build_no_match(task: &ResolveTask, packument: &Packument) -> NoMatchDetails {
194    let mut stable: Vec<(node_semver::Version, &str)> = Vec::new();
195    let mut prerelease: Vec<(node_semver::Version, &str)> = Vec::new();
196    for v in packument.versions.keys() {
197        let Ok(parsed) = node_semver::Version::parse(v) else {
198            continue;
199        };
200        if parsed.pre_release.is_empty() {
201            stable.push((parsed, v.as_str()));
202        } else {
203            prerelease.push((parsed, v.as_str()));
204        }
205    }
206    stable.sort_by(|a, b| b.0.cmp(&a.0));
207    prerelease.sort_by(|a, b| b.0.cmp(&a.0));
208    let (pool, only_prereleases) = if stable.is_empty() {
209        (prerelease, true)
210    } else {
211        (stable, false)
212    };
213    let available = pool
214        .into_iter()
215        .take(5)
216        .map(|(_, s)| s.to_string())
217        .collect();
218    NoMatchDetails {
219        name: task.name.clone(),
220        range: task.range.clone(),
221        importer: task.importer.clone(),
222        ancestors: task.ancestors.to_vec(),
223        original_spec: task.original_specifier.clone(),
224        available,
225        total_versions: packument.versions.len(),
226        only_prereleases,
227    }
228}
229
230/// Build an `AgeGateDetails` snapshot: which versions actually
231/// satisfied the range but were blocked by the cutoff. Recomputed from
232/// the packument rather than threaded out of `pick_version` because
233/// the age-gate path is uncommon and the recompute cost is dwarfed by
234/// the resolution itself.
235/// Resolve a `task.range` string that may be a dist-tag (`"latest"`,
236/// `"next"`, …) to the concrete version it points at. Used by the
237/// diagnostic builders where we need to parse the range for display
238/// purposes after `pick_version` has already accepted or rejected it.
239/// Falls back to the raw input when nothing matches — callers treat a
240/// subsequent semver parse failure as "skip, best-effort".
241fn resolve_dist_tag_range(packument: &Packument, range_str: &str) -> String {
242    if let Some(tagged) = packument.dist_tags.get(range_str) {
243        tagged.clone()
244    } else if range_str == "latest"
245        && let Some(v) = highest_stable_version(packument)
246    {
247        v
248    } else {
249        range_str.to_string()
250    }
251}
252
253pub(crate) fn build_age_gate(
254    task: &ResolveTask,
255    packument: &Packument,
256    minutes: u64,
257) -> AgeGateDetails {
258    // Mirror `pick_version`'s dist-tag handling: if `task.range` is a
259    // tag name (e.g. `"latest"`, `"next"`), resolve it to the concrete
260    // version string before parsing. Without this the semver parse
261    // fails silently and the help text drops the "blocked by age gate"
262    // line entirely, losing the most useful diagnostic.
263    let effective = resolve_dist_tag_range(packument, &task.range);
264    let range = node_semver::Range::parse(&effective).ok();
265    let mut gated: Vec<(node_semver::Version, String)> = Vec::new();
266    if let Some(r) = range {
267        for ver in packument.versions.keys() {
268            let Ok(v) = node_semver::Version::parse(ver) else {
269                continue;
270            };
271            if !v.satisfies(&r) {
272                continue;
273            }
274            gated.push((v, ver.clone()));
275        }
276    }
277    gated.sort_by(|a, b| b.0.cmp(&a.0));
278    AgeGateDetails {
279        name: task.name.clone(),
280        range: task.range.clone(),
281        minutes,
282        importer: task.importer.clone(),
283        ancestors: task.ancestors.to_vec(),
284        gated: gated.into_iter().map(|(_, s)| s).collect(),
285    }
286}
287
288fn format_no_match_help(d: &NoMatchDetails) -> String {
289    let mut s = String::new();
290    push_importer(&mut s, &d.importer);
291    push_chain(&mut s, &d.ancestors, &d.name);
292    if let Some(orig) = &d.original_spec
293        && orig != &d.range
294    {
295        s.push_str(&format!(
296            "original spec: `{orig}` (rewritten to `{}`)\n",
297            d.range
298        ));
299    }
300    if d.available.is_empty() {
301        if d.total_versions == 0 {
302            s.push_str("packument has no versions — check that the package exists on the configured registry");
303        } else {
304            s.push_str(&format!(
305                "packument has {} unparseable version(s) — check registry for non-semver tags",
306                d.total_versions
307            ));
308        }
309    } else if d.only_prereleases {
310        s.push_str(&format!(
311            "no stable versions published; only prereleases available: {}\nhint: request a prerelease explicitly (e.g. `{}@{}`) or via the `next` dist-tag",
312            d.available.join(", "),
313            d.name,
314            d.available.first().map(String::as_str).unwrap_or("next"),
315        ));
316    } else {
317        s.push_str(&format!("available versions: {}", d.available.join(", ")));
318    }
319    s
320}
321
322fn format_age_gate_help(d: &AgeGateDetails) -> String {
323    let mut s = String::new();
324    push_importer(&mut s, &d.importer);
325    push_chain(&mut s, &d.ancestors, &d.name);
326    if !d.gated.is_empty() {
327        s.push_str(&format!(
328            "blocked by age gate: {}\n",
329            d.gated
330                .iter()
331                .take(5)
332                .cloned()
333                .collect::<Vec<_>>()
334                .join(", ")
335        ));
336    }
337    s.push_str("to bypass: loosen `minimumReleaseAge` in .npmrc, set `minimumReleaseAgeStrict=false` to fall back to the lowest satisfying version, or add `");
338    s.push_str(&d.name);
339    s.push_str("` to `minimumReleaseAgeExclude`");
340    s
341}
342
343pub(crate) fn format_registry_help(name: &str, msg: &str) -> String {
344    let kind = classify_registry_error(msg);
345    let mut s = String::new();
346    if !name.is_empty() && name != "(resolver)" {
347        s.push_str(&format!("package: {name}\n"));
348    }
349    s.push_str(match kind {
350        RegistryErrorKind::Tarball => {
351            "tarball download or integrity check failed — try `aube store prune` to clear the cache; if the lockfile references a tarball that moved, delete the lockfile entry for this package and re-resolve"
352        }
353        RegistryErrorKind::Fetch => {
354            "packument fetch failed — verify the registry URL in .npmrc, check auth (`npm login` / `NPM_TOKEN`), and confirm network connectivity"
355        }
356        RegistryErrorKind::Git => {
357            "git dep failed to resolve — confirm the ref exists, that credentials are configured for the host, and that the URL form is supported"
358        }
359        RegistryErrorKind::LocalSpec => {
360            "unparseable local specifier — `file:`/`link:`/`workspace:` paths must be relative to the importer, and `http(s):` URLs must end in `.tgz`"
361        }
362        RegistryErrorKind::Hook => {
363            "pnpmfile `readPackage` hook returned an error — check the hook's stack trace above for the underlying cause"
364        }
365        RegistryErrorKind::ResolverBug => {
366            "internal resolver invariant violated — please report at https://github.com/jdx/aube/discussions with the lockfile and command that reproduced this"
367        }
368        RegistryErrorKind::Generic => {
369            "registry operation failed — see the message above for the underlying cause"
370        }
371    });
372    s
373}
374
375fn format_unknown_catalog_help(d: &CatalogDetails) -> String {
376    let mut s = String::new();
377    if d.available.is_empty() {
378        s.push_str("no catalogs are defined in this workspace; add a `catalog:` block to `pnpm-workspace.yaml` or a `workspaces.catalog` entry in root `package.json`");
379    } else {
380        s.push_str(&format!("defined catalogs: {}", d.available.join(", ")));
381    }
382    s
383}
384
385fn format_unknown_catalog_entry_help(d: &CatalogDetails) -> String {
386    if let Some(chained) = &d.chained_value {
387        return format!(
388            "catalogs cannot chain — replace `{}` with a concrete semver range (e.g. `^1.0.0`) under the catalog entry",
389            chained
390        );
391    }
392    let mut s = String::new();
393    if d.available.is_empty() {
394        s.push_str(&format!(
395            "catalog `{}` is empty; add `{}: <version>` under `catalogs.{}` in pnpm-workspace.yaml",
396            d.catalog, d.name, d.catalog
397        ));
398    } else {
399        let suggestion = suggest_similar(&d.name, &d.available);
400        if let Some(best) = suggestion {
401            s.push_str(&format!(
402                "catalog `{}` defines: {} — did you mean `{}`?",
403                d.catalog,
404                truncate_list(&d.available, 8),
405                best
406            ));
407        } else {
408            s.push_str(&format!(
409                "catalog `{}` defines: {}",
410                d.catalog,
411                truncate_list(&d.available, 8)
412            ));
413        }
414    }
415    s
416}
417
418fn format_exotic_subdep_help(d: &ExoticSubdepDetails) -> String {
419    let mut s = String::new();
420    push_importer(&mut s, &d.importer);
421    push_chain(&mut s, &d.ancestors, &d.name);
422    s.push_str(&format!(
423        "to allow: either pin `{}` in your root package.json (moves the exotic spec out of the transitive graph), or set `blockExoticSubdeps=false` in .npmrc / settings.toml to trust every transitive git/file/tarball dep",
424        d.name
425    ));
426    s
427}
428
429fn push_importer(s: &mut String, importer: &str) {
430    if !importer.is_empty() && importer != "." {
431        s.push_str(&format!("importer: {importer}\n"));
432    }
433}
434
435fn push_chain(s: &mut String, ancestors: &[(String, String)], leaf: &str) {
436    if ancestors.is_empty() {
437        return;
438    }
439    s.push_str("chain: ");
440    for (i, (n, v)) in ancestors.iter().enumerate() {
441        if i > 0 {
442            s.push_str(" > ");
443        }
444        s.push_str(&format!("{n}@{v}"));
445    }
446    s.push_str(&format!(" > {leaf}\n"));
447}
448
449fn truncate_list(items: &[String], max: usize) -> String {
450    if items.len() <= max {
451        items.join(", ")
452    } else {
453        let (head, tail) = items.split_at(max);
454        format!("{} (+{} more)", head.join(", "), tail.len())
455    }
456}
457
458/// Suggest the closest string in `choices` to `needle` using a simple
459/// case-insensitive prefix/substring match, falling back to first-char
460/// equality. Returns `None` when nothing plausibly matches. This is a
461/// deliberately cheap heuristic — good enough for catalog typos,
462/// nothing more.
463fn suggest_similar<'a>(needle: &str, choices: &'a [String]) -> Option<&'a str> {
464    let lower = needle.to_ascii_lowercase();
465    choices
466        .iter()
467        .map(String::as_str)
468        .find(|c| {
469            c.to_ascii_lowercase().contains(&lower) || lower.contains(&c.to_ascii_lowercase())
470        })
471        .or_else(|| {
472            choices
473                .iter()
474                .map(String::as_str)
475                .find(|c| c.chars().next() == needle.chars().next())
476        })
477}
478
479pub(crate) enum RegistryErrorKind {
480    Tarball,
481    Fetch,
482    Git,
483    LocalSpec,
484    Hook,
485    ResolverBug,
486    Generic,
487}
488
489/// Coarse classification by substring match. Registry errors carry
490/// free-form `format!` strings from helper functions that already embed
491/// intent ("fetch ", "tarball ", "git ", "readPackage", etc.), so a
492/// lightweight match on those prefixes lets us pick a targeted help
493/// message without plumbing a new enum through every call site.
494pub(crate) fn classify_registry_error(msg: &str) -> RegistryErrorKind {
495    let lower = msg.to_ascii_lowercase();
496    // Specific-prefix branches (git, hook, local-spec) must run before
497    // the generic `http` / `tarball` substring checks: each of those
498    // error payloads can itself embed an https:// URL or a tarball
499    // path, so a bare substring match on later arms would steal them.
500    if lower.starts_with("git resolve ")
501        || lower.starts_with("git dep ")
502        || lower.starts_with("git task ")
503        || lower.contains("git+")
504    {
505        RegistryErrorKind::Git
506    } else if lower.starts_with("readpackage ") || lower.contains("readpackage hook") {
507        RegistryErrorKind::Hook
508    } else if lower.starts_with("unparseable local specifier") || lower.contains("workspace:") {
509        RegistryErrorKind::LocalSpec
510    } else if lower.contains("tarball") || lower.contains("integrity") {
511        RegistryErrorKind::Tarball
512    } else if lower.starts_with("fetch ") || lower.contains("packument") || lower.contains("http") {
513        RegistryErrorKind::Fetch
514    } else if lower.contains("deferred") || lower.contains("invariant") {
515        RegistryErrorKind::ResolverBug
516    } else {
517        RegistryErrorKind::Generic
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::trust::TrustEvidence;
525
526    #[test]
527    fn trust_downgrade_help_prioritizes_investigation_and_upstream_reporting() {
528        let help = format_trust_downgrade_help(&TrustDowngradeDetails {
529            name: "@scope/pkg".into(),
530            picked_version: "2.0.0".into(),
531            current_evidence: None,
532            prior_evidence: TrustEvidence::TrustedPublisher,
533            prior_version: "1.9.0".into(),
534        });
535
536        assert!(help.contains("not an ordinary version-resolution error"));
537        assert!(help.contains("carries no trust evidence"));
538        assert!(help.contains("confirm the change is expected and nothing appears tampered with"));
539        assert!(help.contains("Report inconsistent evidence to the relevant upstream owner"));
540        assert!(help.contains("belongs with that registry operator"));
541        assert!(help.contains("`@scope/pkg@2.0.0` exception"));
542        assert!(help.contains("A bare `@scope/pkg` exempts every version"));
543        assert!(help.contains("https://aube.jdx.dev/trust-policy-exceptions"));
544    }
545}