Skip to content

Commit c59da31

Browse files
feat(boundaries): implicit dependencies (#10117)
### Description Allow users to allowlist implicit dependencies for boundaries. This is useful in cases where testing libraries or frameworks can inject globals such as `server-only` Can be reviewed commit by commit ### Testing Instructions Added test to boundaries. Note the snapshots do *not* change since no errors are produced. To verify stuff is working as intended, try removing the `implicitDependencies` key and note that an error is produced.
1 parent 417223f commit c59da31

17 files changed

+224
-101
lines changed

crates/turborepo-lib/src/boundaries/config.rs

+5-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ use struct_iterable::Iterable;
66
use turborepo_errors::Spanned;
77

88
#[derive(Serialize, Default, Debug, Clone, Iterable, Deserializable, PartialEq)]
9-
pub struct RootBoundariesConfig {
9+
pub struct BoundariesConfig {
10+
#[serde(skip_serializing_if = "Option::is_none")]
1011
pub tags: Option<Spanned<RulesMap>>,
12+
#[serde(skip_serializing_if = "Option::is_none")]
13+
pub implicit_dependencies: Option<Spanned<Vec<Spanned<String>>>>,
1114
}
15+
1216
pub type RulesMap = HashMap<String, Spanned<Rule>>;
1317

1418
#[derive(Serialize, Default, Debug, Clone, Iterable, Deserializable, PartialEq)]

crates/turborepo-lib/src/boundaries/imports.rs

+66-62
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::{
2-
collections::{BTreeMap, HashSet},
2+
collections::{BTreeMap, HashMap, HashSet},
33
sync::Arc,
44
};
55

@@ -10,8 +10,9 @@ use oxc_resolver::{ResolveError, Resolver, TsConfig};
1010
use swc_common::{comments::SingleThreadedComments, SourceFile, Span};
1111
use turbo_trace::ImportType;
1212
use turbopath::{AbsoluteSystemPath, AnchoredSystemPathBuf, PathRelation, RelativeUnixPath};
13+
use turborepo_errors::Spanned;
1314
use turborepo_repository::{
14-
package_graph::{PackageInfo, PackageName, PackageNode},
15+
package_graph::{PackageName, PackageNode},
1516
package_json::PackageJson,
1617
};
1718

@@ -20,6 +21,64 @@ use crate::{
2021
run::Run,
2122
};
2223

24+
/// All the places a dependency can be declared
25+
#[derive(Clone, Copy)]
26+
pub struct DependencyLocations<'a> {
27+
pub(crate) internal_dependencies: &'a HashSet<&'a PackageNode>,
28+
pub(crate) package_json: &'a PackageJson,
29+
pub(crate) unresolved_external_dependencies: Option<&'a BTreeMap<String, String>>,
30+
pub(crate) implicit_dependencies: &'a HashMap<String, Spanned<()>>,
31+
pub(crate) global_implicit_dependencies: &'a HashMap<String, Spanned<()>>,
32+
}
33+
34+
impl<'a> DependencyLocations<'a> {
35+
/// Go through all the possible places a package could be declared to see if
36+
/// it's a valid import. We don't use `oxc_resolver` because there are some
37+
/// cases where you can resolve a package that isn't declared properly.
38+
fn is_dependency(&self, package_name: &PackageNode) -> bool {
39+
self.internal_dependencies.contains(package_name)
40+
|| self
41+
.unresolved_external_dependencies
42+
.is_some_and(|external_dependencies| {
43+
external_dependencies.contains_key(package_name.as_package_name().as_str())
44+
})
45+
|| self
46+
.package_json
47+
.dependencies
48+
.as_ref()
49+
.is_some_and(|dependencies| {
50+
dependencies.contains_key(package_name.as_package_name().as_str())
51+
})
52+
|| self
53+
.package_json
54+
.dev_dependencies
55+
.as_ref()
56+
.is_some_and(|dev_dependencies| {
57+
dev_dependencies.contains_key(package_name.as_package_name().as_str())
58+
})
59+
|| self
60+
.package_json
61+
.peer_dependencies
62+
.as_ref()
63+
.is_some_and(|peer_dependencies| {
64+
peer_dependencies.contains_key(package_name.as_package_name().as_str())
65+
})
66+
|| self
67+
.package_json
68+
.optional_dependencies
69+
.as_ref()
70+
.is_some_and(|optional_dependencies| {
71+
optional_dependencies.contains_key(package_name.as_package_name().as_str())
72+
})
73+
|| self
74+
.implicit_dependencies
75+
.contains_key(package_name.as_package_name().as_str())
76+
|| self
77+
.global_implicit_dependencies
78+
.contains_key(package_name.as_package_name().as_str())
79+
}
80+
}
81+
2382
impl Run {
2483
/// Checks if the given import can be resolved as a tsconfig path alias,
2584
/// e.g. `@/types/foo` -> `./src/foo`, and if so, checks the resolved paths.
@@ -79,9 +138,7 @@ impl Run {
79138
span: &Span,
80139
file_path: &AbsoluteSystemPath,
81140
file_content: &str,
82-
package_info: &PackageInfo,
83-
internal_dependencies: &HashSet<&PackageNode>,
84-
unresolved_external_dependencies: Option<&BTreeMap<String, String>>,
141+
dependency_locations: DependencyLocations<'_>,
85142
resolver: &Resolver,
86143
) -> Result<(), Error> {
87144
// If the import is prefixed with `@boundaries-ignore`, we ignore it, but print
@@ -154,9 +211,7 @@ impl Run {
154211
span,
155212
file_path,
156213
file_content,
157-
&package_info.package_json,
158-
internal_dependencies,
159-
unresolved_external_dependencies,
214+
dependency_locations,
160215
resolver,
161216
)
162217
} else {
@@ -207,45 +262,6 @@ impl Run {
207262
}
208263
}
209264

210-
/// Go through all the possible places a package could be declared to see if
211-
/// it's a valid import. We don't use `oxc_resolver` because there are some
212-
/// cases where you can resolve a package that isn't declared properly.
213-
fn is_dependency(
214-
internal_dependencies: &HashSet<&PackageNode>,
215-
package_json: &PackageJson,
216-
unresolved_external_dependencies: Option<&BTreeMap<String, String>>,
217-
package_name: &PackageNode,
218-
) -> bool {
219-
internal_dependencies.contains(&package_name)
220-
|| unresolved_external_dependencies.is_some_and(|external_dependencies| {
221-
external_dependencies.contains_key(package_name.as_package_name().as_str())
222-
})
223-
|| package_json
224-
.dependencies
225-
.as_ref()
226-
.is_some_and(|dependencies| {
227-
dependencies.contains_key(package_name.as_package_name().as_str())
228-
})
229-
|| package_json
230-
.dev_dependencies
231-
.as_ref()
232-
.is_some_and(|dev_dependencies| {
233-
dev_dependencies.contains_key(package_name.as_package_name().as_str())
234-
})
235-
|| package_json
236-
.peer_dependencies
237-
.as_ref()
238-
.is_some_and(|peer_dependencies| {
239-
peer_dependencies.contains_key(package_name.as_package_name().as_str())
240-
})
241-
|| package_json
242-
.optional_dependencies
243-
.as_ref()
244-
.is_some_and(|optional_dependencies| {
245-
optional_dependencies.contains_key(package_name.as_package_name().as_str())
246-
})
247-
}
248-
249265
fn get_package_name(import: &str) -> String {
250266
if import.starts_with("@") {
251267
import.split('/').take(2).join("/")
@@ -266,9 +282,7 @@ impl Run {
266282
span: SourceSpan,
267283
file_path: &AbsoluteSystemPath,
268284
file_content: &str,
269-
package_json: &PackageJson,
270-
internal_dependencies: &HashSet<&PackageNode>,
271-
unresolved_external_dependencies: Option<&BTreeMap<String, String>>,
285+
dependency_locations: DependencyLocations<'_>,
272286
resolver: &Resolver,
273287
) -> Option<BoundariesDiagnostic> {
274288
let package_name = Self::get_package_name(import);
@@ -282,12 +296,7 @@ impl Run {
282296
}
283297
let package_name = PackageNode::Workspace(PackageName::Other(package_name));
284298
let folder = file_path.parent().expect("file_path should have a parent");
285-
let is_valid_dependency = Self::is_dependency(
286-
internal_dependencies,
287-
package_json,
288-
unresolved_external_dependencies,
289-
&package_name,
290-
);
299+
let is_valid_dependency = dependency_locations.is_dependency(&package_name);
291300

292301
if !is_valid_dependency
293302
&& !matches!(
@@ -300,12 +309,7 @@ impl Run {
300309
"@types/{}",
301310
package_name.as_package_name().as_str()
302311
)));
303-
let is_types_dependency = Self::is_dependency(
304-
internal_dependencies,
305-
package_json,
306-
unresolved_external_dependencies,
307-
&types_package_name,
308-
);
312+
let is_types_dependency = dependency_locations.is_dependency(&types_package_name);
309313

310314
if is_types_dependency {
311315
return match import_type {

crates/turborepo-lib/src/boundaries/mod.rs

+46-15
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::{
88
sync::{Arc, LazyLock, Mutex},
99
};
1010

11-
pub use config::{Permissions, RootBoundariesConfig, Rule};
11+
pub use config::{BoundariesConfig, Permissions, Rule};
1212
use git2::Repository;
1313
use globwalk::Settings;
1414
use miette::{Diagnostic, NamedSource, Report, SourceSpan};
@@ -31,8 +31,9 @@ use turborepo_repository::package_graph::{PackageInfo, PackageName, PackageNode}
3131
use turborepo_ui::{color, ColorConfig, BOLD_GREEN, BOLD_RED};
3232

3333
use crate::{
34-
boundaries::{tags::ProcessedRulesMap, tsconfig::TsConfigLoader},
34+
boundaries::{imports::DependencyLocations, tags::ProcessedRulesMap, tsconfig::TsConfigLoader},
3535
run::Run,
36+
turbo_json::TurboJson,
3637
};
3738

3839
#[derive(Clone, Debug, Error, Diagnostic)]
@@ -211,12 +212,11 @@ impl BoundariesResult {
211212

212213
impl Run {
213214
pub async fn check_boundaries(&self) -> Result<BoundariesResult, Error> {
214-
let package_tags = self.get_package_tags();
215215
let rules_map = self.get_processed_rules_map();
216216
let packages: Vec<_> = self.pkg_dep_graph().packages().collect();
217217
let repo = Repository::discover(self.repo_root()).ok().map(Mutex::new);
218218
let mut result = BoundariesResult::default();
219-
219+
let global_implicit_dependencies = self.get_implicit_dependencies(&PackageName::Root);
220220
for (package_name, package_info) in packages {
221221
if !self.filtered_pkgs().contains(package_name)
222222
|| matches!(package_name, PackageName::Root)
@@ -228,8 +228,8 @@ impl Run {
228228
&repo,
229229
package_name,
230230
package_info,
231-
&package_tags,
232231
&rules_map,
232+
&global_implicit_dependencies,
233233
&mut result,
234234
)
235235
.await?;
@@ -251,26 +251,49 @@ impl Run {
251251
None
252252
}
253253

254+
pub fn get_implicit_dependencies(&self, pkg: &PackageName) -> HashMap<String, Spanned<()>> {
255+
self.turbo_json_loader()
256+
.load(pkg)
257+
.ok()
258+
.and_then(|turbo_json| turbo_json.boundaries.as_ref())
259+
.and_then(|boundaries| boundaries.implicit_dependencies.as_ref())
260+
.into_iter()
261+
.flatten()
262+
.flatten()
263+
.map(|dep| dep.clone().split())
264+
.collect::<HashMap<_, _>>()
265+
}
266+
254267
/// Either returns a list of errors and number of files checked or a single,
255268
/// fatal error
256269
async fn check_package(
257270
&self,
258271
repo: &Option<Mutex<Repository>>,
259272
package_name: &PackageName,
260273
package_info: &PackageInfo,
261-
all_package_tags: &HashMap<PackageName, Spanned<Vec<Spanned<String>>>>,
262274
tag_rules: &Option<ProcessedRulesMap>,
275+
global_implicit_dependencies: &HashMap<String, Spanned<()>>,
263276
result: &mut BoundariesResult,
264277
) -> Result<(), Error> {
265-
self.check_package_files(repo, package_name, package_info, result)
266-
.await?;
267-
268-
if let Some(current_package_tags) = all_package_tags.get(package_name) {
278+
let implicit_dependencies = self.get_implicit_dependencies(package_name);
279+
self.check_package_files(
280+
repo,
281+
package_name,
282+
package_info,
283+
implicit_dependencies,
284+
global_implicit_dependencies,
285+
result,
286+
)
287+
.await?;
288+
289+
if let Ok(TurboJson {
290+
tags: Some(tags), ..
291+
}) = self.turbo_json_loader().load(package_name)
292+
{
269293
if let Some(tag_rules) = tag_rules {
270294
result.diagnostics.extend(self.check_package_tags(
271295
PackageNode::Workspace(package_name.clone()),
272-
current_package_tags,
273-
all_package_tags,
296+
tags,
274297
tag_rules,
275298
)?);
276299
} else {
@@ -297,6 +320,8 @@ impl Run {
297320
repo: &Option<Mutex<Repository>>,
298321
package_name: &PackageName,
299322
package_info: &PackageInfo,
323+
implicit_dependencies: HashMap<String, Spanned<()>>,
324+
global_implicit_dependencies: &HashMap<String, Spanned<()>>,
300325
result: &mut BoundariesResult,
301326
) -> Result<(), Error> {
302327
let package_root = self.repo_root().resolve(package_info.package_path());
@@ -393,6 +418,14 @@ impl Run {
393418
// Visit the AST and find imports
394419
let mut finder = ImportFinder::default();
395420
module.visit_with(&mut finder);
421+
let dependency_locations = DependencyLocations {
422+
internal_dependencies: &internal_dependencies,
423+
package_json: &package_info.package_json,
424+
implicit_dependencies: &implicit_dependencies,
425+
global_implicit_dependencies,
426+
unresolved_external_dependencies,
427+
};
428+
396429
for (import, span, import_type) in finder.imports() {
397430
self.check_import(
398431
&comments,
@@ -406,9 +439,7 @@ impl Run {
406439
span,
407440
file_path,
408441
&file_content,
409-
package_info,
410-
&internal_dependencies,
411-
unresolved_external_dependencies,
442+
dependency_locations,
412443
&resolver,
413444
)?;
414445
}

crates/turborepo-lib/src/boundaries/tags.rs

+15-6
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,25 @@ impl From<Permissions> for ProcessedPermissions {
4949
}
5050

5151
impl Run {
52+
pub fn load_package_tags(&self, pkg: &PackageName) -> Option<&Spanned<Vec<Spanned<String>>>> {
53+
self.turbo_json_loader()
54+
.load(pkg)
55+
.ok()
56+
.and_then(|turbo_json| turbo_json.tags.as_ref())
57+
}
58+
5259
pub(crate) fn get_package_tags(&self) -> HashMap<PackageName, Spanned<Vec<Spanned<String>>>> {
5360
let mut package_tags = HashMap::new();
54-
let turbo_json_loader = self.turbo_json_loader();
5561
for (package, _) in self.pkg_dep_graph().packages() {
5662
if let Ok(TurboJson {
5763
tags: Some(tags),
5864
boundaries,
5965
..
60-
}) = turbo_json_loader.load(package)
66+
}) = self.turbo_json_loader().load(package)
6167
{
62-
if boundaries.is_some() && !matches!(package, PackageName::Root) {
68+
if boundaries.as_ref().is_some_and(|b| b.tags.is_some())
69+
&& !matches!(package, PackageName::Root)
70+
{
6371
warn!(
6472
"Boundaries rules can only be defined in the root turbo.json. Any rules \
6573
defined in a package's turbo.json will be ignored."
@@ -163,7 +171,6 @@ impl Run {
163171
&self,
164172
pkg: PackageNode,
165173
current_package_tags: &Spanned<Vec<Spanned<String>>>,
166-
all_package_tags: &HashMap<PackageName, Spanned<Vec<Spanned<String>>>>,
167174
tags_rules: &ProcessedRulesMap,
168175
) -> Result<Vec<BoundariesDiagnostic>, Error> {
169176
let mut diagnostics = Vec::new();
@@ -174,7 +181,9 @@ impl Run {
174181
if matches!(dependency, PackageNode::Root) {
175182
continue;
176183
}
177-
let dependency_tags = all_package_tags.get(dependency.as_package_name());
184+
185+
let dependency_tags = self.load_package_tags(dependency.as_package_name());
186+
178187
diagnostics.extend(self.validate_relation(
179188
pkg.as_package_name(),
180189
dependency.as_package_name(),
@@ -190,7 +199,7 @@ impl Run {
190199
if matches!(dependent, PackageNode::Root) {
191200
continue;
192201
}
193-
let dependent_tags = all_package_tags.get(dependent.as_package_name());
202+
let dependent_tags = self.load_package_tags(dependent.as_package_name());
194203
diagnostics.extend(self.validate_relation(
195204
pkg.as_package_name(),
196205
dependent.as_package_name(),

0 commit comments

Comments
 (0)