diff --git a/DESCRIPTION b/DESCRIPTION
index 8a343ba..3d38772 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -50,6 +50,7 @@ Suggests:
rcmdcheck,
igraph,
knitr,
+ lintr,
rmarkdown,
rosv,
testthat (>= 3.0.0),
@@ -91,6 +92,7 @@ Collate:
'data_documentation_examples.R'
'data_downloads_total.R'
'data_has_current_news.R'
+ 'data_lints.R'
'data_r_cmd_check.R'
'data_recognized_source.R'
'data_reverse_dependencies.R'
diff --git a/R/data_lints.R b/R/data_lints.R
new file mode 100644
index 0000000..739d966
--- /dev/null
+++ b/R/data_lints.R
@@ -0,0 +1,201 @@
+#' @include impl_data.R
+
+# Lint metric implementation
+
+#' Find lintable files in a package
+#'
+#' @param path Package source directory.
+#' @param pkg_dirs Subdirectories to search.
+#' @param file_pattern Regex for file extensions.
+#' @return Character vector of file paths.
+#' @noRd
+find_lintable_files <- function(
+ path,
+ pkg_dirs = c("R", "tests", "inst", "vignettes", "data-raw", "demo", "exec"),
+ file_pattern = "(?i)[.](r|rmd|qmd|rnw|rhtml|rrst|rtex|rtxt)$"
+) {
+ unlist(lapply(pkg_dirs, function(dir) {
+ dir_path <- file.path(path, dir)
+ if (dir.exists(dir_path)) {
+ list.files(
+ dir_path,
+ pattern = file_pattern,
+ recursive = TRUE,
+ full.names = TRUE
+ )
+ } else {
+ character(0)
+ }
+ }), use.names = FALSE)
+}
+
+#' Count token-bearing, non-comment lines in a single file
+#'
+#' @param file Path to an R or Rmd file.
+#' @return Integer count of code lines, or 0L on parse error.
+#' @noRd
+count_file_code_lines <- function(file) {
+ tryCatch(
+ {
+ se <- lintr::get_source_expressions(file)
+ full_expr <- se$expressions[[length(se$expressions)]]
+ pd <- full_expr$full_parsed_content
+
+ if (!is.null(pd) && nrow(pd) > 0L) {
+ length(unique(pd$line1[pd$terminal & pd$token != "COMMENT"]))
+ } else {
+ 0L
+ }
+ },
+ error = function(e) 0L
+ )
+}
+
+#' Count token-bearing, non-comment lines in files that lintr processes
+#'
+#' Uses lintr's own parsing frontend to ensure the denominator matches
+#' the scope of lint_package(). A "relevant code line" is a physical
+#' line containing at least one terminal token that is not a comment.
+#'
+#' @param path Path to package source directory.
+#' @param pkg_dirs Character vector of subdirectories to search.
+#' @param file_pattern Regex pattern for file extensions.
+#' @return Integer count of code lines.
+#' @noRd
+count_lintable_code_lines <- function(
+ path,
+ pkg_dirs = c("R", "tests", "inst", "vignettes", "data-raw", "demo", "exec"),
+ file_pattern = "(?i)[.](r|rmd|qmd|rnw|rhtml|rrst|rtex|rtxt)$"
+) {
+ r_files <- find_lintable_files(path, pkg_dirs, file_pattern)
+
+ if (length(r_files) == 0L) {
+ return(0L)
+ }
+
+ sum(viapply(r_files, count_file_code_lines))
+}
+
+# Helper: count unique (filename, line_number) pairs across all lints
+count_linted_lines <- function(lints) {
+ if (length(lints) == 0L) return(0L)
+ pairs <- vcapply(lints, function(l) paste0(l$filename, ":", l$line_number))
+ length(unique(pairs))
+}
+
+# Auxiliary data: full lint results
+impl_data(
+ "lints",
+ class = S7::new_S3_class("lints"),
+ metric = FALSE,
+ tags = c("execution"),
+ suggests = "lintr",
+ permissions = "execution",
+ title = "Lint Results",
+ description = paste(
+ "Full \\pkg{lintr} lint results for the package.",
+ "Contains detailed information about each lint including line numbers,",
+ "column positions, and lint messages. This is auxiliary data used to",
+ "derive the \\code{lint_count} and \\code{lint_free_fraction} metrics.",
+ "The set of linters applied is controlled by the \\code{lint_linters}",
+ "option."
+ )
+)
+
+impl_data(
+ "lints",
+ for_resource = source_code_resource,
+ function(pkg, resource, field, ..., quiet = opt("quiet")) {
+ linter_names <- opt("lint_linters")
+ # We are loading linter objects by name here to avoid hard dependencies
+ # on lintr in the rest of the code.
+ linters <- setNames(
+ lapply(linter_names, function(nm) utils::getFromNamespace(nm, "lintr")()),
+ linter_names
+ )
+ wrapper <- if (quiet) {
+ function(...) capture.output(..., type = "message")
+ } else {
+ identity
+ }
+
+ wrapper({
+ result <- lintr::lint_package(
+ path = resource@path,
+ linters = linters,
+ parse_settings = FALSE # Don't read .lintr config for consistency
+ )
+ })
+
+ result
+ }
+)
+
+# Primary metric: count of lints
+impl_data(
+ "lint_count",
+ metric = TRUE,
+ class = class_integer,
+ tags = c("best practice"),
+ title = "Lint Count",
+ description = paste(
+ "Count of lints identified by \\pkg{lintr}.",
+ "The set of linters applied is controlled by the \\code{lint_linters}",
+ "option, which defaults to linters from the \\emph{correctness},",
+ "\\emph{common_mistakes}, and \\emph{robustness} tags excluding",
+ "those that require configuration or are environment-dependent.",
+ "Lower counts indicate higher code quality."
+ )
+)
+
+impl_data(
+ "lint_count",
+ for_resource = source_code_resource,
+ function(pkg, resource, field, ...) {
+ length(pkg$lints)
+ }
+)
+
+impl_data(
+ "lint_count",
+ for_resource = mock_resource,
+ function(pkg, resource, field, ...) rpois(1, 10)
+)
+
+# Secondary metric: fraction of lintable lines that are lint-free
+impl_data(
+ "lint_free_fraction",
+ metric = TRUE,
+ class = class_double,
+ tags = c("best practice"),
+ title = "Lint-Free Fraction",
+ description = paste(
+ "Fraction of R code lines that are free of lints.",
+ "Code lines are defined as lines containing executable R tokens",
+ "(excluding comments and blank lines) across the standard package",
+ "directories (\\code{R/}, \\code{tests/}, \\code{inst/},",
+ "\\code{vignettes/}, \\code{data-raw/}, \\code{demo/}, \\code{exec/}).",
+ "A value of 1 means no linted lines; lower values indicate more",
+ "pervasive lint issues. The set of linters applied is controlled by",
+ "the \\code{lint_linters} option."
+ )
+)
+
+impl_data(
+ "lint_free_fraction",
+ for_resource = source_code_resource,
+ function(pkg, resource, field, ...) {
+ loc <- count_lintable_code_lines(resource@path)
+ if (loc == 0L) return(1.0)
+ linted <- count_linted_lines(pkg$lints)
+ max(0.0, 1.0 - linted / loc)
+ }
+)
+
+impl_data(
+ "lint_free_fraction",
+ for_resource = mock_resource,
+ function(pkg, resource, field, ...) {
+ runif(1, min = 0.7, max = 1.0)
+ }
+)
diff --git a/R/options.R b/R/options.R
index 58e1bc9..863ad49 100644
--- a/R/options.R
+++ b/R/options.R
@@ -28,6 +28,41 @@ define_options(
(for example, running `R CMD check`)",
quiet = TRUE,
+ fmt(
+ "Character vector of `{{lintr}}` linter function names used when computing
+ `lint_count` and `lint_free_fraction`. Each name must correspond
+ to a linter constructor exported by `{{lintr}}` (e.g.
+ `\"equals_na_linter\"`. Override to add, remove, or replace linters.
+ Note: linters requiring configuration or environment-specific ones
+ (e.g. `\"backport_linter\"`) are excluded from the default set;
+ add them manually by changing this option, possibly after configuring
+ them globally."
+ ),
+ lint_linters = c(
+ "absolute_path_linter",
+ "all_equal_linter",
+ "class_equals_linter",
+ "download_file_linter",
+ "equals_na_linter",
+ "for_loop_index_linter",
+ "length_test_linter",
+ "list_comparison_linter",
+ "nonportable_path_linter",
+ "object_overwrite_linter",
+ "package_hooks_linter",
+ "pipe_return_linter",
+ "redundant_equals_linter",
+ "routine_registration_linter",
+ "sample_int_linter",
+ "seq_linter",
+ "sprintf_linter",
+ "strings_as_factors_linter",
+ "T_and_F_symbol_linter",
+ "terminal_close_linter",
+ "unused_import_linter",
+ "vector_logic_linter"
+ ),
+
fmt("Recognized source control hosting domains used when inferring whether a
package has a source code repository on a recognized hosting platform.
Customize this to add additional git hosting services (e.g., self-hosted
diff --git a/man/figures/badge-dep-lintr-x-flat-square-green.svg b/man/figures/badge-dep-lintr-x-flat-square-green.svg
new file mode 100644
index 0000000..fcb5e18
--- /dev/null
+++ b/man/figures/badge-dep-lintr-x-flat-square-green.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/man/figures/badge-dep-lintr-x-flat-square-red.svg b/man/figures/badge-dep-lintr-x-flat-square-red.svg
new file mode 100644
index 0000000..652fc9c
--- /dev/null
+++ b/man/figures/badge-dep-lintr-x-flat-square-red.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/man/metrics.Rd b/man/metrics.Rd
index 3769bb6..1fbd6c0 100644
--- a/man/metrics.Rd
+++ b/man/metrics.Rd
@@ -27,12 +27,26 @@ For access to \emph{all} the internally calculated data, pass \code{all = TRUE}.
}
\keyword{workflow}
\section{Metrics}{The following metrics are provided by \code{\link{val.meter}}.
+ \subsection{Lint Count}{
+\code{} Count of lints identified by \pkg{lintr}. The set of linters applied is controlled by the \code{lint_linters} option, which defaults to linters from the \emph{correctness}, \emph{common_mistakes}, and \emph{robustness} tags excluding those that require configuration or are environment-dependent. Lower counts indicate higher code quality.
+
+
+
+\Sexpr[stage=install,results=rd]{if (numeric_version(paste0(R.version$major, ".", R.version$minor)) < "4.5.0") { "\\\\ifelse{html}{\\\\figure{badge-best_practice-x-flat-square-blue.svg}{options: alt = \\"[best practice]\\"}}{\\\\strong{[best practice]}}" } else { "\\\\link[val.meter:tags]{\\\\ifelse{html}{\\\\figure{badge-best_practice-x-flat-square-blue.svg}{options: alt = \\"[best practice]\\"}}{\\\\strong{[best practice]}}}" }}
+}
\subsection{Up to date NEWS}{
\code{} a NEWS file exists and is synced with the current package version
+}
+ \subsection{Lint-Free Fraction}{
+\code{} Fraction of R code lines that are free of lints. Code lines are defined as lines containing executable R tokens (excluding comments and blank lines) across the standard package directories (\code{R/}, \code{tests/}, \code{inst/}, \code{vignettes/}, \code{data-raw/}, \code{demo/}, \code{exec/}). A value of 1 means no linted lines; lower values indicate more pervasive lint issues. The set of linters applied is controlled by the \code{lint_linters} option.
+
+
+
+\Sexpr[stage=install,results=rd]{if (numeric_version(paste0(R.version$major, ".", R.version$minor)) < "4.5.0") { "\\\\ifelse{html}{\\\\figure{badge-best_practice-x-flat-square-blue.svg}{options: alt = \\"[best practice]\\"}}{\\\\strong{[best practice]}}" } else { "\\\\link[val.meter:tags]{\\\\ifelse{html}{\\\\figure{badge-best_practice-x-flat-square-blue.svg}{options: alt = \\"[best practice]\\"}}{\\\\strong{[best practice]}}}" }}
}
\subsection{CRAN Reverse Dependencies Count}{
\code{} The number of packages on \acronym{CRAN} that directly depend on this package through \code{Depends}, \code{Imports}, or \code{LinkingTo} fields. This metric reflects adoption within the CRAN ecosystem and indicates how many packages would be affected by breaking changes. Higher counts suggest wider usage and community trust, but also greater responsibility for maintaining backward compatibility.
diff --git a/man/options.Rd b/man/options.Rd
index b861199..de7436b 100644
--- a/man/options.Rd
+++ b/man/options.Rd
@@ -51,6 +51,26 @@ resources (such as download and installation output) and executing code
\item{envvar: }{R_VAL_METER_QUIET (evaluated if possible, raw string otherwise)}
}}
+\item{lint_linters}{\describe{
+Character vector of \code{{lintr}} linter function names used when computing
+\code{lint_count} and \code{lint_free_fraction}. Each name must correspond
+to a linter constructor exported by \code{{lintr}} (e.g.
+\code{"equals_na_linter"}. Override to add, remove, or replace linters.
+Note: linters requiring configuration or environment-specific ones
+(e.g. \code{"backport_linter"}) are excluded from the default set;
+add them manually by changing this option, possibly after configuring
+them globally.\item{default: }{\preformatted{c("absolute_path_linter", "all_equal_linter", "class_equals_linter",
+ "download_file_linter", "equals_na_linter", "for_loop_index_linter",
+ "length_test_linter", "list_comparison_linter", "nonportable_path_linter",
+ "object_overwrite_linter", "package_hooks_linter", "pipe_return_linter",
+ "redundant_equals_linter", "routine_registration_linter",
+ "sample_int_linter", "seq_linter", "sprintf_linter", "strings_as_factors_linter",
+ "T_and_F_symbol_linter", "terminal_close_linter", "unused_import_linter",
+ "vector_logic_linter")}}
+\item{option: }{val.meter.lint_linters}
+\item{envvar: }{R_VAL_METER_LINT_LINTERS (evaluated if possible, raw string otherwise)}
+}}
+
\item{source_control_domains}{\describe{
Recognized source control hosting domains used when inferring whether a
package has a source code repository on a recognized hosting platform.
diff --git a/man/options_params.Rd b/man/options_params.Rd
index 5e588b2..c3a9f18 100644
--- a/man/options_params.Rd
+++ b/man/options_params.Rd
@@ -4,6 +4,15 @@
\alias{options_params}
\title{Options As Parameters}
\arguments{
+\item{lint_linters}{Character vector of \code{{lintr}} linter function names used when computing
+\code{lint_count} and \code{lint_free_fraction}. Each name must correspond
+to a linter constructor exported by \code{{lintr}} (e.g.
+\code{"equals_na_linter"}. Override to add, remove, or replace linters.
+Note: linters requiring configuration or environment-specific ones
+(e.g. \code{"backport_linter"}) are excluded from the default set;
+add them manually by changing this option, possibly after configuring
+them globally. (Defaults to \verb{c("absolute_path_linter", "all_equal_linter", "class_equals_linter", ; "download_file_linter", "equals_na_linter", "for_loop_index_linter", ; "length_test_linter", "list_comparison_linter", "nonportable_path_linter", ; "object_overwrite_linter", "package_hooks_linter", "pipe_return_linter", ; "redundant_equals_linter", "routine_registration_linter", ; "sample_int_linter", "seq_linter", "sprintf_linter", "strings_as_factors_linter", ; "T_and_F_symbol_linter", "terminal_close_linter", "unused_import_linter", ; "vector_logic_linter")}, overwritable using option 'val.meter.lint_linters' or environment variable 'R_VAL_METER_LINT_LINTERS')}
+
\item{tags}{Set the default \code{val.meter} tags policy. Tags characterize the
types of information various metrics contain. For more details, see
\code{\link[=tags]{tags()}}. (Defaults to \code{tags(TRUE)}, overwritable using option 'val.meter.tags' or environment variable 'R_VAL_METER_TAGS')}
diff --git a/tests/testthat/test-data_lints.R b/tests/testthat/test-data_lints.R
new file mode 100644
index 0000000..01d9fbc
--- /dev/null
+++ b/tests/testthat/test-data_lints.R
@@ -0,0 +1,434 @@
+# Helper: create a fake lintr `lints` object with n entries
+create_mock_lints <- function(n = 3) {
+ lint_list <- replicate(n, structure(
+ list(
+ filename = "R/test.R",
+ line_number = 1L,
+ column_number = 1L,
+ type = "warning",
+ message = "mock lint",
+ line = "x = 1",
+ ranges = NULL
+ ),
+ class = "lint"
+ ), simplify = FALSE)
+
+ structure(lint_list, class = c("lints", "list"))
+}
+
+# Helper: create a minimal package source directory for source_code_resource
+create_mock_pkg_dir <- function(dir) {
+ dir.create(file.path(dir, "R"), recursive = TRUE)
+ writeLines(
+ c(
+ "Package: mockpkg",
+ "Version: 1.0.0",
+ "Title: Mock Package",
+ "Description: A mock package for testing.",
+ "Authors@R: person('A', 'B', role = 'aut')",
+ "License: MIT + file LICENSE"
+ ),
+ file.path(dir, "DESCRIPTION")
+ )
+ writeLines("x <- 1", file.path(dir, "R", "mock.R"))
+ invisible(dir)
+}
+
+describe("lint_count derivation from mocked lints", {
+ it("returns 0 when there are no lints", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ create_mock_pkg_dir(tmp_dir)
+
+ with_mocked_bindings(
+ lint_package = function(...) create_mock_lints(0),
+ .package = "lintr",
+ {
+ p <- pkg(
+ source_code_resource(path = tmp_dir, package = "mockpkg"),
+ permissions = permissions("execution")
+ )
+ result <- p$lint_count
+ expect_equal(result, 0L)
+ }
+ )
+ })
+
+ it("returns the correct count matching number of lints", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ create_mock_pkg_dir(tmp_dir)
+
+ with_mocked_bindings(
+ lint_package = function(...) create_mock_lints(7),
+ .package = "lintr",
+ {
+ p <- pkg(
+ source_code_resource(path = tmp_dir, package = "mockpkg"),
+ permissions = permissions("execution")
+ )
+ result <- p$lint_count
+ expect_equal(result, 7L)
+ }
+ )
+ })
+
+ it("returns a non-negative integer", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ create_mock_pkg_dir(tmp_dir)
+
+ with_mocked_bindings(
+ lint_package = function(...) create_mock_lints(4),
+ .package = "lintr",
+ {
+ p <- pkg(
+ source_code_resource(path = tmp_dir, package = "mockpkg"),
+ permissions = permissions("execution")
+ )
+ result <- p$lint_count
+ expect_true(is.numeric(result))
+ expect_true(result >= 0)
+ }
+ )
+ })
+
+ it("stores the full lints object in lints", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ create_mock_pkg_dir(tmp_dir)
+
+ with_mocked_bindings(
+ lint_package = function(...) create_mock_lints(3),
+ .package = "lintr",
+ {
+ p <- pkg(
+ source_code_resource(path = tmp_dir, package = "mockpkg"),
+ permissions = permissions("execution")
+ )
+ lints <- p$lints
+ expect_true(inherits(lints, "lints"))
+ expect_length(lints, 3)
+ }
+ )
+ })
+})
+
+describe("lint_count mock implementation", {
+ it("returns non-negative values", {
+ results <- replicate(50, {
+ p <- random_pkg()
+ p$lint_count
+ })
+
+ expect_true(all(results >= 0))
+ })
+
+ it("returns numeric values consistently", {
+ results <- replicate(20, {
+ p <- random_pkg()
+ p$lint_count
+ })
+
+ expect_true(all(vlapply(as.list(results), is.numeric)))
+ })
+})
+
+describe("find_lintable_files helper", {
+ it("returns empty character vector for package with no R files", {
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ result <- find_lintable_files(tmp_dir)
+ expect_type(result, "character")
+ expect_length(result, 0L)
+ })
+
+ it("finds .R files in R/ directory", {
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ writeLines("x <- 1", file.path(tmp_dir, "R", "a.R"))
+ writeLines("y <- 2", file.path(tmp_dir, "R", "b.R"))
+ result <- find_lintable_files(tmp_dir)
+ expect_length(result, 2L)
+ expect_true(all(grepl("\\.R$", result)))
+ })
+
+ it("finds files in tests/ directory", {
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "tests", "testthat"), recursive = TRUE)
+ writeLines(
+ "test_that('x', {})",
+ file.path(tmp_dir, "tests", "testthat", "test-a.R")
+ )
+ result <- find_lintable_files(tmp_dir)
+ expect_length(result, 1L)
+ })
+
+ it("finds files across multiple package directories", {
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ dir.create(file.path(tmp_dir, "tests"))
+ dir.create(file.path(tmp_dir, "vignettes"))
+ writeLines("x <- 1", file.path(tmp_dir, "R", "code.R"))
+ writeLines("test_that('x', {})", file.path(tmp_dir, "tests", "test.R"))
+ writeLines("# vignette", file.path(tmp_dir, "vignettes", "intro.R"))
+ result <- find_lintable_files(tmp_dir)
+ expect_length(result, 3L)
+ })
+
+ it("respects custom pkg_dirs parameter", {
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ dir.create(file.path(tmp_dir, "tests"))
+ writeLines("x <- 1", file.path(tmp_dir, "R", "code.R"))
+ writeLines("test_that('x', {})", file.path(tmp_dir, "tests", "test.R"))
+ result <- find_lintable_files(tmp_dir, pkg_dirs = "R")
+ expect_length(result, 1L)
+ expect_true(grepl("/R/", result))
+ })
+
+ it("respects custom file_pattern parameter", {
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ writeLines("x <- 1", file.path(tmp_dir, "R", "code.R"))
+ writeLines("---\ntitle: test\n---", file.path(tmp_dir, "R", "doc.Rmd"))
+ # Only .R files
+ result <- find_lintable_files(tmp_dir, file_pattern = "\\.R$")
+ expect_length(result, 1L)
+ expect_true(grepl("\\.R$", result))
+ })
+
+ it("ignores non-existent directories", {
+ tmp_dir <- withr::local_tempdir()
+ # No directories created
+ result <- find_lintable_files(tmp_dir)
+ expect_type(result, "character")
+ expect_length(result, 0L)
+ })
+})
+
+describe("count_file_code_lines helper", {
+ it("counts code lines in a simple R file", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ file_path <- file.path(tmp_dir, "test.R")
+ writeLines(c("x <- 1", "y <- 2", "z <- 3"), file_path)
+ result <- count_file_code_lines(file_path)
+ expect_equal(result, 3L)
+ })
+
+ it("excludes blank lines", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ file_path <- file.path(tmp_dir, "test.R")
+ writeLines(c("x <- 1", "", "", "y <- 2"), file_path)
+ result <- count_file_code_lines(file_path)
+ expect_equal(result, 2L)
+ })
+
+ it("excludes comment-only lines", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ file_path <- file.path(tmp_dir, "test.R")
+ writeLines(c("# comment", "x <- 1", "#' roxygen", "y <- 2"), file_path)
+ result <- count_file_code_lines(file_path)
+ expect_equal(result, 2L)
+ })
+
+ it("returns 0L for empty file", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ file_path <- file.path(tmp_dir, "test.R")
+ writeLines(character(0), file_path)
+ result <- count_file_code_lines(file_path)
+ expect_equal(result, 0L)
+ })
+
+ it("returns 0L for comment-only file", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ file_path <- file.path(tmp_dir, "test.R")
+ writeLines(c("# just", "# comments"), file_path)
+ result <- count_file_code_lines(file_path)
+ expect_equal(result, 0L)
+ })
+})
+
+describe("count_lintable_code_lines helper", {
+ it("returns 0 for a package with no R files", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ result <- count_lintable_code_lines(tmp_dir)
+ expect_equal(result, 0L)
+ })
+
+ it("counts token-bearing non-comment lines across R files", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ # Line 1: code, Line 2: blank (excluded), Line 3: code
+ writeLines(c("x <- 1", "", "y <- 2"), file.path(tmp_dir, "R", "a.R"))
+ # Line 1: code
+ writeLines(c("z <- 3"), file.path(tmp_dir, "R", "b.R"))
+ result <- count_lintable_code_lines(tmp_dir)
+ expect_equal(result, 3L)
+ })
+
+ it("excludes comment-only lines", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ # 2 code lines, 2 comment lines, 1 blank
+ writeLines(
+ c("# This is a comment", "x <- 1", "", "#' roxygen", "y <- 2"),
+ file.path(tmp_dir, "R", "a.R")
+ )
+ result <- count_lintable_code_lines(tmp_dir)
+ expect_equal(result, 2L)
+ })
+
+ it("includes tests/ directory in scope", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ dir.create(file.path(tmp_dir, "R"))
+ dir.create(file.path(tmp_dir, "tests", "testthat"), recursive = TRUE)
+ writeLines("x <- 1", file.path(tmp_dir, "R", "a.R"))
+ writeLines("test_that('works', { expect_true(TRUE) })",
+ file.path(tmp_dir, "tests", "testthat", "test-a.R"))
+ result <- count_lintable_code_lines(tmp_dir)
+ # 1 from R/, 1 from tests/
+ expect_equal(result, 2L)
+ })
+})
+
+describe("count_linted_lines helper", {
+ it("returns 0 for empty lints", {
+ result <- count_linted_lines(create_mock_lints(0))
+ expect_equal(result, 0L)
+ })
+
+ it("deduplicates lines with multiple lints", {
+ lints_same_line <- structure(
+ list(
+ structure(
+ list(filename = "R/a.R", line_number = 5L, column_number = 1L,
+ type = "warning", message = "lint 1", line = "x", ranges = NULL),
+ class = "lint"
+ ),
+ structure(
+ list(filename = "R/a.R", line_number = 5L, column_number = 3L,
+ type = "warning", message = "lint 2", line = "x", ranges = NULL),
+ class = "lint"
+ ),
+ structure(
+ list(filename = "R/a.R", line_number = 6L, column_number = 1L,
+ type = "warning", message = "lint 3", line = "y", ranges = NULL),
+ class = "lint"
+ )
+ ),
+ class = c("lints", "list")
+ )
+ result <- count_linted_lines(lints_same_line)
+ expect_equal(result, 2L)
+ })
+})
+
+describe("lint_free_fraction derivation", {
+ it("returns 1 when there are no lints", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ create_mock_pkg_dir(tmp_dir)
+
+ with_mocked_bindings(
+ lint_package = function(...) create_mock_lints(0),
+ .package = "lintr",
+ {
+ p <- pkg(
+ source_code_resource(path = tmp_dir, package = "mockpkg"),
+ permissions = permissions("execution")
+ )
+ result <- p$lint_free_fraction
+ expect_equal(result, 1.0)
+ }
+ )
+ })
+
+ it("returns a value in [0, 1]", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ create_mock_pkg_dir(tmp_dir)
+
+ with_mocked_bindings(
+ lint_package = function(...) create_mock_lints(2),
+ .package = "lintr",
+ {
+ p <- pkg(
+ source_code_resource(path = tmp_dir, package = "mockpkg"),
+ permissions = permissions("execution")
+ )
+ result <- p$lint_free_fraction
+ expect_true(is.numeric(result))
+ expect_true(result >= 0.0 && result <= 1.0)
+ }
+ )
+ })
+})
+
+describe("lint_free_fraction mock implementation", {
+ it("returns values in [0, 1]", {
+ results <- replicate(50, {
+ p <- random_pkg()
+ p$lint_free_fraction
+ })
+
+ expect_true(all(results >= 0.0 & results <= 1.0))
+ })
+
+ it("returns numeric values consistently", {
+ results <- replicate(20, {
+ p <- random_pkg()
+ p$lint_free_fraction
+ })
+
+ expect_true(all(vlapply(as.list(results), is.numeric)))
+ })
+})
+
+describe("lint_linters option customization", {
+ it("uses the linter names from the option", {
+ skip_if_not_installed("lintr")
+ tmp_dir <- withr::local_tempdir()
+ create_mock_pkg_dir(tmp_dir)
+
+ called_with <- NULL
+ old <- opt_set("lint_linters", c("equals_na_linter", "seq_linter"))
+ on.exit(opt_set("lint_linters", old))
+
+ with_mocked_bindings(
+ lint_package = function(..., linters) {
+ called_with <<- names(linters)
+ create_mock_lints(0)
+ },
+ .package = "lintr",
+ {
+ p <- pkg(
+ source_code_resource(path = tmp_dir, package = "mockpkg"),
+ permissions = permissions("execution")
+ )
+ p$lints
+ }
+ )
+
+ expect_equal(sort(called_with), c("equals_na_linter", "seq_linter"))
+ })
+
+ it("can restrict to a subset of linters", {
+ old <- opt_set("lint_linters", c("equals_na_linter"))
+ on.exit(opt_set("lint_linters", old))
+
+ linters <- opt("lint_linters")
+ expect_equal(linters, "equals_na_linter")
+ expect_length(linters, 1L)
+ })
+})