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