From a26bb5b544582607008ffcc422d4ab7b948dcdde Mon Sep 17 00:00:00 2001 From: yuwk <1729065730@qq.com> Date: Thu, 15 Oct 2026 16:30:53 +0000 Subject: [PATCH] fix(lsp): respect minimumDependencyAge in completions and resolution Import-specifier completions and LSP npm/jsr resolution used empty newest-dependency-date options, so they could offer versions newer than the workspace minimumDependencyAge. Use the same age, exclude, and fallback resolution as the CLI, and rebuild the search APIs when config reloads. Co-authored-by: ump45nose --- cli/lsp/config.rs | 30 ++++++++ cli/lsp/jsr.rs | 49 +++++++++++- cli/lsp/language_server.rs | 84 ++++++++++++++------ cli/lsp/resolver.rs | 21 ++++- libs/resolver/factory.rs | 114 ++++++++++++++++++---------- tests/integration/lsp_tests.rs | 135 +++++++++++++++++++++++++++++++++ 6 files changed, 363 insertions(+), 70 deletions(-) diff --git a/cli/lsp/config.rs b/cli/lsp/config.rs index b2e2120ac86bcf..f925821a3cdba9 100644 --- a/cli/lsp/config.rs +++ b/cli/lsp/config.rs @@ -59,6 +59,7 @@ use indexmap::IndexSet; use lsp_types::ClientCapabilities; use lsp_types::Uri; use node_resolver::NodeResolverOptions; +use sys_traits::SystemTimeNow; use tower_lsp::lsp_types as lsp; use super::logging::lsp_log; @@ -1775,6 +1776,35 @@ impl ConfigData { self.member_dir.member_or_root_pkg_json() } + /// Workspace `minimumDependencyAge`, with the same `.npmrc` and default + /// fallbacks the CLI uses when the config does not set an age. + pub fn minimum_dependency_age_config( + &self, + ) -> deno_config::deno_json::MinimumDependencyAgeConfig { + let parsed = match self + .member_dir + .workspace + .minimum_dependency_age(&CliSys::default()) + { + Ok(config) => config, + Err(err) => { + lsp_warn!( + "Failed to parse \"minimumDependencyAge\" for \"{}\": {err}", + self.scope + ); + Default::default() + } + }; + deno_resolver::factory::resolve_minimum_dependency_age_config( + parsed, + self + .npmrc + .as_ref() + .and_then(|npmrc| npmrc.min_release_age_days), + chrono::DateTime::::from(CliSys::default().sys_time_now()), + ) + } + pub fn scope_contains_specifier(&self, specifier: &ModuleSpecifier) -> bool { specifier.as_str().starts_with(self.scope.as_str()) || self diff --git a/cli/lsp/jsr.rs b/cli/lsp/jsr.rs index 36a1f076a0971f..cfae4a62b7b32c 100644 --- a/cli/lsp/jsr.rs +++ b/cli/lsp/jsr.rs @@ -21,6 +21,7 @@ use deno_semver::jsr::JsrPackageReqReference; use deno_semver::package::PackageNv; use deno_semver::package::PackageReq; use serde::Deserialize; +use sys_traits::SystemTimeNow; use super::config::ConfigData; use super::search::PackageSearchApi; @@ -48,6 +49,8 @@ pub struct JsrCacheResolver { info_by_name: DashMap>>, workspace_packages_by_name: HashMap, cache: Arc, + newest_dependency_date_options: + deno_graph::packages::NewestDependencyDateOptions, } impl JsrCacheResolver { @@ -56,6 +59,22 @@ impl JsrCacheResolver { config_data: Option<&ConfigData>, workspace_resolver: &WorkspaceResolver, ) -> Self { + let newest_dependency_date_options = match config_data { + Some(config_data) => { + deno_resolver::factory::jsr_newest_dependency_date_options( + &config_data.minimum_dependency_age_config(), + ) + } + None => deno_resolver::factory::jsr_newest_dependency_date_options( + &deno_resolver::factory::resolve_minimum_dependency_age_config( + Default::default(), + None, + chrono::DateTime::::from( + CliSys::default().sys_time_now(), + ), + ), + ), + }; let nv_by_req = DashMap::new(); let info_by_nv = DashMap::new(); let info_by_name = DashMap::new(); @@ -127,6 +146,7 @@ impl JsrCacheResolver { info_by_name, workspace_packages_by_name, cache: cache.clone(), + newest_dependency_date_options, } } @@ -147,6 +167,8 @@ impl JsrCacheResolver { &req.version_req, &package_info, ); + let newest_dependency_date = + self.newest_dependency_date_options.get_for_package(name); let version = versions .into_iter() .rev() @@ -160,6 +182,12 @@ impl JsrCacheResolver { { return false; } + if let Some(cutoff) = newest_dependency_date + && let Some(version_info) = package_info.versions.get(*v) + && !version_info.matches_newest_dependency_date(cutoff) + { + return false; + } let nv = PackageNv { name: name.clone(), version: (*v).clone(), @@ -372,12 +400,14 @@ pub struct CliJsrSearchApi { } impl CliJsrSearchApi { - pub fn new(file_fetcher: Arc) -> Self { + pub fn new( + file_fetcher: Arc, + newest_dependency_date_options: deno_graph::packages::NewestDependencyDateOptions, + ) -> Self { let resolver = JsrFetchResolver::new( file_fetcher.clone(), Arc::new(JsrVersionResolver { - // not currently supported in the lsp - newest_dependency_date_options: Default::default(), + newest_dependency_date_options, }), ); Self { @@ -428,7 +458,18 @@ impl PackageSearchApi for CliJsrSearchApi { .package_info(name) .await .ok_or_else(|| anyhow!("JSR package info not found: {}", name))?; - let mut versions = info.versions.keys().cloned().collect::>(); + let package_name = deno_semver::package::PackageName::from(name); + let version_resolver = self + .resolver + .version_resolver_for_package(&package_name, &info); + let mut versions = info + .versions + .iter() + .filter(|(_, version_info)| { + version_resolver.matches_newest_dependency_date(version_info) + }) + .map(|(version, _)| version.clone()) + .collect::>(); versions.sort(); versions.reverse(); let versions = Arc::new(versions); diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index 2b76df103e21b4..7ff4d2fb9a1036 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -46,6 +46,7 @@ use indexmap::IndexSet; use log::error; use serde::Deserialize; use serde_json::from_value; +use sys_traits::SystemTimeNow; use tokio::sync::OnceCell; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::UnboundedSender; @@ -622,6 +623,47 @@ impl LanguageServer { } } +/// `minimumDependencyAge` for import completions. The search APIs are +/// process-global, so this follows the root workspace folder (then any other +/// loaded scope). Resolution itself is per-scope. +fn minimum_dependency_age_config( + config: &Config, +) -> deno_config::deno_json::MinimumDependencyAgeConfig { + let data = config + .root_url() + .and_then(|root| config.tree.data_for_specifier(root)) + .or_else(|| config.tree.data_by_scope().values().next()); + match data { + Some(data) => data.minimum_dependency_age_config(), + None => deno_resolver::factory::resolve_minimum_dependency_age_config( + Default::default(), + None, + chrono::DateTime::::from(CliSys::default().sys_time_now()), + ), + } +} + +fn package_search_apis( + file_fetcher: Arc, + config: &deno_config::deno_json::MinimumDependencyAgeConfig, +) -> (CliJsrSearchApi, CliNpmSearchApi) { + let jsr_search_api = CliJsrSearchApi::new( + file_fetcher.clone(), + deno_resolver::factory::jsr_newest_dependency_date_options(config), + ); + let npm_search_api = CliNpmSearchApi::new( + file_fetcher, + Arc::new(NpmVersionResolver { + link_packages: Default::default(), + newest_dependency_date_options: + deno_resolver::factory::npm_newest_dependency_date_options(config), + overrides: Default::default(), + trust_policy: Default::default(), + }), + ); + (jsr_search_api, npm_search_api) +} + impl Inner { fn new(client: Client, performance: Arc) -> Self { let cache = LspCache::default(); @@ -630,18 +672,11 @@ impl Inner { cache.deno_dir().registries_folder_path(), http_client_provider.clone(), ); - let jsr_search_api = - CliJsrSearchApi::new(module_registry.file_fetcher.clone()); - let npm_search_api = CliNpmSearchApi::new( + let config = Config::default(); + let (jsr_search_api, npm_search_api) = package_search_apis( module_registry.file_fetcher.clone(), - Arc::new(NpmVersionResolver { - link_packages: Default::default(), - newest_dependency_date_options: Default::default(), - overrides: Default::default(), - trust_policy: Default::default(), - }), + &minimum_dependency_age_config(&config), ); - let config = Config::default(); let ts_server = Arc::new(TsServer::new(performance.clone())); let initial_cwd = resolve_cwd(None).unwrap().into_owned(); @@ -855,21 +890,20 @@ impl Inner { self.module_registry.disable(registry); } } - self.jsr_search_api = - CliJsrSearchApi::new(self.module_registry.file_fetcher.clone()); - self.npm_search_api = CliNpmSearchApi::new( + // todo(dsherret): the npm_search_api should probably be specific + // to each workspace so that the link packages can be properly + // hooked up. minimumDependencyAge follows the root workspace. + self.rebuild_package_search_apis(); + self.performance.measure(mark); + } + + fn rebuild_package_search_apis(&mut self) { + let (jsr_search_api, npm_search_api) = package_search_apis( self.module_registry.file_fetcher.clone(), - Arc::new(NpmVersionResolver { - // todo(dsherret): the npm_search_api should probably be specific - // to each workspace so that the link packages can be properly - // hooked up - link_packages: Default::default(), - newest_dependency_date_options: Default::default(), - overrides: Default::default(), - trust_policy: Default::default(), - }), + &minimum_dependency_age_config(&self.config), ); - self.performance.measure(mark); + self.jsr_search_api = jsr_search_api; + self.npm_search_api = npm_search_api; } #[cfg_attr(feature = "lsp-tracing", tracing::instrument(skip_all))] @@ -1249,6 +1283,10 @@ impl Inner { reason, ) .await; + // Import-specifier completions read versions through the search APIs, + // which are process-global. Rebuild them so a deno.json change (including + // `minimumDependencyAge` and its `exclude` list) takes effect. + self.rebuild_package_search_apis(); self .client .send_did_refresh_deno_configuration_tree_notification( diff --git a/cli/lsp/resolver.rs b/cli/lsp/resolver.rs index 2780b1b36fd0a5..f5ebdbdd3b8064 100644 --- a/cli/lsp/resolver.rs +++ b/cli/lsp/resolver.rs @@ -63,6 +63,7 @@ use node_resolver::ResolutionMode; use node_resolver::cache::NodeResolutionSys; use node_resolver::cache::NodeResolutionThreadLocalCache; use once_cell::sync::Lazy; +use sys_traits::SystemTimeNow; use super::cache::LspCache; use super::documents::DocumentModule; @@ -1068,11 +1069,24 @@ impl<'a> ResolverFactory<'a> { http_client_provider.clone(), pb.clone(), )); + let minimum_dependency_age = match self.config_data { + Some(data) => data.minimum_dependency_age_config(), + None => deno_resolver::factory::resolve_minimum_dependency_age_config( + Default::default(), + npmrc.min_release_age_days, + chrono::DateTime::::from(sys.sys_time_now()), + ), + }; let registry_info_provider = Arc::new(CliNpmRegistryInfoProvider::new( npm_cache.clone(), npm_client.clone(), npmrc.clone(), - NpmPackumentFormat::Abbreviated, + NpmPackumentFormat::new( + &npmrc, + minimum_dependency_age + .age + .and_then(|date| date.into_option()), + ), )); let link_packages: WorkspaceNpmLinkPackagesRc = self .config_data @@ -1115,7 +1129,10 @@ impl<'a> ResolverFactory<'a> { .unwrap_or_default(); let npm_version_resolver = Arc::new(NpmVersionResolver { link_packages: link_packages.0.clone(), - newest_dependency_date_options: Default::default(), + newest_dependency_date_options: + deno_resolver::factory::npm_newest_dependency_date_options( + &minimum_dependency_age, + ), overrides: Arc::new(overrides), trust_policy: Default::default(), }); diff --git a/libs/resolver/factory.rs b/libs/resolver/factory.rs index 56e91b0a101514..f39dfba44fb7fe 100644 --- a/libs/resolver/factory.rs +++ b/libs/resolver/factory.rs @@ -985,23 +985,10 @@ impl ResolverFactory { let minimum_dependency_age_config = self.minimum_dependency_age_config()?; - let (exclude_jsr_pkgs, exclude_jsr_pkg_prefixes) = - split_minimum_dependency_age_excludes( - &minimum_dependency_age_config.exclude, - "jsr:", - ); - Ok(new_rc(deno_graph::packages::JsrVersionResolver { - newest_dependency_date_options: - deno_graph::packages::NewestDependencyDateOptions { - date: minimum_dependency_age_config - .age - .as_ref() - .and_then(|d| d.into_option()) - .map(deno_graph::packages::NewestDependencyDate), - exclude_jsr_pkgs, - exclude_jsr_pkg_prefixes, - }, + newest_dependency_date_options: jsr_newest_dependency_date_options( + minimum_dependency_age_config, + ), })) }) } @@ -1012,7 +999,7 @@ impl ResolverFactory { ) -> Result<&MinimumDependencyAgeConfig, anyhow::Error> { self.minimum_dependency_age.get_or_try_init(|| { let workspace_factory = self.workspace_factory(); - let mut config = if let Some(date) = self.options.newest_dependency_date { + let config = if let Some(date) = self.options.newest_dependency_date { MinimumDependencyAgeConfig { age: Some(date), exclude: Vec::new(), @@ -1021,15 +1008,20 @@ impl ResolverFactory { let workspace = &workspace_factory.workspace_directory()?.workspace; workspace.minimum_dependency_age(workspace_factory.sys())? }; - if config.age.is_none() { - apply_minimum_dependency_age_fallbacks( - &mut config, - workspace_factory.npmrc()?.min_release_age_days, - chrono::DateTime::::from( - workspace_factory.sys().sys_time_now(), - ), - ); - } + // Only read `.npmrc` when the age is unset. A configured age does not + // depend on it, and a missing npmrc must not fail that path. + let npmrc_min_release_age_days = if config.age.is_none() { + workspace_factory.npmrc()?.min_release_age_days + } else { + None + }; + let config = resolve_minimum_dependency_age_config( + config, + npmrc_min_release_age_days, + chrono::DateTime::::from( + workspace_factory.sys().sys_time_now(), + ), + ); if let Some(newest_dependency_date) = config.age.and_then(|d| d.into_option()) { @@ -1195,22 +1187,10 @@ impl ResolverFactory { ), }; - let (exclude, exclude_prefixes) = split_minimum_dependency_age_excludes( - &minimum_dependency_age_config.exclude, - "npm:", - ); - Ok(new_rc(NpmVersionResolver { - newest_dependency_date_options: - deno_npm::resolution::NewestDependencyDateOptions { - date: minimum_dependency_age_config - .age - .as_ref() - .and_then(|d| d.into_option()) - .map(deno_npm::resolution::NewestDependencyDate), - exclude, - exclude_prefixes, - }, + newest_dependency_date_options: npm_newest_dependency_date_options( + minimum_dependency_age_config, + ), link_packages: self .workspace_factory .workspace_npm_link_packages()? @@ -1345,6 +1325,58 @@ impl ResolverFactory { } } +/// Resolves `minimumDependencyAge`, applying the `.npmrc` `min-release-age` +/// and built-in default when the config does not set an age. +pub fn resolve_minimum_dependency_age_config( + mut config: MinimumDependencyAgeConfig, + npmrc_min_release_age_days: Option, + now: chrono::DateTime, +) -> MinimumDependencyAgeConfig { + if config.age.is_none() { + apply_minimum_dependency_age_fallbacks( + &mut config, + npmrc_min_release_age_days, + now, + ); + } + config +} + +/// npm `NewestDependencyDateOptions` for a resolved minimum dependency age, +/// including `exclude` entries (`npm:` exact names and `npm:` wildcard prefixes). +pub fn npm_newest_dependency_date_options( + config: &MinimumDependencyAgeConfig, +) -> deno_npm::resolution::NewestDependencyDateOptions { + let (exclude, exclude_prefixes) = + split_minimum_dependency_age_excludes(&config.exclude, "npm:"); + deno_npm::resolution::NewestDependencyDateOptions { + date: config + .age + .and_then(|date| date.into_option()) + .map(deno_npm::resolution::NewestDependencyDate), + exclude, + exclude_prefixes, + } +} + +/// JSR `NewestDependencyDateOptions` for a resolved minimum dependency age, +/// including `exclude` entries (`jsr:` exact names and `jsr:` wildcard prefixes). +#[cfg(feature = "graph")] +pub fn jsr_newest_dependency_date_options( + config: &MinimumDependencyAgeConfig, +) -> deno_graph::packages::NewestDependencyDateOptions { + let (exclude_jsr_pkgs, exclude_jsr_pkg_prefixes) = + split_minimum_dependency_age_excludes(&config.exclude, "jsr:"); + deno_graph::packages::NewestDependencyDateOptions { + date: config + .age + .and_then(|date| date.into_option()) + .map(deno_graph::packages::NewestDependencyDate), + exclude_jsr_pkgs, + exclude_jsr_pkg_prefixes, + } +} + /// Splits `minimumDependencyAge.exclude` entries with the given scheme /// (`npm:` or `jsr:`) into exact package names and package name prefixes /// (from trailing `*` wildcard entries like `npm:@scope/*`). diff --git a/tests/integration/lsp_tests.rs b/tests/integration/lsp_tests.rs index 3f91ecbbfafc16..fdaf9289c1db62 100644 --- a/tests/integration/lsp_tests.rs +++ b/tests/integration/lsp_tests.rs @@ -11641,6 +11641,141 @@ fn lsp_completions_npm() { client.shutdown(); } +/// Versions newer than `minimumDependencyAge` are omitted from jsr/npm import +/// completions, `exclude` opts a package out, and editing deno.json refreshes +/// the list. https://github.com/denoland/deno/issues/31009 +#[test(timeout = 300)] +fn lsp_completions_minimum_dependency_age() { + let context = TestContextBuilder::new() + .use_http_server() + .use_temp_cwd() + .build(); + let temp_dir = context.temp_dir(); + // @denotest/add: 0.2.0 is 2025-01-20, 0.2.1 is 2025-05-20, 1.0.0 is 2025-06-01. + // @denotest/min-release-age-latest: 1.0.0 is year 2000, 2.0.0 is year 9999. + temp_dir.write( + "deno.json", + r#"{ "minimumDependencyAge": "2025-03-01T00:00:00.000Z" }"#, + ); + let deno_json_uri = + url_to_uri(&temp_dir.url().join("deno.json").unwrap()).unwrap(); + let mut client = context.new_lsp_command().build(); + client.initialize_default(); + let uri = "file:///a/file.ts"; + client.did_open(json!({ + "textDocument": { + "uri": uri, + "languageId": "typescript", + "version": 1, + "text": "import \"jsr:@denotest/add@\";\nimport \"npm:@denotest/min-release-age-latest@\";\n", + } + })); + + let jsr_labels = + version_completion_labels(&mut client, uri, (0, 26), "jsr:@denotest/add@"); + assert_eq!( + jsr_labels, + vec!["jsr:@denotest/add@0.2.0".to_string()], + "jsr completions should only include versions older than the cutoff", + ); + let npm_labels = version_completion_labels( + &mut client, + uri, + (1, 45), + "npm:@denotest/min-release-age-latest@", + ); + assert_eq!( + npm_labels, + vec!["npm:@denotest/min-release-age-latest@1.0.0".to_string()], + "npm completions should only include versions older than the cutoff", + ); + + temp_dir.write( + "deno.json", + r#"{ + "minimumDependencyAge": { + "age": "2025-03-01T00:00:00.000Z", + "exclude": ["jsr:@denotest/add", "npm:@denotest/min-release-age-latest"] + } + }"#, + ); + reload_deno_json(&mut client, &deno_json_uri); + let jsr_labels = + version_completion_labels(&mut client, uri, (0, 26), "jsr:@denotest/add@"); + assert!( + jsr_labels + .iter() + .any(|label| label == "jsr:@denotest/add@1.0.0"), + "excluded jsr package should still offer versions newer than the cutoff, got {jsr_labels:?}" + ); + let npm_labels = version_completion_labels( + &mut client, + uri, + (1, 45), + "npm:@denotest/min-release-age-latest@", + ); + assert!( + npm_labels + .iter() + .any(|label| label == "npm:@denotest/min-release-age-latest@2.0.0"), + "excluded npm package should still offer versions newer than the cutoff, got {npm_labels:?}" + ); + + temp_dir.write("deno.json", r#"{ "minimumDependencyAge": false }"#); + reload_deno_json(&mut client, &deno_json_uri); + let jsr_labels = + version_completion_labels(&mut client, uri, (0, 26), "jsr:@denotest/add@"); + assert!( + jsr_labels + .iter() + .any(|label| label == "jsr:@denotest/add@1.0.0"), + "disabling minimumDependencyAge should offer the newer jsr version, got {jsr_labels:?}" + ); + let npm_labels = version_completion_labels( + &mut client, + uri, + (1, 45), + "npm:@denotest/min-release-age-latest@", + ); + assert!( + npm_labels + .iter() + .any(|label| label == "npm:@denotest/min-release-age-latest@2.0.0"), + "disabling minimumDependencyAge should offer the newer npm version, got {npm_labels:?}" + ); + + client.shutdown(); +} + +fn version_completion_labels( + client: &mut LspClient, + uri: &str, + position: (usize, usize), + prefix: &str, +) -> Vec { + let list = client.get_completion_list( + uri, + position, + json!({ "triggerKind": 2, "triggerCharacter": "@" }), + ); + list + .items + .into_iter() + .map(|item| item.label) + .filter(|label| label.starts_with(prefix)) + .collect() +} + +fn reload_deno_json(client: &mut LspClient, deno_json_uri: &impl Serialize) { + client.did_change_watched_files(json!({ + "changes": [{ + "uri": deno_json_uri, + "type": 2, + }], + })); + client.handle_refresh_diagnostics_request(); +} + // Regression test for https://github.com/denoland/deno/issues/24927. // Auto-completion for the configuration file `imports` field should suggest // jsr:/npm:/node: specifiers just like in script imports do.