ExQuality.Scope (ExQuality v0.13.0)
View SourceResolves how much code a stage runs over, as opposed to which stages run.
Every skip switch this library has narrows stages. An agent iterating on one file does not want fewer checks, it wants the same checks over less code: on a 3,700-test umbrella the suite is 68% of a run's wall clock, and a one-file change needs a handful of test files rather than all of them.
A scope is one of:
:all- everything, which is what an unscoped run has always done:changed- the test files that map to the files changed against a base ref- a glob string - the test files matching it
The one failure mode that matters
A scoped run that resolves to no files must never report green, because it
fails in the safe-looking direction: mix quality exits 0, the report says
"status": "ok", and nothing ran. So an empty resolution is not an empty run,
it falls back to the full suite and says why in fallback_reason.
For the same reason resolve/2 reports the scope it achieved, not the one it
was asked for. A run that fell back reports scope: :all with the request
kept in requested_scope, so a caller that refuses to move a baseline on a
scoped run does not have to reason about fallbacks.
Uncommitted work counts
An agent mid-task has everything uncommitted, so a diff that reads only
committed history reports no changes on exactly the runs this exists for.
:changed reads the working tree against the merge base with the base ref,
and folds in untracked files.
Example
ExQuality.Scope.resolve(:changed, base_ref: "origin/main")
#=> %{
#=> scope: :changed,
#=> requested_scope: :changed,
#=> files: ["test/user_test.exs"],
#=> base_ref: "origin/main",
#=> fallback_reason: nil
#=> }
Summary
Functions
Returns the ref :changed is measured against when nothing names one.
Renders a scope for the report and for human output.
Returns the scope a loaded config asks the test stage for.
Parses a scope written by a human, in .quality.exs or on the command line.
Resolves a scope to the test files to run.
Maps changed files to the test files that cover them.
Types
@type resolved() :: %{ scope: :all | :changed | {:glob, String.t()}, requested_scope: t(), files: :all | [String.t()], base_ref: String.t() | nil, fallback_reason: String.t() | nil }
What a scope resolved to.
files is :all for a full suite, which is not the same as []: an empty
list would be a run of nothing.
@type t() :: :all | :changed | {:glob, String.t()}
A requested scope.
Functions
@spec default_base_ref() :: String.t() | nil
Returns the ref :changed is measured against when nothing names one.
The repository's own origin/HEAD is preferred, because a repository whose
trunk is neither main nor master has still recorded which one it is.
Returns nil outside a git repository, which resolve/2 turns into a full
suite rather than a failure.
Renders a scope for the report and for human output.
iex> ExQuality.Scope.describe(:all)
"all"
iex> ExQuality.Scope.describe({:glob, "test/unit/**"})
"test/unit/**"
Returns the scope a loaded config asks the test stage for.
Raises when the config names something that is not a scope, rather than
falling back to :all: a typo that silently ran everything would be slow, and
one that silently ran nothing would be a green run of no tests.
iex> ExQuality.Scope.from_config([])
:all
iex> ExQuality.Scope.from_config(test: [scope: :changed])
:changed
Parses a scope written by a human, in .quality.exs or on the command line.
Anything that is not all or changed is a glob, because a glob is the only
one of the three that has to carry a value.
iex> ExQuality.Scope.parse("changed")
{:ok, :changed}
iex> ExQuality.Scope.parse(:all)
{:ok, :all}
iex> ExQuality.Scope.parse("test/unit/**/*_test.exs")
{:ok, {:glob, "test/unit/**/*_test.exs"}}
iex> ExQuality.Scope.parse(42)
{:error, "test scope must be :all, :changed or a glob string, got: 42"}
Resolves a scope to the test files to run.
Options
:base_ref- what:changedis measured against (default: the repository's default branch, seedefault_base_ref/0)
Maps changed files to the test files that cover them.
A changed test file is itself. A changed source file is the test file beside
it, lib/foo/bar.ex to test/foo/bar_test.exs, which works unchanged in an
umbrella because apps/web/lib/foo.ex maps under apps/web/test/.
Only files that exist are returned. Everything else - a changed mix.exs, a
source file with no test - contributes nothing, and contributing nothing is
what makes resolve/2 fall back to the full suite.
iex> ExQuality.Scope.test_files(["mix.exs"])
[]