Skip to content

Commit 8b66b5b

Browse files
feat(boundaries): auto ignore (#10147)
### Description Allow users to automatically add `@boundaries-ignore` comments to their errors, either all at once or interactively using the `--ignore` flag. ![Recording 2025-03-11 at 19 01 37](https://github.com/user-attachments/assets/30d0a85f-e021-4ce8-852d-4eece735f779) ### Testing Instructions Added a test for the all at once case, but you should test the interactive case manually. --------- Co-authored-by: Chris Olszewski <[email protected]>
1 parent d0df9ec commit 8b66b5b

File tree

11 files changed

+263
-19
lines changed

11 files changed

+263
-19
lines changed

Cargo.lock

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turborepo-lib/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ human-panic = "1.2.1"
6969
human_format = "1.1.0"
7070
humantime = "2.1.0"
7171
ignore = "0.4.22"
72+
indicatif = { workspace = true }
7273
itertools = { workspace = true }
7374
jsonc-parser = { version = "0.21.0" }
7475
lazy_static = { workspace = true }

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

+4
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ impl Run {
251251
.to_string();
252252

253253
Ok(Some(BoundariesDiagnostic::ImportLeavesPackage {
254+
path: file_path.to_owned(),
254255
import: import.to_string(),
255256
resolved_import_path,
256257
package_name: package_name.to_owned(),
@@ -289,6 +290,7 @@ impl Run {
289290

290291
if package_name.starts_with("@types/") && matches!(import_type, ImportType::Value) {
291292
return Some(BoundariesDiagnostic::NotTypeOnlyImport {
293+
path: file_path.to_owned(),
292294
import: import.to_string(),
293295
span,
294296
text: NamedSource::new(file_path.as_str(), file_content.to_string()),
@@ -315,6 +317,7 @@ impl Run {
315317
return match import_type {
316318
ImportType::Type => None,
317319
ImportType::Value => Some(BoundariesDiagnostic::NotTypeOnlyImport {
320+
path: file_path.to_owned(),
318321
import: import.to_string(),
319322
span,
320323
text: NamedSource::new(file_path.as_str(), file_content.to_string()),
@@ -323,6 +326,7 @@ impl Run {
323326
}
324327

325328
return Some(BoundariesDiagnostic::PackageNotFound {
329+
path: file_path.to_owned(),
326330
name: package_name.to_string(),
327331
span,
328332
text: NamedSource::new(file_path.as_str(), file_content.to_string()),

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

+84-4
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ mod tags;
44
mod tsconfig;
55

66
use std::{
7-
collections::{HashMap, HashSet},
7+
collections::{BTreeMap, HashMap, HashSet},
8+
fs::OpenOptions,
9+
io::Write,
810
sync::{Arc, LazyLock, Mutex},
911
};
1012

1113
pub use config::{BoundariesConfig, Permissions, Rule};
1214
use git2::Repository;
1315
use globwalk::Settings;
16+
use indicatif::{ProgressBar, ProgressIterator};
1417
use miette::{Diagnostic, NamedSource, Report, SourceSpan};
1518
use regex::Regex;
1619
use swc_common::{
@@ -25,7 +28,7 @@ use swc_ecma_visit::VisitWith;
2528
use thiserror::Error;
2629
use tracing::log::warn;
2730
use turbo_trace::{ImportFinder, Tracer};
28-
use turbopath::AbsoluteSystemPathBuf;
31+
use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf};
2932
use turborepo_errors::Spanned;
3033
use turborepo_repository::package_graph::{PackageInfo, PackageName, PackageNode};
3134
use turborepo_ui::{color, ColorConfig, BOLD_GREEN, BOLD_RED};
@@ -97,6 +100,7 @@ pub enum BoundariesDiagnostic {
97100
)]
98101
#[help("add `type` to the import declaration")]
99102
NotTypeOnlyImport {
103+
path: AbsoluteSystemPathBuf,
100104
import: String,
101105
#[label("package imported here")]
102106
span: SourceSpan,
@@ -105,6 +109,7 @@ pub enum BoundariesDiagnostic {
105109
},
106110
#[error("cannot import package `{name}` because it is not a dependency")]
107111
PackageNotFound {
112+
path: AbsoluteSystemPathBuf,
108113
name: String,
109114
#[label("package imported here")]
110115
span: SourceSpan,
@@ -116,6 +121,7 @@ pub enum BoundariesDiagnostic {
116121
"`{import}` resolves to path `{resolved_import_path}` which is outside of `{package_name}`"
117122
))]
118123
ImportLeavesPackage {
124+
path: AbsoluteSystemPathBuf,
119125
import: String,
120126
resolved_import_path: String,
121127
package_name: PackageName,
@@ -142,6 +148,19 @@ pub enum Error {
142148
GlobWalk(#[from] globwalk::WalkError),
143149
#[error("failed to read file: {0}")]
144150
FileNotFound(AbsoluteSystemPathBuf),
151+
#[error("failed to write to file: {0}")]
152+
FileWrite(AbsoluteSystemPathBuf),
153+
}
154+
155+
impl BoundariesDiagnostic {
156+
pub fn path_and_span(&self) -> Option<(&AbsoluteSystemPath, SourceSpan)> {
157+
match self {
158+
Self::ImportLeavesPackage { path, span, .. } => Some((path, *span)),
159+
Self::PackageNotFound { path, span, .. } => Some((path, *span)),
160+
Self::NotTypeOnlyImport { path, span, .. } => Some((path, *span)),
161+
_ => None,
162+
}
163+
}
145164
}
146165

147166
static PACKAGE_NAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
@@ -211,13 +230,21 @@ impl BoundariesResult {
211230
}
212231

213232
impl Run {
214-
pub async fn check_boundaries(&self) -> Result<BoundariesResult, Error> {
233+
pub async fn check_boundaries(&self, show_progress: bool) -> Result<BoundariesResult, Error> {
215234
let rules_map = self.get_processed_rules_map();
216235
let packages: Vec<_> = self.pkg_dep_graph().packages().collect();
217236
let repo = Repository::discover(self.repo_root()).ok().map(Mutex::new);
218237
let mut result = BoundariesResult::default();
219238
let global_implicit_dependencies = self.get_implicit_dependencies(&PackageName::Root);
220-
for (package_name, package_info) in packages {
239+
240+
let progress = if show_progress {
241+
println!("Checking packages...");
242+
ProgressBar::new(packages.len() as u64)
243+
} else {
244+
ProgressBar::hidden()
245+
};
246+
247+
for (package_name, package_info) in packages.into_iter().progress_with(progress) {
221248
if !self.filtered_pkgs().contains(package_name)
222249
|| matches!(package_name, PackageName::Root)
223250
{
@@ -456,4 +483,57 @@ impl Run {
456483

457484
Ok(())
458485
}
486+
487+
pub fn patch_file(
488+
&self,
489+
file_path: &AbsoluteSystemPath,
490+
file_patches: Vec<(SourceSpan, String)>,
491+
) -> Result<(), Error> {
492+
// Deduplicate and sort by offset
493+
let file_patches = file_patches
494+
.into_iter()
495+
.map(|(span, patch)| (span.offset(), patch))
496+
.collect::<BTreeMap<usize, String>>();
497+
498+
let contents = file_path
499+
.read_to_string()
500+
.map_err(|_| Error::FileNotFound(file_path.to_owned()))?;
501+
502+
let mut options = OpenOptions::new();
503+
options.read(true).write(true).truncate(true);
504+
let mut file = file_path
505+
.open_with_options(options)
506+
.map_err(|_| Error::FileNotFound(file_path.to_owned()))?;
507+
508+
let mut last_idx = 0;
509+
for (idx, reason) in file_patches {
510+
let contents_before_span = &contents[last_idx..idx];
511+
512+
// Find the last newline before the span (note this is the index into the slice,
513+
// not the full file)
514+
let newline_idx = contents_before_span.rfind('\n');
515+
516+
// If newline exists, we write all the contents before newline
517+
if let Some(newline_idx) = newline_idx {
518+
file.write_all(contents[last_idx..(last_idx + newline_idx)].as_bytes())
519+
.map_err(|_| Error::FileWrite(file_path.to_owned()))?;
520+
file.write_all(b"\n")
521+
.map_err(|_| Error::FileWrite(file_path.to_owned()))?;
522+
}
523+
524+
file.write_all(b"// @boundaries-ignore ")
525+
.map_err(|_| Error::FileWrite(file_path.to_owned()))?;
526+
file.write_all(reason.as_bytes())
527+
.map_err(|_| Error::FileWrite(file_path.to_owned()))?;
528+
file.write_all(b"\n")
529+
.map_err(|_| Error::FileWrite(file_path.to_owned()))?;
530+
531+
last_idx = idx;
532+
}
533+
534+
file.write_all(contents[last_idx..].as_bytes())
535+
.map_err(|_| Error::FileWrite(file_path.to_owned()))?;
536+
537+
Ok(())
538+
}
459539
}

crates/turborepo-lib/src/cli/error.rs

+2
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ pub enum Error {
7474
Opts(#[from] crate::opts::Error),
7575
#[error(transparent)]
7676
SignalListener(#[from] turborepo_signals::listeners::Error),
77+
#[error(transparent)]
78+
Dialoguer(#[from] dialoguer::Error),
7779
}
7880

7981
const MAX_CHARS_PER_TASK_LINE: usize = 100;

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

+33-2
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,10 @@ pub enum Command {
587587
Boundaries {
588588
#[clap(short = 'F', long, group = "scope-filter-group")]
589589
filter: Vec<String>,
590+
#[clap(long, value_enum, default_missing_value = "prompt", num_args = 0..=1, require_equals = true)]
591+
ignore: Option<BoundariesIgnore>,
592+
#[clap(long, requires = "ignore")]
593+
reason: Option<String>,
590594
},
591595
#[clap(hide = true)]
592596
Clone {
@@ -761,6 +765,15 @@ pub enum Command {
761765
},
762766
}
763767

768+
#[derive(Copy, Clone, Debug, Default, ValueEnum, Serialize, Eq, PartialEq)]
769+
pub enum BoundariesIgnore {
770+
/// Adds a `@boundaries-ignore` comment everywhere possible
771+
All,
772+
/// Prompts user if they want to add `@boundaries-ignore` comment
773+
#[default]
774+
Prompt,
775+
}
776+
764777
#[derive(Parser, Clone, Debug, Default, Serialize, PartialEq)]
765778
pub struct GenerateWorkspaceArgs {
766779
/// Name for the new workspace
@@ -1391,13 +1404,15 @@ pub async fn run(
13911404

13921405
Ok(0)
13931406
}
1394-
Command::Boundaries { .. } => {
1407+
Command::Boundaries { ignore, reason, .. } => {
13951408
let event = CommandEventBuilder::new("boundaries").with_parent(&root_telemetry);
1409+
let ignore = *ignore;
1410+
let reason = reason.clone();
13961411

13971412
event.track_call();
13981413
let base = CommandBase::new(cli_args.clone(), repo_root, version, color_config)?;
13991414

1400-
Ok(boundaries::run(base, event).await?)
1415+
Ok(boundaries::run(base, event, ignore, reason).await?)
14011416
}
14021417
Command::Clone {
14031418
url,
@@ -3329,4 +3344,20 @@ mod test {
33293344
assert_snapshot!(args.join("-").as_str(), err);
33303345
}
33313346
}
3347+
3348+
#[test_case::test_case(&["turbo", "boundaries"], true; "empty")]
3349+
#[test_case::test_case(&["turbo", "boundaries", "--ignore"], true; "with ignore")]
3350+
#[test_case::test_case(&["turbo", "boundaries", "--ignore=all"], true; "with ignore all")]
3351+
#[test_case::test_case(&["turbo", "boundaries", "--ignore=prompt"], true; "with ignore prompt")]
3352+
#[test_case::test_case(&["turbo", "boundaries", "--filter", "ui"], true; "with filter")]
3353+
fn test_boundaries(args: &[&str], is_okay: bool) {
3354+
let os_args = args.iter().map(|s| OsString::from(*s)).collect();
3355+
let cli = Args::parse(os_args);
3356+
if is_okay {
3357+
cli.unwrap();
3358+
} else {
3359+
let err = cli.unwrap_err();
3360+
assert_snapshot!(args.join("-").as_str(), err);
3361+
}
3362+
}
33323363
}

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

+74-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
use std::collections::HashMap;
2+
3+
use dialoguer::{Confirm, Input};
4+
use miette::{Report, SourceSpan};
5+
use turbopath::AbsoluteSystemPath;
16
use turborepo_signals::{listeners::get_signal, SignalHandler};
27
use turborepo_telemetry::events::command::CommandEventBuilder;
8+
use turborepo_ui::{color, BOLD_GREEN};
39

4-
use crate::{cli, commands::CommandBase, run::builder::RunBuilder};
10+
use crate::{cli, cli::BoundariesIgnore, commands::CommandBase, run::builder::RunBuilder};
511

6-
pub async fn run(base: CommandBase, telemetry: CommandEventBuilder) -> Result<i32, cli::Error> {
12+
pub async fn run(
13+
base: CommandBase,
14+
telemetry: CommandEventBuilder,
15+
ignore: Option<BoundariesIgnore>,
16+
reason: Option<String>,
17+
) -> Result<i32, cli::Error> {
718
let signal = get_signal()?;
819
let handler = SignalHandler::new(signal);
920

@@ -12,9 +23,68 @@ pub async fn run(base: CommandBase, telemetry: CommandEventBuilder) -> Result<i3
1223
.build(&handler, telemetry)
1324
.await?;
1425

15-
let result = run.check_boundaries().await?;
26+
let result = run.check_boundaries(true).await?;
27+
28+
if let Some(ignore) = ignore {
29+
let mut patches: HashMap<&AbsoluteSystemPath, Vec<(SourceSpan, String)>> = HashMap::new();
30+
for diagnostic in &result.diagnostics {
31+
let Some((path, span)) = diagnostic.path_and_span() else {
32+
continue;
33+
};
34+
35+
let reason = match ignore {
36+
BoundariesIgnore::All => Some(reason.clone().unwrap_or_else(|| {
37+
"automatically added by `turbo boundaries --ignore=all`".to_string()
38+
})),
39+
BoundariesIgnore::Prompt => {
40+
print!("{esc}c", esc = 27 as char);
41+
println!();
42+
println!();
43+
println!("{:?}", Report::new(diagnostic.clone()));
44+
let prompt = format!(
45+
"Ignore this error by adding a {} comment?",
46+
color!(run.color_config(), BOLD_GREEN, "@boundaries-ignore"),
47+
);
48+
if Confirm::new()
49+
.with_prompt(prompt)
50+
.default(false)
51+
.interact()?
52+
{
53+
if let Some(reason) = reason.clone() {
54+
Some(reason)
55+
} else {
56+
Some(
57+
Input::new()
58+
.with_prompt("Reason for ignoring this error")
59+
.interact_text()?,
60+
)
61+
}
62+
} else {
63+
None
64+
}
65+
}
66+
};
1667

17-
result.emit(run.color_config());
68+
if let Some(reason) = reason {
69+
patches.entry(path).or_default().push((span, reason));
70+
}
71+
}
72+
73+
for (path, file_patches) in patches {
74+
let short_path = match run.repo_root().anchor(path) {
75+
Ok(path) => path.to_string(),
76+
Err(_) => path.to_string(),
77+
};
78+
println!(
79+
"{} {}",
80+
color!(run.color_config(), BOLD_GREEN, "patching"),
81+
short_path
82+
);
83+
run.patch_file(path, file_patches)?;
84+
}
85+
} else {
86+
result.emit(run.color_config());
87+
}
1888

1989
if result.is_ok() {
2090
Ok(0)

crates/turborepo-lib/src/opts.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ impl Opts {
153153

154154
(&Box::new(execution_args), &Box::default())
155155
}
156-
Some(Command::Boundaries { filter }) => {
156+
Some(Command::Boundaries { filter, .. }) => {
157157
let execution_args = ExecutionArgs {
158158
filter: filter.clone(),
159159
..Default::default()

0 commit comments

Comments
 (0)