diff --git a/.changeset/fixable-gritql-plugins.md b/.changeset/fixable-gritql-plugins.md new file mode 100644 index 000000000000..129d6c8a033e --- /dev/null +++ b/.changeset/fixable-gritql-plugins.md @@ -0,0 +1,17 @@ +--- +"@biomejs/biome": minor +--- + +Added support for applying GritQL plugin rewrites as code actions. GritQL plugins that use the rewrite operator (`=>`) now produce fixable diagnostics for JavaScript, CSS, and JSON files. By default, plugin rewrites are treated as unsafe fixes and require `--write --unsafe` to apply. Plugin authors can pass `fix_kind = "safe"` to `register_diagnostic()` to mark a fix as safe, allowing it to be applied with just `--write`. + +**Example plugin** (`useConsoleInfo.grit`): +```grit +language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead of console.log.", severity = "warn", fix_kind = "safe"), + $call => `console.info($msg)` +} +``` + +Running `biome check --write` applies safe rewrites. Unsafe rewrites (the default, or `fix_kind = "unsafe"`) still require `--write --unsafe`. diff --git a/Cargo.lock b/Cargo.lock index 6e7d1a42f553..850ae373099e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,7 @@ dependencies = [ "biome_parser", "biome_rowan", "biome_suppression", + "biome_text_edit", "camino", "enumflags2", "indexmap", diff --git a/crates/biome_analyze/Cargo.toml b/crates/biome_analyze/Cargo.toml index bf09f589f532..82ab054d9089 100644 --- a/crates/biome_analyze/Cargo.toml +++ b/crates/biome_analyze/Cargo.toml @@ -21,6 +21,7 @@ biome_diagnostics = { workspace = true } biome_parser = { workspace = true } biome_rowan = { workspace = true } biome_suppression = { workspace = true } +biome_text_edit = { workspace = true } camino = { workspace = true } enumflags2 = { workspace = true } indexmap = { workspace = true } diff --git a/crates/biome_analyze/src/analyzer_plugin.rs b/crates/biome_analyze/src/analyzer_plugin.rs index 7028267172a6..33ef1852672c 100644 --- a/crates/biome_analyze/src/analyzer_plugin.rs +++ b/crates/biome_analyze/src/analyzer_plugin.rs @@ -1,10 +1,12 @@ +use biome_diagnostics::Applicability; +use biome_rowan::{ + AnySyntaxNode, Language, RawSyntaxKind, SyntaxKind, SyntaxNode, TextRange, WalkEvent, +}; use camino::Utf8PathBuf; use rustc_hash::FxHashSet; use std::hash::Hash; use std::{fmt::Debug, sync::Arc}; -use biome_rowan::{AnySyntaxNode, Language, RawSyntaxKind, SyntaxKind, SyntaxNode, WalkEvent}; - use crate::matcher::SignalRuleKey; use crate::{ PluginSignal, RuleCategory, RuleDiagnostic, SignalEntry, Visitor, VisitorContext, profiling, @@ -16,13 +18,41 @@ pub type AnalyzerPluginSlice<'a> = &'a [Arc>]; /// Vector of analyzer plugins that can be cheaply cloned. pub type AnalyzerPluginVec = Vec>>; +/// Data for a code action produced by a plugin. +#[derive(Debug, Clone)] +pub struct PluginActionData { + /// The source range this action applies to. + pub source_range: TextRange, + /// The original source text that was matched. + pub original_text: String, + /// The rewritten text to replace the original. + pub rewritten_text: String, + /// A message describing the action. + pub message: String, + /// Whether this fix is safe or unsafe. + pub applicability: Applicability, +} + +/// A diagnostic paired with its optional code action. +#[derive(Debug)] +pub struct PluginDiagnosticEntry { + pub diagnostic: RuleDiagnostic, + pub action: Option, +} + +/// Result of evaluating a plugin, containing diagnostics paired with their actions. +#[derive(Debug, Default)] +pub struct PluginEvalResult { + pub entries: Vec, +} + /// Definition of an analyzer plugin. pub trait AnalyzerPlugin: Debug + Send + Sync { fn language(&self) -> PluginTargetLanguage; fn query(&self) -> Vec; - fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> Vec; + fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> PluginEvalResult; } #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] @@ -103,20 +133,26 @@ where } let rule_timer = profiling::start_plugin_rule("plugin"); - let diagnostics = self + let eval_result = self .plugin .evaluate(node.clone().into(), ctx.options.file_path.clone()); rule_timer.stop(); - let signals = diagnostics.into_iter().map(|diagnostic| { - let name = diagnostic + let signals = eval_result.entries.into_iter().map(|entry| { + let name = entry + .diagnostic .subcategory .clone() .unwrap_or_else(|| "anonymous".into()); + let text_range = entry.diagnostic.span().unwrap_or_default(); + + let signal = PluginSignal::::new(entry.diagnostic) + .with_plugin_action(entry.action) + .with_root(node.clone()); SignalEntry { - text_range: diagnostic.span().unwrap_or_default(), - signal: Box::new(PluginSignal::::new(diagnostic)), + text_range, + signal: Box::new(signal), rule: SignalRuleKey::Plugin(name.into()), category: RuleCategory::Lint, instances: Default::default(), diff --git a/crates/biome_analyze/src/lib.rs b/crates/biome_analyze/src/lib.rs index 5d08efefd195..8c0f9ceedfb7 100644 --- a/crates/biome_analyze/src/lib.rs +++ b/crates/biome_analyze/src/lib.rs @@ -30,7 +30,8 @@ mod visitor; pub use biome_diagnostics::category_concat; pub use crate::analyzer_plugin::{ - AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, PluginTargetLanguage, PluginVisitor, + AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, PluginActionData, + PluginDiagnosticEntry, PluginEvalResult, PluginTargetLanguage, PluginVisitor, }; pub use crate::categories::{ ActionCategory, OtherActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder, diff --git a/crates/biome_analyze/src/signals.rs b/crates/biome_analyze/src/signals.rs index 4ebd62ea216c..165d4b6c455b 100644 --- a/crates/biome_analyze/src/signals.rs +++ b/crates/biome_analyze/src/signals.rs @@ -2,8 +2,8 @@ use crate::categories::{ SUPPRESSION_INLINE_ACTION_CATEGORY, SUPPRESSION_TOP_LEVEL_ACTION_CATEGORY, }; use crate::{ - AnalyzerDiagnostic, AnalyzerOptions, OtherActionCategory, Queryable, RuleDiagnostic, RuleGroup, - ServiceBag, SuppressionAction, + AnalyzerDiagnostic, AnalyzerOptions, OtherActionCategory, PluginActionData, Queryable, + RuleDiagnostic, RuleGroup, ServiceBag, SuppressionAction, categories::ActionCategory, context::RuleContext, registry::{RuleLanguage, RuleRoot}, @@ -11,7 +11,9 @@ use crate::{ }; use biome_console::{MarkupBuf, markup}; use biome_diagnostics::{Applicability, CodeSuggestion, Error, advice::CodeSuggestionAdvice}; -use biome_rowan::{BatchMutation, Language}; +use biome_rowan::{BatchMutation, Language, SyntaxNode, TextRange}; +use biome_text_edit::TextEdit; +use std::borrow::Cow; use std::iter::FusedIterator; use std::marker::PhantomData; use std::vec::IntoIter; @@ -109,18 +111,30 @@ where /// Unlike [DiagnosticSignal] which converts through [Error] into /// [DiagnosticKind::Raw](crate::diagnostics::DiagnosticKind::Raw), this type /// directly converts via `AnalyzerDiagnostic::from(RuleDiagnostic)`. -pub struct PluginSignal { +pub struct PluginSignal { diagnostic: RuleDiagnostic, - _phantom: PhantomData, + plugin_action: Option, + root: Option>, } impl PluginSignal { pub fn new(diagnostic: RuleDiagnostic) -> Self { Self { diagnostic, - _phantom: PhantomData, + plugin_action: None, + root: None, } } + + pub fn with_plugin_action(mut self, action: Option) -> Self { + self.plugin_action = action; + self + } + + pub fn with_root(mut self, root: SyntaxNode) -> Self { + self.root = Some(root); + self + } } impl AnalyzerSignal for PluginSignal { @@ -129,7 +143,25 @@ impl AnalyzerSignal for PluginSignal { } fn actions(&self) -> AnalyzerActionIter { - AnalyzerActionIter::new(vec![]) + let Some(action_data) = &self.plugin_action else { + return AnalyzerActionIter::new(vec![]); + }; + + let Some(root) = &self.root else { + return AnalyzerActionIter::new(vec![]); + }; + + let text_edit = + TextEdit::from_unicode_words(&action_data.original_text, &action_data.rewritten_text); + + AnalyzerActionIter::new(vec![AnalyzerAction { + rule_name: None, + category: ActionCategory::QuickFix(Cow::Borrowed("plugin")), + applicability: action_data.applicability, + message: markup!({ action_data.message }).to_owned(), + mutation: BatchMutation::new(root.clone()), + text_edit: Some((action_data.source_range, text_edit)), + }]) } fn transformations(&self) -> AnalyzerTransformationIter { @@ -149,6 +181,8 @@ pub struct AnalyzerAction { pub applicability: Applicability, pub message: MarkupBuf, pub mutation: BatchMutation, + /// Pre-computed text edit for plugin rewrites. Takes precedence over mutation. + pub text_edit: Option<(TextRange, TextEdit)>, } impl AnalyzerAction { @@ -179,7 +213,10 @@ impl Default for AnalyzerActionIter { impl From> for CodeSuggestionAdvice { fn from(action: AnalyzerAction) -> Self { - let (_, suggestion) = action.mutation.to_text_range_and_edit().unwrap_or_default(); + let (_, suggestion) = action + .text_edit + .or_else(|| action.mutation.to_text_range_and_edit()) + .unwrap_or_default(); Self { applicability: action.applicability, msg: action.message, @@ -190,7 +227,10 @@ impl From> for CodeSuggestionAdvice { impl From> for CodeSuggestionItem { fn from(action: AnalyzerAction) -> Self { - let (range, suggestion) = action.mutation.to_text_range_and_edit().unwrap_or_default(); + let (range, suggestion) = action + .text_edit + .or_else(|| action.mutation.to_text_range_and_edit()) + .unwrap_or_default(); Self { rule_name: action.rule_name, @@ -469,6 +509,7 @@ where category: action.category, mutation: action.mutation, message: action.message, + text_edit: None, }); }; if let Some(text_range) = R::text_range(&ctx, &self.state) @@ -485,6 +526,7 @@ where applicability: Applicability::Always, mutation: suppression_action.mutation, message: suppression_action.message, + text_edit: None, }; actions.push(action); } @@ -498,6 +540,7 @@ where applicability: Applicability::Always, mutation: suppression_action.mutation, message: suppression_action.message, + text_edit: None, }; actions.push(action); } diff --git a/crates/biome_cli/tests/commands/check.rs b/crates/biome_cli/tests/commands/check.rs index aa40310e6d9a..9747054c937c 100644 --- a/crates/biome_cli/tests/commands/check.rs +++ b/crates/biome_cli/tests/commands/check.rs @@ -3423,6 +3423,194 @@ const foo = 'bad' )); } +#[test] +fn check_plugin_apply_rewrite() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useConsoleInfo.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead of console.log.", severity = "warn"), + $call => `console.info($msg)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", "--write", "--unsafe", file_path.as_str()].as_slice()), + ); + + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents(&fs, file_path, "console.info(\"hello\");\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_apply_rewrite", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_rewrite_no_write() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useConsoleInfo.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead of console.log.", severity = "warn"), + $call => `console.info($msg)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", file_path.as_str()].as_slice()), + ); + + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents(&fs, file_path, "console.log(\"hello\");\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_rewrite_no_write", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_rewrite_write_without_unsafe() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useConsoleInfo.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead of console.log.", severity = "warn"), + $call => `console.info($msg)` +} +"#, + ); + + let file_path = Utf8Path::new("file.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", "--write", file_path.as_str()].as_slice()), + ); + + // --write without --unsafe should NOT apply unsafe fixes + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents(&fs, file_path, "console.log(\"hello\");\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_rewrite_write_without_unsafe", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_multiple_rewrites() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useLoggerInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useLoggerInfo.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use logger.info instead of console.log.", severity = "warn"), + $call => `logger.info($msg)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert( + file_path.into(), + b"console.log(\"hello\");\nconsole.log(\"world\");\nconsole.log(\"!\");\n", + ); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", "--write", "--unsafe", file_path.as_str()].as_slice()), + ); + + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents( + &fs, + file_path, + "logger.info(\"hello\");\nlogger.info(\"world\");\nlogger.info(\"!\");\n", + ); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_multiple_rewrites", + fs, + console, + result, + )); +} + #[test] fn doesnt_check_file_when_assist_is_disabled() { let fs = MemoryFileSystem::default(); @@ -3579,3 +3767,286 @@ fn check_format_with_syntax_errors_when_flag_enabled() { result, )); } + +#[test] +fn check_plugin_apply_rewrite_css() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["banRed.grit"], + "css": { "linter": { "enabled": true } }, + "linter": { + "rules": { "recommended": false } + }, + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("banRed.grit"), + br#"language css + +`red` as $color where { + register_diagnostic( + span = $color, + message = "Avoid using red.", + severity = "warn" + ), + $color => `blue` +} +"#, + ); + + let file_path = Utf8Path::new("file.css"); + fs.insert(file_path.into(), b"a { color: red; }\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", "--write", "--unsafe", file_path.as_str()].as_slice()), + ); + + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents(&fs, file_path, "a { color: blue; }\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_apply_rewrite_css", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_safe_fix_write() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useConsoleInfo.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead of console.log.", severity = "warn", fix_kind = "safe"), + $call => `console.info($msg)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", "--write", file_path.as_str()].as_slice()), + ); + + // --write without --unsafe should apply safe fixes + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents(&fs, file_path, "console.info(\"hello\");\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_safe_fix_write", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_safe_fix_no_write() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("useConsoleInfo.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead of console.log.", severity = "warn", fix_kind = "safe"), + $call => `console.info($msg)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", file_path.as_str()].as_slice()), + ); + + // Without --write, safe fixes should not be applied but shown as "Safe fix" + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents(&fs, file_path, "console.log(\"hello\");\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_safe_fix_no_write", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_invalid_fix_kind() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["badFixKind.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("badFixKind.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead.", fix_kind = "invalid"), + $call => `console.info($msg)` +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", file_path.as_str()].as_slice()), + ); + + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_invalid_fix_kind", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_invalid_severity() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["badSeverity.grit"], + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("badSeverity.grit"), + br#"language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead.", severity = "invalid") +} +"#, + ); + + let file_path = Utf8Path::new("input.js"); + fs.insert(file_path.into(), b"console.log(\"hello\");\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", file_path.as_str()].as_slice()), + ); + + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_invalid_severity", + fs, + console, + result, + )); +} + +#[test] +fn check_plugin_apply_rewrite_json() { + let fs = MemoryFileSystem::default(); + let mut console = BufferConsole::default(); + + fs.insert( + Utf8PathBuf::from("biome.json"), + br#"{ + "plugins": ["fixVersion.grit"], + "json": { "linter": { "enabled": true } }, + "linter": { + "rules": { "recommended": false } + }, + "formatter": { "enabled": false } +} +"#, + ); + + fs.insert( + Utf8PathBuf::from("fixVersion.grit"), + br#"language json + +`"1.0.0"` as $version where { + register_diagnostic( + span = $version, + message = "Update version.", + severity = "warn" + ), + $version => `"2.0.0"` +} +"#, + ); + + let file_path = Utf8Path::new("package.json"); + fs.insert(file_path.into(), b"{\"version\": \"1.0.0\"}\n"); + + let (fs, result) = run_cli_with_server_workspace( + fs, + &mut console, + Args::from(["check", "--write", "--unsafe", file_path.as_str()].as_slice()), + ); + + assert!(result.is_ok(), "run_cli returned {result:?}"); + assert_file_contents(&fs, file_path, "{\"version\": \"2.0.0\"}\n"); + assert_cli_snapshot(SnapshotPayload::new( + module_path!(), + "check_plugin_apply_rewrite_json", + fs, + console, + result, + )); +} diff --git a/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_apply_rewrite.snap b/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_apply_rewrite.snap new file mode 100644 index 000000000000..0da9f16a0423 --- /dev/null +++ b/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_apply_rewrite.snap @@ -0,0 +1,37 @@ +--- +source: crates/biome_cli/tests/snap_test.rs +expression: redactor(content) +--- +## `biome.json` + +```json +{ + "plugins": ["useConsoleInfo.grit"], + "formatter": { "enabled": false } +} +``` + +## `input.js` + +```js +console.info("hello"); + +``` + +## `useConsoleInfo.grit` + +```grit +language js + +`console.log($msg)` as $call where { + register_diagnostic(span = $call, message = "Use console.info instead of console.log.", severity = "warn"), + $call => `console.info($msg)` +} + +``` + +# Emitted Messages + +```block +Checked 1 file in