Mix.Statifier.RegressionRegistry (Statifier v2.3.0)

Copy Markdown View Source

Reads and writes test/passing_tests.json, the regression ratchet registry.

The registry names the tests that must always pass. It has three lists, one per suite, and entries may be literal paths or globs:

{
  "description": "...",
  "internal_tests": ["test/statifier/**/*_test.exs"],
  "last_updated": "2026-08-02",
  "scion_tests": [],
  "w3c_tests": []
}

mix test.regression runs exactly what this file expands to; mix test.baseline is the only supported way to grow it. This module holds the pure part of both tasks - load, expand, categorize, add, encode - so the tasks themselves stay thin wrappers around System.cmd/3.

Encoding is deliberately hand-rolled rather than delegated to a JSON pretty printer: keys are sorted and arrays are one entry per line, so ratcheting a test in produces a one-line diff.

Summary

Functions

Adds paths to the registry, dated today.

The suite a test file belongs to, decided by its path.

The conformance suites, the only categories the ratchet grows by hand.

Every test file on disk for a conformance category, sorted.

How much of category's emitted corpus passing covers.

Path of the registry file, relative to the project root.

Encodes registry as JSON with sorted keys, two-space indent, and one array entry per line, so that ratcheting in a test is a minimal diff.

Expands the patterns held under category into test files.

Expands a list of literal paths and globs into existing test files.

Every test file the registry expands to, across all categories.

The registry key holding category.

Loads the registry from path.

Writes registry back to path, pretty-printed.

One report line per conformance category, plus the denominator caveat.

Directory holding the corpus for a conformance category.

Human-readable name of a conformance suite.

mix test arguments that make paths runnable.

Types

category()

@type category() :: :internal | :scion | :w3c

stats()

@type stats() :: %{
  passing: non_neg_integer(),
  total: non_neg_integer(),
  percent: float() | nil
}

t()

@type t() :: %{required(String.t()) => term()}

Functions

add(registry, paths, today)

@spec add(registry :: t(), paths :: [String.t()], today :: Date.t()) ::
  {t(), [String.t()], [String.t()]}

Adds paths to the registry, dated today.

Returns {registry, added, skipped}. Internal tests are skipped: they are covered by globs already, so listing them individually would only add churn. Entries are deduplicated and sorted, and the ratchet never removes anything.

categorize(path)

@spec categorize(path :: String.t()) :: category()

The suite a test file belongs to, decided by its path.

Examples

iex> Mix.Statifier.RegressionRegistry.categorize("test/scion_tests/basic/basic0_test.exs")
:scion

iex> Mix.Statifier.RegressionRegistry.categorize("test/statifier/document_test.exs")
:internal

conformance_categories()

@spec conformance_categories() :: [category()]

The conformance suites, the only categories the ratchet grows by hand.

corpus_files(category, root \\ ".")

@spec corpus_files(category :: category(), root :: String.t()) :: [String.t()]

Every test file on disk for a conformance category, sorted.

Used by mix test.baseline to find candidates the registry does not know about yet. root prefixes the suite directory; it defaults to the project root and exists so the tasks can be pointed at a fixture tree under test.

corpus_stats(passing, category, root \\ ".")

@spec corpus_stats(
  passing :: [String.t()],
  category :: category(),
  root :: String.t()
) :: stats()

How much of category's emitted corpus passing covers.

total counts the test files on disk under the suite directory, which is the only denominator the ratchet can reach 100% of - cases excluded at generation time (tools/corpus/*/exclusions.exs) never emit a file. The numerator is the intersection of passing with those files, never a plain length, because a registry entry may be a glob or may name a path outside the suite directory. percent is nil when the corpus is empty, so callers report "no files" rather than dividing by zero.

default_path()

@spec default_path() :: String.t()

Path of the registry file, relative to the project root.

encode(registry)

@spec encode(registry :: t()) :: String.t()

Encodes registry as JSON with sorted keys, two-space indent, and one array entry per line, so that ratcheting in a test is a minimal diff.

Examples

iex> Mix.Statifier.RegressionRegistry.encode(%{"scion_tests" => ["a.exs"], "n" => 1})
~s|{\n  "n": 1,\n  "scion_tests": [\n    "a.exs"\n  ]\n}\n|

expand(registry, category)

@spec expand(registry :: t(), category :: category()) :: {[String.t()], [String.t()]}

Expands the patterns held under category into test files.

Returns {files, missing}, where missing lists entries that matched nothing on disk. A registry entry that no longer exists is a hole in the ratchet, so callers report it rather than skipping it.

expand_patterns(patterns)

@spec expand_patterns(patterns :: [String.t()]) :: {[String.t()], [String.t()]}

Expands a list of literal paths and globs into existing test files.

Returns {sorted_unique_files, patterns_that_matched_nothing}.

Examples

iex> Mix.Statifier.RegressionRegistry.expand_patterns(["test/no_such_test.exs"])
{[], ["test/no_such_test.exs"]}

files(registry)

@spec files(registry :: t()) :: {[String.t()], [String.t()]}

Every test file the registry expands to, across all categories.

Returns {files, missing} with the same meaning as expand/2.

key(category)

@spec key(category :: category()) :: String.t()

The registry key holding category.

Examples

iex> Mix.Statifier.RegressionRegistry.key(:scion)
"scion_tests"

load(path \\ "test/passing_tests.json")

@spec load(path :: String.t()) :: {:ok, t()} | {:error, String.t()}

Loads the registry from path.

Returns {:error, reason} rather than raising: both tasks report the reason and exit non-zero, and a malformed registry must never be mistaken for an empty one.

save(registry, path)

@spec save(registry :: t(), path :: String.t()) :: :ok | {:error, String.t()}

Writes registry back to path, pretty-printed.

stats_lines(passing, categories, root \\ ".")

@spec stats_lines(
  passing :: [String.t()],
  categories :: [category()],
  root :: String.t()
) :: [
  String.t()
]

One report line per conformance category, plus the denominator caveat.

Each per-category line reads " LABEL: passing/total (percent%)", with labels padded so the counts line up. A category whose total is 0 is omitted entirely. Callers prefix these lines with their own header (the numerator's meaning differs between a scan and a ratchet run, so the header text is not this function's job) and print the block as-is otherwise. Returns [] - no lines, no caveat - when no category has any emitted files, so a fixture tree with no corpus prints nothing at all.

suite_dir(category)

@spec suite_dir(category :: category()) :: String.t() | nil

Directory holding the corpus for a conformance category.

Examples

iex> Mix.Statifier.RegressionRegistry.suite_dir(:w3c)
"test/scxml_tests"

suite_label(category)

@spec suite_label(category :: category()) :: String.t() | nil

Human-readable name of a conformance suite.

Examples

iex> Mix.Statifier.RegressionRegistry.suite_label(:scion)
"SCION"

iex> Mix.Statifier.RegressionRegistry.suite_label(:internal)
nil

test_args(paths)

@spec test_args(paths :: [String.t()]) :: [String.t()]

mix test arguments that make paths runnable.

Conformance suites are excluded by default, so their tag has to be included explicitly.

Examples

iex> Mix.Statifier.RegressionRegistry.test_args(["test/scion_tests/basic/basic0_test.exs"])
["--include", "scion"]