main.rs raw

   1  // Copyright (c) The Limenka developers
   2  // Distributed under the MIT software license, see the accompanying
   3  // file COPYING or https://opensource.org/license/mit/.
   4  
   5  use std::env;
   6  use std::fs;
   7  use std::io::ErrorKind;
   8  use std::path::PathBuf;
   9  use std::process::{Command, ExitCode, Stdio};
  10  
  11  /// A possible error returned by any of the linters.
  12  ///
  13  /// The error string should explain the failure type and list all violations.
  14  type LintError = String;
  15  type LintResult = Result<(), LintError>;
  16  type LintFn = fn() -> LintResult;
  17  
  18  struct Linter {
  19      pub description: &'static str,
  20      pub name: &'static str,
  21      pub lint_fn: LintFn,
  22  }
  23  
  24  fn get_linter_list() -> Vec<&'static Linter> {
  25      vec![
  26          &Linter {
  27              description: "Check that all command line arguments are documented.",
  28              name: "doc",
  29              lint_fn: lint_doc
  30          },
  31          &Linter {
  32              description: "Check that no symbol from limenka-build-config.h is used without the header being included",
  33              name: "includes_build_config",
  34              lint_fn: lint_includes_build_config
  35          },
  36          &Linter {
  37              description: "Check that markdown links resolve",
  38              name: "markdown",
  39              lint_fn: lint_markdown
  40          },
  41          &Linter {
  42              description: "Lint Python code",
  43              name: "py_lint",
  44              lint_fn: lint_py_lint,
  45          },
  46          &Linter {
  47              description: "Check that std::filesystem is not used directly",
  48              name: "std_filesystem",
  49              lint_fn: lint_std_filesystem
  50          },
  51          &Linter {
  52              description: "Check that fatal assertions are not used in RPC code",
  53              name: "rpc_assert",
  54              lint_fn: lint_rpc_assert
  55          },
  56          &Linter {
  57              description: "Check that boost assertions are not used",
  58              name: "boost_assert",
  59              lint_fn: lint_boost_assert
  60          },
  61          &Linter {
  62              description: "Check that release note snippets are in the right folder",
  63              name: "doc_release_note_snippets",
  64              lint_fn: lint_doc_release_note_snippets
  65          },
  66          &Linter {
  67              description: "Check that subtrees are pure subtrees",
  68              name: "subtree",
  69              lint_fn: lint_subtree
  70          },
  71          &Linter {
  72              description: "Check scripted-diffs",
  73              name: "scripted_diff",
  74              lint_fn: lint_scripted_diff
  75          },
  76          &Linter {
  77              description: "Check that commit messages have a new line before the body or no body at all.",
  78              name: "commit_msg",
  79              lint_fn: lint_commit_msg
  80          },
  81          &Linter {
  82              description: "Check that tabs are not used as whitespace",
  83              name: "tabs_whitespace",
  84              lint_fn: lint_tabs_whitespace
  85          },
  86          &Linter {
  87              description: "Check for trailing whitespace",
  88              name: "trailing_whitespace",
  89              lint_fn: lint_trailing_whitespace
  90          },
  91          &Linter {
  92              description: "Run all linters of the form: test/lint/lint-*.py",
  93              name: "all_python_linters",
  94              lint_fn: run_all_python_linters
  95          },
  96      ]
  97  }
  98  
  99  fn print_help_and_exit() {
 100      print!(
 101          r#"
 102  Usage: test_runner [--lint=LINTER_TO_RUN]
 103  Runs all linters in the lint test suite, printing any errors
 104  they detect.
 105  
 106  If you wish to only run some particular lint tests, pass
 107  '--lint=' with the name of the lint test you wish to run.
 108  You can set as many '--lint=' values as you wish, e.g.:
 109  test_runner --lint=doc --lint=subtree
 110  
 111  The individual linters available to run are:
 112  "#
 113      );
 114      for linter in get_linter_list() {
 115          println!("{}: \"{}\"", linter.name, linter.description)
 116      }
 117  
 118      std::process::exit(1);
 119  }
 120  
 121  fn parse_lint_args(args: &[String]) -> Vec<&'static Linter> {
 122      let linter_list = get_linter_list();
 123      let mut lint_values = Vec::new();
 124  
 125      for arg in args {
 126          #[allow(clippy::if_same_then_else)]
 127          if arg.starts_with("--lint=") {
 128              let lint_arg_value = arg
 129                  .trim_start_matches("--lint=")
 130                  .trim_matches('"')
 131                  .trim_matches('\'');
 132  
 133              let try_find_linter = linter_list
 134                  .iter()
 135                  .find(|linter| linter.name == lint_arg_value);
 136              match try_find_linter {
 137                  Some(linter) => {
 138                      lint_values.push(*linter);
 139                  }
 140                  None => {
 141                      println!("No linter {lint_arg_value} found!");
 142                      print_help_and_exit();
 143                  }
 144              }
 145          } else if arg.eq("--help") || arg.eq("-h") {
 146              print_help_and_exit();
 147          } else {
 148              print_help_and_exit();
 149          }
 150      }
 151  
 152      lint_values
 153  }
 154  
 155  /// Return the git command
 156  ///
 157  /// Lint functions should use this command, so that only files tracked by git are considered and
 158  /// temporary and untracked files are ignored. For example, instead of 'grep', 'git grep' should be
 159  /// used.
 160  fn git() -> Command {
 161      let mut git = Command::new("git");
 162      git.arg("--no-pager");
 163      git
 164  }
 165  
 166  /// Return stdout on success and a LintError on failure, when invalid UTF8 was detected or the
 167  /// command did not succeed.
 168  fn check_output(cmd: &mut std::process::Command) -> Result<String, LintError> {
 169      let out = cmd.output().expect("command error");
 170      if !out.status.success() {
 171          return Err(String::from_utf8_lossy(&out.stderr).to_string());
 172      }
 173      Ok(String::from_utf8(out.stdout)
 174          .map_err(|e| {
 175              format!("All path names, source code, messages, and output must be valid UTF8!\n{e}")
 176          })?
 177          .trim()
 178          .to_string())
 179  }
 180  
 181  /// Return the git root as utf8, or panic
 182  fn get_git_root() -> PathBuf {
 183      PathBuf::from(check_output(git().args(["rev-parse", "--show-toplevel"])).unwrap())
 184  }
 185  
 186  /// Return the commit range, or panic
 187  fn commit_range() -> String {
 188      // Use the env var, if set. E.g. COMMIT_RANGE='HEAD~n..HEAD' for the last 'n' commits.
 189      env::var("COMMIT_RANGE").unwrap_or_else(|_| {
 190          // Otherwise, assume that a merge commit exists. This merge commit is assumed
 191          // to be the base, after which linting will be done. If the merge commit is
 192          // HEAD, the range will be empty.
 193          format!(
 194              "{}..HEAD",
 195              check_output(git().args(["rev-list", "--max-count=1", "--merges", "HEAD"]))
 196                  .expect("check_output failed")
 197          )
 198      })
 199  }
 200  
 201  /// Return all subtree paths
 202  fn get_subtrees() -> Vec<&'static str> {
 203      vec![
 204          "src/crc32c",
 205          "src/crypto/ctaes",
 206          "src/leveldb",
 207          "src/minisketch",
 208          "src/secp256k1",
 209      ]
 210  }
 211  
 212  /// Return the pathspecs to exclude all subtrees
 213  fn get_pathspecs_exclude_subtrees() -> Vec<String> {
 214      get_subtrees()
 215          .iter()
 216          .map(|s| format!(":(exclude){}", s))
 217          .collect()
 218  }
 219  
 220  fn lint_subtree() -> LintResult {
 221      // This only checks that the trees are pure subtrees, it is not doing a full
 222      // check with -r to not have to fetch all the remotes.
 223      let mut good = true;
 224      for subtree in get_subtrees() {
 225          good &= Command::new("test/lint/git-subtree-check.sh")
 226              .arg(subtree)
 227              .status()
 228              .expect("command_error")
 229              .success();
 230      }
 231      if good {
 232          Ok(())
 233      } else {
 234          Err("".to_string())
 235      }
 236  }
 237  
 238  fn lint_scripted_diff() -> LintResult {
 239      if Command::new("test/lint/commit-script-check.sh")
 240          .arg(commit_range())
 241          .status()
 242          .expect("command error")
 243          .success()
 244      {
 245          Ok(())
 246      } else {
 247          Err("".to_string())
 248      }
 249  }
 250  
 251  fn lint_commit_msg() -> LintResult {
 252      let mut good = true;
 253      let commit_hashes = check_output(git().args(&[
 254          "-c",
 255          "log.showSignature=false",
 256          "log",
 257          &commit_range(),
 258          "--format=%H",
 259      ]))?;
 260      for hash in commit_hashes.lines() {
 261          let commit_info = check_output(git().args([
 262              "-c",
 263              "log.showSignature=false",
 264              "log",
 265              "--format=%B",
 266              "-n",
 267              "1",
 268              hash,
 269          ]))?;
 270          if let Some(line) = commit_info.lines().nth(1) {
 271              if !line.is_empty() {
 272                  println!(
 273                          "The subject line of commit hash {} is followed by a non-empty line. Subject lines should always be followed by a blank line.",
 274                          hash
 275                      );
 276                  good = false;
 277              }
 278          }
 279      }
 280      if good {
 281          Ok(())
 282      } else {
 283          Err("".to_string())
 284      }
 285  }
 286  
 287  fn lint_py_lint() -> LintResult {
 288      let bin_name = "ruff";
 289      let checks = format!(
 290          "--select={}",
 291          [
 292              "B006", // mutable-argument-default
 293              "B008", // function-call-in-default-argument
 294              "E101", // indentation contains mixed spaces and tabs
 295              "E401", // multiple imports on one line
 296              "E402", // module level import not at top of file
 297              "E702", // multiple statements on one line (semicolon)
 298              "E703", // statement ends with a semicolon
 299              "E711", // comparison to None should be 'if cond is None:'
 300              "E721", // do not compare types, use "isinstance()"
 301              "E722", // do not use bare 'except'
 302              "E742", // do not define classes named "l", "O", or "I"
 303              "E743", // do not define functions named "l", "O", or "I"
 304              "F402", // import module from line N shadowed by loop variable
 305              "F403", // 'from foo_module import *' used; unable to detect undefined names
 306              "F404", // future import(s) name after other statements
 307              "F405", // foo_function may be undefined, or defined from star imports: bar_module
 308              "F406", // "from module import *" only allowed at module level
 309              "F407", // an undefined __future__ feature name was imported
 310              "F541", // f-string without any placeholders
 311              "F601", // dictionary key name repeated with different values
 312              "F602", // dictionary key variable name repeated with different values
 313              "F621", // too many expressions in an assignment with star-unpacking
 314              "F631", // assertion test is a tuple, which are always True
 315              "F632", // use ==/!= to compare str, bytes, and int literals
 316              "F811", // redefinition of unused name from line N
 317              "F821", // undefined name 'Foo'
 318              "F822", // undefined name name in __all__
 319              "F823", // local variable name … referenced before assignment
 320              "PLE",  // Pylint errors
 321              "W191", // indentation contains tabs
 322              "W291", // trailing whitespace
 323              "W292", // no newline at end of file
 324              "W293", // blank line contains whitespace
 325              "W605", // invalid escape sequence "x"
 326          ]
 327          .join(",")
 328      );
 329      let files = check_output(
 330          git()
 331              .args(["ls-files", "--", "*.py"])
 332              .args(get_pathspecs_exclude_subtrees()),
 333      )?;
 334  
 335      let mut cmd = Command::new(bin_name);
 336      cmd.args(["check", &checks]).args(files.lines());
 337  
 338      match cmd.status() {
 339          Ok(status) if status.success() => Ok(()),
 340          Ok(_) => Err(format!("`{}` found errors!", bin_name)),
 341          Err(e) if e.kind() == ErrorKind::NotFound => {
 342              println!(
 343                  "`{}` was not found in $PATH, skipping those checks.",
 344                  bin_name
 345              );
 346              Ok(())
 347          }
 348          Err(e) => Err(format!("Error running `{}`: {}", bin_name, e)),
 349      }
 350  }
 351  
 352  fn lint_std_filesystem() -> LintResult {
 353      let found = git()
 354          .args([
 355              "grep",
 356              "--line-number",
 357              "std::filesystem",
 358              "--",
 359              "./src/",
 360              ":(exclude)src/util/fs.h",
 361          ])
 362          .status()
 363          .expect("command error")
 364          .success();
 365      if found {
 366          Err(r#"
 367  Direct use of std::filesystem may be dangerous and buggy. Please include <util/fs.h> and use the
 368  fs:: namespace, which has unsafe filesystem functions marked as deleted.
 369              "#
 370          .trim()
 371          .to_string())
 372      } else {
 373          Ok(())
 374      }
 375  }
 376  
 377  fn lint_rpc_assert() -> LintResult {
 378      let found = git()
 379          .args([
 380              "grep",
 381              "--line-number",
 382              "--extended-regexp",
 383              r"\<(A|a)ss(ume|ert)\(",
 384              "--",
 385              "src/rpc/",
 386              "src/wallet/rpc*",
 387              ":(exclude)src/rpc/server.cpp",
 388              // src/rpc/server.cpp is excluded from this check since it's mostly meta-code.
 389          ])
 390          .status()
 391          .expect("command error")
 392          .success();
 393      if found {
 394          Err(r#"
 395  CHECK_NONFATAL(condition) or NONFATAL_UNREACHABLE should be used instead of assert for RPC code.
 396  
 397  Aborting the whole process is undesirable for RPC code. So nonfatal
 398  checks should be used over assert. See: src/util/check.h
 399              "#
 400          .trim()
 401          .to_string())
 402      } else {
 403          Ok(())
 404      }
 405  }
 406  
 407  fn lint_boost_assert() -> LintResult {
 408      let found = git()
 409          .args([
 410              "grep",
 411              "--line-number",
 412              "--extended-regexp",
 413              r"BOOST_ASSERT\(",
 414              "--",
 415              "*.cpp",
 416              "*.h",
 417          ])
 418          .status()
 419          .expect("command error")
 420          .success();
 421      if found {
 422          Err(r#"
 423  BOOST_ASSERT must be replaced with Assert, BOOST_REQUIRE, or BOOST_CHECK to avoid an unnecessary
 424  include of the boost/assert.hpp dependency.
 425              "#
 426          .trim()
 427          .to_string())
 428      } else {
 429          Ok(())
 430      }
 431  }
 432  
 433  fn lint_doc_release_note_snippets() -> LintResult {
 434      let non_release_notes = check_output(git().args([
 435          "ls-files",
 436          "--",
 437          "doc/release-notes/",
 438          ":(exclude)doc/release-notes/*.*.md", // Assume that at least one dot implies a proper release note
 439      ]))?;
 440      if non_release_notes.is_empty() {
 441          Ok(())
 442      } else {
 443          println!("{non_release_notes}");
 444          Err(r#"
 445  Release note snippets and other docs must be put into the doc/ folder directly.
 446  
 447  The doc/release-notes/ folder is for archived release notes of previous releases only. Snippets are
 448  expected to follow the naming "/doc/release-notes-<PR number>.md".
 449              "#
 450          .trim()
 451          .to_string())
 452      }
 453  }
 454  
 455  /// Return the pathspecs for whitespace related excludes
 456  fn get_pathspecs_exclude_whitespace() -> Vec<String> {
 457      let mut list = get_pathspecs_exclude_subtrees();
 458      list.extend(
 459          [
 460              // Permanent excludes
 461              "*.patch",
 462              "src/qt/locale",
 463              "contrib/windeploy/win-codesign.cert",
 464              "doc/README_windows.txt",
 465              // Temporary excludes, or existing violations
 466              "doc/release-notes/release-notes-0.*",
 467              "contrib/init/limenkad.openrc",
 468              "contrib/macdeploy/macdeployqtplus",
 469              "src/crypto/sha256_sse4.cpp",
 470              "src/qt/res/src/*.svg",
 471              "test/functional/test_framework/crypto/ellswift_decode_test_vectors.csv",
 472              "test/functional/test_framework/crypto/xswiftec_inv_test_vectors.csv",
 473              "contrib/qos/tc.sh",
 474              "contrib/verify-commits/gpg.sh",
 475              "src/univalue/include/univalue_escapes.h",
 476              "src/univalue/test/object.cpp",
 477              "test/lint/git-subtree-check.sh",
 478          ]
 479          .iter()
 480          .map(|s| format!(":(exclude){}", s)),
 481      );
 482      list
 483  }
 484  
 485  fn lint_trailing_whitespace() -> LintResult {
 486      let trailing_space = git()
 487          .args(["grep", "-I", "--line-number", "\\s$", "--"])
 488          .args(get_pathspecs_exclude_whitespace())
 489          .status()
 490          .expect("command error")
 491          .success();
 492      if trailing_space {
 493          Err(r#"
 494  Trailing whitespace (including Windows line endings [CR LF]) is problematic, because git may warn
 495  about it, or editors may remove it by default, forcing developers in the future to either undo the
 496  changes manually or spend time on review.
 497  
 498  Thus, it is best to remove the trailing space now.
 499  
 500  Please add any false positives, such as subtrees, Windows-related files, patch files, or externally
 501  sourced files to the exclude list.
 502              "#
 503          .trim()
 504          .to_string())
 505      } else {
 506          Ok(())
 507      }
 508  }
 509  
 510  fn lint_tabs_whitespace() -> LintResult {
 511      let tabs = git()
 512          .args(["grep", "-I", "--line-number", "--perl-regexp", "^\\t", "--"])
 513          .args(["*.cpp", "*.h", "*.md", "*.py", "*.sh"])
 514          .args(get_pathspecs_exclude_whitespace())
 515          .status()
 516          .expect("command error")
 517          .success();
 518      if tabs {
 519          Err(r#"
 520  Use of tabs in this codebase is problematic, because existing code uses spaces and tabs will cause
 521  display issues and conflict with editor settings.
 522  
 523  Please remove the tabs.
 524  
 525  Please add any false positives, such as subtrees, or externally sourced files to the exclude list.
 526              "#
 527          .trim()
 528          .to_string())
 529      } else {
 530          Ok(())
 531      }
 532  }
 533  
 534  fn lint_includes_build_config() -> LintResult {
 535      let config_path = "./cmake/limenka-build-config.h.in";
 536      let defines_regex = format!(
 537          r"^\s*(?!//).*({})",
 538          check_output(Command::new("grep").args(["define", "--", config_path]))
 539              .expect("grep failed")
 540              .lines()
 541              .map(|line| {
 542                  line.split_whitespace()
 543                      .nth(1)
 544                      .unwrap_or_else(|| panic!("Could not extract name in line: {line}"))
 545              })
 546              .collect::<Vec<_>>()
 547              .join("|")
 548      );
 549      let print_affected_files = |mode: bool| {
 550          // * mode==true: Print files which use the define, but lack the include
 551          // * mode==false: Print files which lack the define, but use the include
 552          let defines_files = check_output(
 553              git()
 554                  .args([
 555                      "grep",
 556                      "--perl-regexp",
 557                      if mode {
 558                          "--files-with-matches"
 559                      } else {
 560                          "--files-without-match"
 561                      },
 562                      &defines_regex,
 563                      "--",
 564                      "*.cpp",
 565                      "*.h",
 566                  ])
 567                  .args(get_pathspecs_exclude_subtrees())
 568                  .args([
 569                      // These are exceptions which don't use limenka-build-config.h, rather CMakeLists.txt adds
 570                      // these cppflags manually.
 571                      ":(exclude)src/crypto/sha256_arm_shani.cpp",
 572                      ":(exclude)src/crypto/sha256_avx2.cpp",
 573                      ":(exclude)src/crypto/sha256_sse41.cpp",
 574                      ":(exclude)src/crypto/sha256_x86_shani.cpp",
 575                  ]),
 576          )
 577          .expect("grep failed");
 578          git()
 579              .args([
 580                  "grep",
 581                  if mode {
 582                      "--files-without-match"
 583                  } else {
 584                      "--files-with-matches"
 585                  },
 586                  if mode {
 587                      "^#include <limenka-build-config.h> // IWYU pragma: keep$"
 588                  } else {
 589                      "#include <limenka-build-config.h>" // Catch redundant includes with and without the IWYU pragma
 590                  },
 591                  "--",
 592              ])
 593              .args(defines_files.lines())
 594              .status()
 595              .expect("command error")
 596              .success()
 597      };
 598      let missing = print_affected_files(true);
 599      if missing {
 600          return Err(format!(
 601              r#"
 602  One or more files use a symbol declared in the limenka-build-config.h header. However, they are not
 603  including the header. This is problematic, because the header may or may not be indirectly
 604  included. If the indirect include were to be intentionally or accidentally removed, the build could
 605  still succeed, but silently be buggy. For example, a slower fallback algorithm could be picked,
 606  even though limenka-build-config.h indicates that a faster feature is available and should be used.
 607  
 608  If you are unsure which symbol is used, you can find it with this command:
 609  git grep --perl-regexp '{}' -- file_name
 610  
 611  Make sure to include it with the IWYU pragma. Otherwise, IWYU may falsely instruct to remove the
 612  include again.
 613  
 614  #include <limenka-build-config.h> // IWYU pragma: keep
 615              "#,
 616              defines_regex
 617          )
 618          .trim()
 619          .to_string());
 620      }
 621      let redundant = print_affected_files(false);
 622      if redundant {
 623          return Err(r#"
 624  None of the files use a symbol declared in the limenka-build-config.h header. However, they are including
 625  the header. Consider removing the unused include.
 626              "#
 627          .to_string());
 628      }
 629      Ok(())
 630  }
 631  
 632  fn lint_doc() -> LintResult {
 633      if Command::new("test/lint/check-doc.py")
 634          .status()
 635          .expect("command error")
 636          .success()
 637      {
 638          Ok(())
 639      } else {
 640          Err("".to_string())
 641      }
 642  }
 643  
 644  fn lint_markdown() -> LintResult {
 645      let bin_name = "mlc";
 646      let mut md_ignore_paths = get_subtrees();
 647      md_ignore_paths.push("./doc/README_doxygen.md");
 648      let md_ignore_path_str = md_ignore_paths.join(",");
 649  
 650      let mut cmd = Command::new(bin_name);
 651      cmd.args([
 652          "--offline",
 653          "--ignore-path",
 654          md_ignore_path_str.as_str(),
 655          "--gitignore",
 656          "--gituntracked",
 657          "--root-dir",
 658          ".",
 659      ])
 660      .stdout(Stdio::null()); // Suppress overly-verbose output
 661  
 662      match cmd.output() {
 663          Ok(output) if output.status.success() => Ok(()),
 664          Ok(output) => {
 665              let stderr = String::from_utf8_lossy(&output.stderr);
 666              Err(format!(
 667                  r#"
 668  One or more markdown links are broken.
 669  
 670  Note: relative links are preferred as jump-to-file works natively within Emacs, but they are not required.
 671  
 672  Markdown link errors found:
 673  {}
 674                  "#,
 675                  stderr
 676              )
 677              .trim()
 678              .to_string())
 679          }
 680          Err(e) if e.kind() == ErrorKind::NotFound => {
 681              println!("`mlc` was not found in $PATH, skipping markdown lint check.");
 682              Ok(())
 683          }
 684          Err(e) => Err(format!("Error running mlc: {}", e)), // Misc errors
 685      }
 686  }
 687  
 688  fn run_all_python_linters() -> LintResult {
 689      let mut good = true;
 690      let lint_dir = get_git_root().join("test/lint");
 691      for entry in fs::read_dir(lint_dir).unwrap() {
 692          let entry = entry.unwrap();
 693          let entry_fn = entry.file_name().into_string().unwrap();
 694          if entry_fn.starts_with("lint-")
 695              && entry_fn.ends_with(".py")
 696              && !Command::new("python3")
 697                  .arg(entry.path())
 698                  .status()
 699                  .expect("command error")
 700                  .success()
 701          {
 702              good = false;
 703              println!("^---- ⚠️ Failure generated from {}", entry_fn);
 704          }
 705      }
 706      if good {
 707          Ok(())
 708      } else {
 709          Err("".to_string())
 710      }
 711  }
 712  
 713  fn main() -> ExitCode {
 714      let linters_to_run: Vec<&Linter> = if env::args().count() > 1 {
 715          let args: Vec<String> = env::args().skip(1).collect();
 716          parse_lint_args(&args)
 717      } else {
 718          // If no arguments are passed, run all linters.
 719          get_linter_list()
 720      };
 721  
 722      let git_root = get_git_root();
 723      let commit_range = commit_range();
 724      let commit_log = check_output(git().args(["log", "--no-merges", "--oneline", &commit_range]))
 725          .expect("check_output failed");
 726      println!("Checking commit range ({commit_range}):\n{commit_log}\n");
 727  
 728      let mut test_failed = false;
 729      for linter in linters_to_run {
 730          // chdir to root before each lint test
 731          env::set_current_dir(&git_root).unwrap();
 732          if let Err(err) = (linter.lint_fn)() {
 733              println!(
 734                  "^^^\n{err}\n^---- ⚠️ Failure generated from lint check '{}' ({})!\n\n",
 735                  linter.name, linter.description,
 736              );
 737              test_failed = true;
 738          }
 739      }
 740      if test_failed {
 741          ExitCode::FAILURE
 742      } else {
 743          ExitCode::SUCCESS
 744      }
 745  }
 746