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#[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 pub available: Vec<String>,
62 pub total_versions: usize,
67 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 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 pub available: Vec<String>,
97 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
163pub(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
205fn 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 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
433fn 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
464pub(crate) fn classify_registry_error(msg: &str) -> RegistryErrorKind {
470 let lower = msg.to_ascii_lowercase();
471 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}