Changelog
View SourceAll notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.13.0 - 2026-08-02
Changed
- Stage status lines carry colour: green on a passing stage's tick and name, dim on a skipped line, and the failure line unchanged because it already rendered in red. Colour is a second channel over the
✓/✗/○, never a replacement for it, so anything reading the output without a terminal still reads the status off the glyph. Both shells format throughIO.ANSI, which drops the codes when stdout is not a terminal, so CI logs, piped runs and--format jsonare unchanged and there is no flag to turn it off - The two identical renderers for stage results are now one.
ExQuality.Printer.print_result/1prints straight to the shell when no printer agent is running, which is all the sequential format and compile phases needed from it, somix qualityno longer carries its own copy of the same three clauses
Fixed
- The guide links on the hex.pm package page reached the guides rather than their markdown source. hex.pm renders the README from the tarball and resolves relative paths against it, and
docs/ships in the package, sodocs/configuration.mdresolved to the file. The README links to HexDocs by absolute URL now. Links between the guides stay relative, which is what ExDoc resolves
[0.12.0] - 2026-08-01
Added
test: [scope: :changed], or--test-scope changed, runs only the test files covering the code that changed. Every flag this library had narrows stages, and the expensive question is how much code. Measured on a large umbrella that adopted it: across a run of agentic developmentmix qualityaveraged 61.7s, of which the suite was 68%, while the same agent could run a single test file in about 3s.--quickdoes not address that - it removes dialyzer and the coverage threshold and still runs every test, which against that split is 10% of the cost. Changed files are resolved against the merge base with the repository's default branch, including uncommitted and untracked work, because an agent mid-task has everything uncommitted and a diff that reads only committed history would report no changes on exactly the runs this exists for.lib/foo/bar.exmaps totest/foo/bar_test.exs, in an umbrella under the same app, and a changed test file is itself.--test-scopealso takesallor a glob string, andtest: [base_ref: "origin/main"]overrides what:changedis measured against- A scope that resolves to no test files runs the full suite. This is the one failure mode that would make the feature worse than not having it, because it fails in the safe-looking direction: exit 0,
"status": "ok", nothing run. The report says which happened, carrying the achievedscopewithrequested_scopeandfallback_reasonbeside it, so a caller checkingscope == "all"never has to reason about fallbacks - Coverage on a scoped run is absent rather than lower. It is reported as
"coverage": "skipped"with a reason and never as a number: a percentage over a subset of the suite is not a smaller truth, it is a different and misleading one, and an adopting project that lowers a recorded figure on a green run would corrupt it against a run that never measured - Profiles.
profiles:in.quality.exsnames bundles of options, selected withmix quality --profile loop, so the fast path has a name a project's docs and its agent instructions can point at - a fast path nobody invokes is worth nothing. A profile'sstages:key is an allow-list; every other stage, built-in or custom, is reported as skipped naming the profile. The profile merges over the config file and under the CLI. An unknown name fails the run, because falling back to "run everything" would turn a typo into a slow green --until-first-failureruns one stage at a time, cheapest first, and stops at the first failure. An iterating caller does not need a full battery report, it needs the next thing to fix, and a run that pays for the suite before saying "you have a formatting error" is the opposite of thattest: [coverage: false]was already config-settable; profiles make it settable per profile, so a run can drop instrumentation while keeping dialyzer, which previously required taking all of--quick. Measured on a 3,700-test umbrella, warm build, 15 cores: 3.3s of 47s wall clock, but +46% user time, so the wall cost moves toward the CPU figure where cores are scarce--report -writes the report to stdout, as--format jsondoes. Agents were parsing human terminal text because a file path is one more stepprofile,scopeandbase_refat the report root, always present.statusalone does not say what a run is evidence for: a green run over three test files and a green full run are different claims, and this is what lets anything that ratchets a number, moves a baseline or gates a merge refuse to move on the narrow one- The Tests stage reports
scope,files,test_files,base_refand the coverage keys above in its own object ExQuality.Scope, which owns scope parsing and resolutionExQuality.Config.apply_profile/2andExQuality.Config.profile/1- A stage result may carry
meta, a map of extra report fields describing what the stage did rather than what it found
Changed
- Format, Compile and Tests can now be turned off by
--skip <key>or by a profile'sstages:list, and are reported as skipped with the reason like any other stage. They have never been skippable before, and an unprofiled run with no--skipstill runs all three unconditionally - A run with no
--profile, no--test-scopeand no--until-first-failurebehaves exactly as it did in 0.11.0. This is a 0.x library with real users, and the default path does not move
0.11.0 - 2026-08-01
Fixed
- A skipped custom stage's reason is no longer whatever the toolchain printed first. The reason a person reads was the command's first non-empty line, which made it hostage to output the command did not write:
mixemits==> appheaders for an umbrella and a build-lock notice when another stage holds the lock, either of which turnedskipped (the database has no tables - run bin/test-setup)intoskipped (==> admin). That defeats the point ofskip_exit_code:, which exists to turn a confusing failure into a reason somebody can act on. The document'ssummaryis now preferred when the command wrote one, falling back to the first line for a command that prints prose or setsparse: :none - A custom stage's
command:may name a project's own script.System.cmd/3resolves a bare name on the PATH and otherwise wants an absolute path - even./bin/check.shraises:enoent- so a repo-relative script, which is the ordinary case for a custom stage, had to be written ascommand: "bash", args: ["bin/check.sh"]. A command containing/is now expanded before it runs, relative tocd:when the entry names one and to the project root otherwise, so an entry reads as the shell it looks like. A bare name still goes to the PATH and an absolute path is used as it stands
0.10.0 - 2026-08-01
Added
compile: [force: true]compiles dev and test with--force, so a local run matches the cold build CI does. The usual argument for--force- that a warm build hides warnings - is obsolete, since Elixir re-emits persisted diagnostics for unchanged files. One class does survive: remove a public function and its callers are not recompiled, because a remote call is not a compile-time dependency, so nothing warns locally and CI fails. Off by default, since it costs a full recompile of the project's own apps on every run and is redundant in CI. The success summary names it (dev + test compiled (forced, warnings as errors)), and a forced run that passes stays as quiet as an incremental one
0.9.0 - 2026-08-01
Added
- Credo can run more than one config. A
.credo.exsthat declares a second config - the usual case is one check overpriv/*/migrations/, which sits outside credo's defaultfiles.included- had that config silently never run, because the stage always invokedmix credoonce with no--config-name. A check the project believes is enforced, was not.credo: [configs: ["default", "migrations"]]now runs one invocation per name, in order, and merges the results into one stage result - Findings from multiple credo configs are deduplicated on
{file, line, column, check}, so two configs with overlappingfilesglobs do not double an issue count - A credo run that fails without reporting issues now names the config it came from (
Check failed in "migrations" (see output)), because the likeliest cause is a config name.credo.exsdoes not define - Custom stages. A project with a house check, a schema linter, a custom mix task or a shell script gate could have ExQuality's parallelism, timing, report and printer, or it could have its own check, but not both - so those checks lived outside the run, outside the JSON report, and outside anything that routes findings to fixers.
custom:in.quality.exsregisters them as stages. An entry names either amodule:implementing the existingExQuality.Stagecontract, or acommand:run by the newExQuality.Stages.Command - A custom command reports structured findings by printing one JSON document on stdout (
summary,stats,findings); onlyfileandmessageare required per finding, andappis inferred from the path. Output that does not parse falls through verbatim, as everywhere else.parse: :noneopts out for a command known to print prose skip_exit_code:lets a custom command say "not applicable" - a nullability check needs a migrated test database, and without it the stage fails with a database error that reads like a code problem. The stage reports:skippedwith the command's own reason instead--skip <key>skips any stage by key, built-in or custom, and is repeatable.@switchesis static, so a custom stage could never have a--skip-<key>generated for it. The existing--skip-credoand friends are unchangedExQuality.Finding.from_map/2, the reading half of the report's finding encoding. It is what a custom command's output is decoded with, and what a consumer of a report needs in order to read one back
Changed
- Malformed
custom:entries fail the run at load time and name the offending entry: a missingkeyorname, neither or both ofmodule/command, a key or name colliding with a built-in stage, a duplicate key, an unknownkind, or a module that is not loadable or does not exportrun/1. A stage that never registers is a check nobody is told is not running
0.8.0 - 2026-08-01
Fixed
- Dialyzer could report
✓ No warningson a project with 110 real warnings. The Gettext stage ranmix gettext.extract --merge, which recompiles the project, alongside the analysis stages that read the same build. Dialyzer, invoked with--no-compile, hitCould not get Core Erlang codeand produced nothing, and the debug_info escape hatch promoted that to a pass - Dialyzer now separates "ran and found nothing" from "never ran" by whether dialyxir printed its own
Total errors:tally, and reports the second as a failure (Analysis did not complete (build changed under it? see output)) rather than a pass - The Gettext stage found no
.pofiles in an umbrella and reportedAll translations completehaving read nothing. It now looks under every child app as well as the root, and reports:skippedwith the reason when it examined no files - The Gettext stage rejected every path containing
/en/and everyerrors.poinvisibly, so a project whose only locale wasenhad every file filtered out and still went green - Dialyzer findings in an umbrella carried
app: nulland an app-relativefilethat did not open from the umbrella root. Each path is now resolved against the child apps by existence; an ambiguous path is left alone rather than guessed at - A failing test suite came back with no findings and the whole run log in
output. Each ExUnit failure is now parsed into a finding withfile,line,app, the test module and the assertion message
Changed
- The Gettext stage no longer writes to your repository.
mix gettext.extract --mergerewrote.pot/.pofiles and left the dev build inconsistent enough that the nextmix compile --warnings-as-errorsfailed. It is now opt-in withgettext: [extract: true], and the stage reads the committed files as they stand - Gettext reports
%ExQuality.Finding{}structs with app attribution rather than a hand-built prose string, so its failures route like every other stage's - Gettext's source locale is configurable (
gettext: [source_locale: "en"]), as is the excluded basename list (gettext: [exclude: ["errors.po"]])
Added
- Mix alias detection. ExQuality shells out to the real
mix credo,mix dialyzer,mix format,mix sobelow,mix deps.unlockandmix test.coverage, and Mix resolves aliases before tasks. A project that aliases one of those names silently changed what the stage measured:mix sobelowran the alias, ignored every switch and wrote no report;mix test.coverageran the entire suite a second time before aggregating. Those stages now refuse to run and name the alias (✗ Sobelow: mix sobelow is aliased in mix.exs).mix testis the deliberate exception, since atest:alias does the setup the suite needs ExQuality.Aliases, withshadowing?/1andshadowed/2- Stages declare whether they read the build or write to it (
stage_kind/1,ExQuality.Stage.kind/2). Writers run serialized before the parallel readers, the way compilation already gates the run - A project mark under
assets/: an SVG with a transparent surround, so it reads on a light or a dark background, plus PNG renders from 512 down to 32. It is the README's header, and the published docs' logo and favicon. The assets are not shipped in the package: HexDocs is built from the checkout at publish time, so nothing downloading the dependency needs them
0.7.0 - 2026-08-01
Added
ExQuality.Finding, a structured representation of a single actionable problem (file, line, column, severity, check, message, and the raw tool output it came from)Stage results may carry
findings;ExQuality.Stage.findings/1reads themCredo parses its issues into findings, and failure output renders them grouped by file and sorted by line
Stages without a parser, and output that does not parse, still print in full: findings never replace output they did not account for
Every stage that is considered and not run now prints a line saying so, with the reason (
○ Dialyzer: skipped (--quick),○ Doctor: skipped (:doctor not installed),○ Credo: skipped (disabled in .quality.exs))ExQuality.Config.skip_reason/2andExQuality.Stage.skipped/2ExQuality.Umbrella, which answers which child apps exist, where they live, and what they declareFindings carry the umbrella app their file belongs to, and rendered output is grouped by app
Test failures name the apps they came from (
3 of 4,180 failed (web: 3))mix quality --report PATHwrites a JSON report of the run, and--format jsonputs it on stdout with the human output on stderr. A caller can route on which stage failed and on its findings instead of scraping the console or re-running the toolExQuality.Report, which builds that report from the same results the human output is rendered fromA Sobelow security stage, auto-enabled on
:sobelow, with--skip-sobelowto turn it off. Findings at or above the project'sexit:threshold block the run and are rendered; the rest are reported as a count (2 blocking findings (1 high, 1 medium), 3 informational not shown) and shown under--verboseorsobelow: [show_informational: true]The Sobelow stage runs once per Phoenix child app in an umbrella, where
mix sobelowalone finds nothing to scan, and tags each finding with its appExQuality.Umbrella.app_deps/0, the child dependencies keyed by appCoverage without ExCoveralls: a project that sets
test_coverage: [summary: [threshold: N]]is measured with Elixir's ownmix test --cover. In an umbrella the run exports per app and aggregates withmix test.coverage, so a module exercised by another app's tests no longer reads as 0%A failing coverage check reports the modules under the threshold as findings, with their source files, instead of the whole per-module table
test: [coverage: true | false]in.quality.exs, to measure coverage in a project that states no threshold, or to never measure it:native_coverageinExQuality.Tools.detect/0, true when:excoverallsis absentmix quality.plt, which builds the Dialyzer PLT outside a run so a container image or CI job can cache it instead of paying for it inside a checkA run that has to build the PLT says so while it happens (
⋯ Dialyzer: building PLT (this is a one-time cost)), instead of a multi-minute wait behind a stage that prints one line at the end, and reports it afterwards (No warnings (PLT built this run),stats.plt_built)ExQuality.Plt, which recognises PLT work in dialyxir's outputExQuality.OutputCollector.new/1takes an:on_linehandler, called with each line of a command's output as it arrivesExQuality.Json, which reads a JSON document out of output a compiler or a tool also wrote toGuides under
docs/, shipped with the package and published with the docs: configuration, stages, reports, umbrella projects, and CI and pre-commitExQuality.Finding.relative_path/1, which normalises a path a tool reported into one relative to the run's root
Changed
- The README leads with the output contract - a passing stage costs one line, every stage the run considered is reported with its reason, a failure renders as findings with
file:line- instead of a feature list, and the reference detail it carried moves intodocs/. The comparison table is gone: it made unverifiable claims about other tools that would rot usage-rules.mdis organised around what an agent has to decide: which command to run, what each line shape means, what to do about each stage's findings, and the fixes that are never acceptable. The anti-fixes are collected in one place rather than scattered, and cover skipping a failing test, adding a--skip-*flag and weakening a check, not just coverage and Sobelow thresholds- The package description names the tools, the output and the audience, so it is findable by what someone would search for on Hex
- A passing run and
mix quality.initend with✓rather than✅, matching the stage lines :doctormoves to~> 0.23, which requiresdecimal ~> 3.1and clears GHSA-rhv4-8758-jx7v, an unbounded exponent inDecimal.newthat enables unauthenticated denial of service.:jasonmoves to 1.4.5, the first release whose optional:decimalrequirement admits 3.x- Dialyzer runs with
--format short --format dialyxir. Each warning's one-line form becomes a finding naming the warning (no_return,pattern_match), and the warning count is the number of them instead of a count of lines shaped likefile.ex:12:, which also counted PLT chatter and any explanation that named a second file. dialyxir's long explanation of each warning is still printed, so it stays in the stage's output and in the JSON report - The security audit runs
mix deps.audit --format json. Each vulnerability becomes a finding against the lockfile, naming the advisory, the version in use and the version that fixes it, instead of being counted by searching the human output forAdvisory:andseverity: high. Unused dependencies become findings too.stats.high_severityand its siblings are replaced bystats.vulnerabilities_by_severity - Credo runs with
--format json. Findings name the check that produced them (Credo.Check.Readability.ModuleDoc) instead of its category, the issue count is the number of issues rather than a summary-line parse, and an issue that credo reported is never dropped for having an unfamiliar line shape. A stage summary now reads5 issues (2 readability, 3 design)
Fixed
Findings report a path relative to the run's root. mix_audit reports an absolute lockfile, so a vulnerability rendered as
/Users/someone/code/app/mix.lock: longer to read, not what a reader would type, different between a laptop and CI for the same problem, and comparing as a different finding. It is the same baseExQuality.Umbrella.app_for_path/2matches, so a finding'sfileand itsappcan no longer disagree about where it is. A path outside the project root stays absoluteA run that stops at a compile error now reports the analysis stages it never reached as skipped, instead of saying nothing about them
A disabled or uninstalled stage no longer vanishes from the output, where it read as a stage that passed
Tool auto-detection reads every umbrella child app's dependencies, not just the root's. An umbrella root usually declares no deps, so credo, dialyzer and friends were reported as not installed and a run that checked almost nothing passed
Test statistics sum every app's summary line instead of reporting the first app's numbers as the whole suite's
Coverage reads every
[TOTAL]line; when apps are measured separately the lowest leads, with the per-app numbers alongside itThe coverage threshold is read from
coveralls.json'scoverage_options, where excoveralls actually writesminimum_coverage. Only the top level was looked at, so a project configuring it the standard way had no threshold enforcedAn integer threshold (
minimum_coverage: 70) no longer crashes the summaryThe Format stage reports a failing
mix formatinstead of discarding its exit code. A file with a syntax error names no.exfile to count, so the first line of the run was a green tick on a broken fileThe Format stage reports a project with no
.formatter.exsas skipped, rather than failing the run over a config file it never had.quality.exsis read from the project root instead of the working directory, and an umbrella child with no file of its own now reads the umbrella root's
0.6.0 - 2026-04-05
Fixed
- Output full, non-truncated content when errors occur.
0.5.0 - 2026-02-23
Changed
- Condensed
usage-rules.mdby removing first-time setup, auto-detection, and detailed configuration sections that aren't needed for day-to-day LLM usage
0.4.0 - 2026-02-23
Fixed
- Include
:jasonas a runtime dependency instead of dev/test only, fixing compilation warnings in host projects - Pass
--no-compiletomix dialyzerto avoid race conditions with parallel analysis stages competing over_build/dev - Mark tests using
File.cd!asasync: falseto prevent intermittent compilation failures from global working directory changes
Changed
- Updated
usage-rules.mdto instruct LLMs not to truncatemix qualityoutput
0.3.0 - 2026-02-03
Added
- Test options pass-through: Pass extra arguments to
mix testormix coveralls:- Via CLI using
--separator:mix quality --quick -- --only integration - Via config file:
test: [args: ["--only", "integration"]] - CLI args override config file args (no merge)
- Supports any test flags:
--only,--include,--exclude,--seed, etc.
- Via CLI using
0.2.0 - 2026-01-09
Added
mix quality.initnow automatically configures ExCoveralls inmix.exswhen coverage is selected:- Adds
test_coverage: [tool: ExCoveralls]to project configuration - Adds
preferred_cli_envsettings for all coveralls commands - Smart detection prevents duplicate configuration
- Properly indents to match existing project style
- Adds
0.1.0 - 2026-01-09
Added
- Initial release of ExQuality (formerly Quality)
- Three-phase execution pipeline:
- Phase 1: Auto-fix (format)
- Phase 2: Compilation (dev + test in parallel)
- Phase 3: Parallel analysis with streaming output
- Quality stages:
- Format: Auto-fixes code formatting with
mix format - Compile: Compiles dev + test environments in parallel with warnings as errors
- Credo: Static analysis with
--strictmode (configurable) - Dialyzer: Type checking with graceful PLT handling
- Doctor: Documentation coverage checking
- Gettext: Translation completeness validation
- Test: Test suite with optional coverage via excoveralls
- Format: Auto-fixes code formatting with
- Quick mode (
--quick):- Skips dialyzer (slow type checking)
- Skips coverage enforcement (tests run, % not checked)
- Perfect for rapid iteration during development
- Auto-detection system:
- Automatically enables stages based on installed dependencies
- No configuration needed for standard setups
- Configuration system:
- 4-tier precedence: Defaults → Auto-detection →
.quality.exs→ CLI flags - Project-level customization via
.quality.exs - Per-stage enable/disable controls
- CLI flags for runtime overrides
- 4-tier precedence: Defaults → Auto-detection →
- Streaming output:
- Results display as each stage completes
- No interleaving (serialized via
Quality.Printer) - Fast stages provide immediate feedback
- Actionable feedback:
- Full tool output preserved in failure details
- File:line references for easy navigation
- Works for both humans and LLM coding assistants
- CLI options:
--quick- Fast mode for iterative development--skip-dialyzer- Skip Dialyzer type checking--skip-credo- Skip Credo static analysis--skip-doctor- Skip Doctor documentation checks--skip-gettext- Skip Gettext translation checks--verbose- Show full output even on success
- Documentation:
- Comprehensive README with workflow examples
usage-rules.mdfor LLM integration- Example
.quality.exsconfiguration file
- Coverage threshold:
- Single source of truth (reads from coveralls config)
- Respects
coveralls.jsonormix.exssettings - No duplicate configuration needed
Technical Details
- Zero runtime dependencies
- Optional dev dependencies: credo, dialyxir, doctor, excoveralls, gettext
- Parallel execution using Elixir Tasks
- Agent-based output serialization (ExQuality.Printer)
- Collectable protocol for silent output capture (ExQuality.OutputCollector)
- Tool detection via dependency scanning (ExQuality.Tools)
- Deep-merge configuration system (ExQuality.Config)
Philosophy
ExQuality is designed for rapid, iterative development with confidence:
- Fast feedback loop with
--quickmode - Comprehensive verification with full mode
- Actionable output with file:line references
- Zero configuration required (works out of the box)
- Progressive enhancement (add tools as needed)