Skip to main content

aube_resolver/
error.rs

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