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#[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 pub available: Vec<String>,
49 pub total_versions: usize,
54 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 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 pub available: Vec<String>,
84 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
114pub(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
156fn 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 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
384fn 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
415pub(crate) fn classify_registry_error(msg: &str) -> RegistryErrorKind {
421 let lower = msg.to_ascii_lowercase();
422 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}