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