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#[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 pub available: Vec<String>,
61 pub total_versions: usize,
66 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 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 pub available: Vec<String>,
96 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
149pub(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
191fn 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 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
419fn 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
450pub(crate) fn classify_registry_error(msg: &str) -> RegistryErrorKind {
456 let lower = msg.to_ascii_lowercase();
457 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}