verity 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 56eba3ab988df08937ded0d1a64257aef3e6b0377e89c205cb1142555cfe6022
4
+ data.tar.gz: 767c78b325581dbb233805be573efcf77a1ef6842d1ecb79c23e1036f0d18dd5
5
+ SHA512:
6
+ metadata.gz: 9d1afd58fb7e9eead47231b4c08ef719fcdbba846ed760fbc84238baceaca870c76bb160e678f1a3ff81c3ed40ed4fcc2460f7709bbe7fda911008b1704f26b3
7
+ data.tar.gz: 99325207bf1dbf549b2ee760d5e2528f8b2ae23e98930c3fe67123c18cf577fb3e8b572ef42ebcb8784f4acf13239516e6b45d2e5c9c468d68be5c6c893f4a83
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tad Thorley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # Verity
2
+
3
+ Metadata-first Ruby tests: each case is a structured record (tags, timeouts, resource hints) backed by a SQLite manifest queue. The CLI loads discovery files, syncs them into the manifest, and runs tests — either on a single worker or across parallel forked processes that claim tests atomically from the queue.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby **≥ 3.3**
8
+
9
+ ## Installation
10
+
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "verity"
15
+ ```
16
+
17
+ Or install from the repository root:
18
+
19
+ ```bash
20
+ gem build verity.gemspec && gem install verity-*.gem
21
+ ```
22
+
23
+ ## Running
24
+
25
+ From a checkout, after dependencies are available:
26
+
27
+ ```bash
28
+ ./bin/verity
29
+ # or
30
+ bundle exec verity
31
+ ```
32
+
33
+ Positional arguments are treated as file paths or globs — only those files are loaded instead of the configured `test_globs`:
34
+
35
+ ```bash
36
+ verity verity/models/user_test.rb
37
+ verity verity/models/*_test.rb verity/lib/auth_test.rb
38
+ ```
39
+
40
+ Each argument is resolved with `File.expand_path`, so relative paths work from any directory.
41
+
42
+ Use `--workers` / `-w` to run tests in parallel across forked processes:
43
+
44
+ ```bash
45
+ verity -w 4 # exactly 4 workers
46
+ verity -w cpus # one worker per CPU (Etc.nprocessors)
47
+ verity --workers 2 verity/ # combine with positional args
48
+ ```
49
+
50
+ Exit status is **0** if every claimed test passes, **1** otherwise (`exit` in `bin/verity` mirrors that). **2** is used for invalid CLI options or an invalid `--reporter` / `-r` value.
51
+
52
+ ```bash
53
+ verity --reporter dots
54
+ verity -r null
55
+ verity -r ./reporters/mine.rb:MyReporter
56
+ ```
57
+
58
+ There is no `--version` flag. The current version is available programmatically as `Verity::VERSION`.
59
+
60
+ Built-in names are the same as for `Verity.build_reporter` (case-insensitive): `colored`, `colored_dots`, `documentation`, `doc`, `dots`, `null`, `none`, `silent`. Custom reporters: `path/to/file.rb:ClassName` (class must `include Verity::Reporter`); the file is `load`ed, then `ClassName.new` is called with no arguments.
61
+
62
+ `ColoredDotsReporter` (the default) prints green **.** / red **F** / yellow **E** when stdout is a TTY. Set `NO_COLOR` in the environment to disable; set `FORCE_COLOR` or `VERITY_FORCE_COLOR` to `"1"`, `"true"`, or `"yes"` (case-insensitive) to force color when not a TTY.
63
+
64
+ ## Configuration
65
+
66
+ Use `Verity.configure` before `Verity.run` (or ensure defaults match your layout):
67
+
68
+ ```ruby
69
+ Verity.configure do |c|
70
+ c.manifest_path = "verity/manifest.db" # default; path relative to cwd (ignored by git); or ":memory:" for single-process only
71
+ c.test_globs = ["verity/**/*_test.rb"] # default; set to your Verity discovery globs
72
+ # c.worker_count = :cpus # default; or a positive Integer, or "cpus" / :cpu / "cpu"
73
+ # c.reporter = Verity::Reporters::ColoredDotsReporter.new($stdout) # default
74
+ end
75
+ ```
76
+
77
+ - **`test_globs`** — array of patterns passed to `Dir.glob`; merged and de-duplicated for **`test_files`**.
78
+ - **`manifest_path`** — SQLite database path (default **`verity/manifest.db`**), or `":memory:"` for an in-memory DB (only with **`worker_count` 1**).
79
+ - **`worker_count`** — number of parallel worker processes (`Integer` or decimal string), or **`:cpus`** / **`:cpu`** / **`"cpus"`** / **`"cpu"`** to use `Etc.nprocessors` (minimum **1**). Resolved at run time via **`Configuration#resolved_worker_count`**. Parallel runs need a **file** manifest (not `":memory:"`) and **`Kernel#fork`**.
80
+ - **`reporter`** — object that includes `Verity::Reporter` (default: `Verity::Reporters::ColoredDotsReporter` on `$stdout`). See **Custom reporters** below.
81
+
82
+ `Verity.run(worker_id: 0)` loads all `test_files`, migrates the manifest, replaces the `tests` table from the registry, then runs the manifest-driven runner for that worker.
83
+
84
+ `Verity.load_discovery!` only clears the registry and loads `test_files` (useful if you build your own harness). For each file it precomputes **fingerprints** with **Prism**: the hash covers the **block body** only (description and metadata changes do not change identity). **`Test#file`** and **`Test#line`** remain the **`test` call** location. If you `load` a file outside that path (no plan installed), fingerprints fall back to a line-based slug.
85
+
86
+ ### Custom reporters
87
+
88
+ Implement {Verity::Reporter} and assign it on configuration. `Verity.run` and `Runner.new` (no `reporter:` keyword) use `Verity.configuration.reporter`. Built-ins live under `Verity::Reporters`:
89
+
90
+ | Class | Purpose |
91
+ |-------|---------|
92
+ | `ColoredDotsReporter` | Default — green/red/yellow dots with ANSI color (TTY-aware) |
93
+ | `DotsReporter` | Plain `.` / `F` / `E` dots, no color |
94
+ | `DocumentationReporter` | Prints group titles and test descriptions (outline style) |
95
+ | `NullReporter` | Discards all output (used internally for parallel child workers) |
96
+ | `TestReporter` | In-memory recorder for testing integrations (see below) |
97
+ | `CompositeReporter` | Delegates to multiple reporters |
98
+ | `ParallelSummaryReporter` | Emits the multi-worker summary block after parallel runs |
99
+
100
+ ```ruby
101
+ class MyReporter
102
+ include Verity::Reporter
103
+
104
+ def on_run_start(total:, worker_id:)
105
+ # total: expected number of examples for this worker (nil if unknown)
106
+ end
107
+
108
+ def on_test_complete(result:, worker_id:)
109
+ # See Verity::Runner::Result: :test, :status (:pass | :fail | :error), :error
110
+ end
111
+
112
+ def on_run_finish(summary:, worker_id:)
113
+ # summary: :total, :passed, :failed, :errored, :skipped, :focus
114
+ end
115
+
116
+ # Optional: after Verity.run with worker_count > 1 (parent process only)
117
+ def on_parallel_complete(counts:, problem_rows:)
118
+ end
119
+ end
120
+
121
+ Verity.configure do |c|
122
+ c.reporter = MyReporter.new
123
+ end
124
+ ```
125
+
126
+ For a one-off run without changing global config, pass `Verity::Runner.new(reporter: MyReporter.new)`.
127
+
128
+ ### TestReporter
129
+
130
+ `Verity::Reporters::TestReporter` records every callback in memory (no I/O), useful for testing integrations against the reporter protocol. It exposes four readers:
131
+
132
+ | Reader | Stores |
133
+ |--------|--------|
134
+ | `run_starts` | `[{ total:, worker_id: }, ...]` |
135
+ | `test_completes` | `[{ status:, worker_id: }, ...]` |
136
+ | `run_finishes` | `[{ summary:, worker_id: }, ...]` |
137
+ | `parallel_finishes` | `[{ counts:, problem_rows: }, ...]` |
138
+
139
+ ```ruby
140
+ reporter = Verity::Reporters::TestReporter.new
141
+ Verity.configure { |c| c.reporter = reporter }
142
+ Verity.run
143
+ reporter.test_completes.count { _1[:status] == :pass }
144
+ ```
145
+
146
+ ## Grouping
147
+
148
+ Nest tests under titled sections with **`group`**. Each `test` registers with a **`group_path`** (array of titles) used for output and tooling; fingerprints and execution order are unchanged.
149
+
150
+ ```ruby
151
+ group "Authentication", tags: [:integration] do
152
+ group "sessions", tags: [:focus] do
153
+ test "creates a session" do
154
+ # ...
155
+ end
156
+ end
157
+ end
158
+
159
+ group "WIP", tags: [:skip] do
160
+ test "not scheduled yet" do
161
+ end
162
+ end
163
+ ```
164
+
165
+ Tags on a **`group`** apply to **every nested `test`** (and inner groups): they are stored on each test as **`inherited_group_tags`** (outer groups first) and merged with the test’s own **`tags:`** for **`Verity.skipped?`**, **`Verity.focus_tag?`**, and **`Verity.effective_tags`**. **`:skip`** on any ancestor (or on the test) skips the example; **`:focus`** follows the same suite-wide rules as test-level **`:focus`**.
166
+
167
+ **`Verity::Reporters::DocumentationReporter`** prints new group titles when the path changes (indented like an outline). Dot reporters do not show groups. Custom reporters can read **`result.test.group_path`** and **`result.test.inherited_group_tags`**.
168
+
169
+ The group stack is cleared before each discovery file is loaded so a stray unclosed `group` in one file does not affect the next.
170
+
171
+ ## Tags
172
+
173
+ - **`tags: [:skip]`** — The example is **not** enqueued in the manifest and does **not** run. It still appears in **`Verity::Registry.all`**. The summary line includes **`N skipped`** when `N > 0`. String `"skip"` in tags is treated the same (normalized with `to_sym`). A **`group`** may use **`tags: [:skip]`**; that applies to all nested tests (see **Grouping**).
174
+ - **`tags: [:focus]`** — If **any** non-skipped registered test has **`:focus`** (including via an enclosing **`group`**), **only** tests that have **`:focus`** in their effective tags are runnable (manifest + direct **`Runner#run`**). If every non-skipped test is focused, the filter does nothing (same as “all focused”). **`Skip` wins:** a test with both **`skip`** and **`focus`** is skipped. When focus narrows the suite, the summary ends with **`(focus)`**.
175
+
176
+ ## `Verity::Test` fields
177
+
178
+ Each registered test is a `Data.define` struct with 11 fields:
179
+
180
+ | Field | Type | Description |
181
+ |-------|------|-------------|
182
+ | `fingerprint` | `String` | Stable identity hash derived from the block body via Prism AST |
183
+ | `description` | `String` | Human-readable name passed to `test "..."` |
184
+ | `tags` | `Array<Symbol>` | Tags declared on the test itself (e.g. `[:unit, :focus]`) |
185
+ | `timeout` | `Float`, `nil` | Optional per-test timeout in seconds |
186
+ | `requires` | `Array` | Declared dependency hints (e.g. `[:active_record]`) |
187
+ | `resources` | `Hash` | Extra keyword args from `test` (e.g. `{ tables: [:users] }`) |
188
+ | `file` | `String` | Absolute path of the file containing the `test` call |
189
+ | `line` | `Integer` | Line number of the `test` call |
190
+ | `fn` | `Proc` | The test body block |
191
+ | `group_path` | `Array<String>` | Nested `group` titles at registration time (outer first) |
192
+ | `inherited_group_tags` | `Array<Symbol>` | Tags accumulated from enclosing `group` blocks (outer first) |
193
+
194
+ ## Repository layout (this project)
195
+
196
+ | Directory | Role |
197
+ |-----------|------|
198
+ | `test/` | Minitest for Verity internals |
199
+ | `spec/` | RSpec examples |
200
+ | `verity/` | Verity DSL files (default discovery glob targets `verity/**/*_test.rb`) |
201
+ | `lib/` | Gem implementation |
202
+
203
+ ### Triple suite: compare, convert, and cross-check behavior
204
+
205
+ Integration scenarios are spelled out three ways on purpose:
206
+
207
+ | Layer | Paths | Audience |
208
+ |-------|-------|----------|
209
+ | **Dogfood DSL** | `verity/<topic>_test.rb` | Readers learning Verity (`test`, `group`, built-in assertions) |
210
+ | **Minitest** | `test/<topic>_test.rb` | Readers used to `@test`/assert style and class-based suites |
211
+ | **RSpec** | `spec/verity/<topic>_spec.rb` | Readers used to `describe`/`it` matchers |
212
+
213
+ Matching files share the **same basename** (`foo_test.rb` ↔ `foo_spec.rb`). Scenario titles are aligned so you can open two panes side by side when porting assertions or onboarding a team. Keeping all three suites green is deliberate **redundant proof** — the SQLite manifest and runner stay honest under different loaders and assertions.
214
+
215
+ Example triplet:
216
+
217
+ - [`verity/assertions_test.rb`](verity/assertions_test.rb)
218
+ - [`test/assertions_test.rb`](test/assertions_test.rb)
219
+ - [`spec/verity/assertions_spec.rb`](spec/verity/assertions_spec.rb)
220
+
221
+ ## Design notes
222
+
223
+ See [verity-notes.md](verity-notes.md) for schema, fingerprints, and planned execution model.
224
+
225
+ ## License
226
+
227
+ MIT — see [LICENSE](LICENSE).
data/bin/benchmark ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "rbconfig"
6
+ require "bundler/setup"
7
+
8
+ root = File.expand_path("..", __dir__)
9
+ Dir.chdir(root) or abort("benchmark: cannot chdir to #{root}")
10
+
11
+ ruby = RbConfig.ruby
12
+ opts = { iterations: 1 }
13
+ OptionParser.new do |o|
14
+ o.banner = "Usage: benchmark [options]"
15
+ o.separator ""
16
+ o.separator "Runs the same three suites as bin/test_all and prints wall-clock timings."
17
+ o.separator ""
18
+ o.on("-n", "--iterations N", Integer, "Repeat each suite N times (default: 1)") do |n|
19
+ opts[:iterations] = n
20
+ end
21
+ o.on("-h", "--help", "Show this help") do
22
+ puts o
23
+ exit 0
24
+ end
25
+ end.parse!
26
+
27
+ bench_iterations = opts[:iterations]
28
+ abort "benchmark: iterations must be >= 1" if bench_iterations < 1
29
+
30
+ def fmt_sec(s)
31
+ format("%0.3f", s)
32
+ end
33
+
34
+ def summarize(samples)
35
+ return { min: samples.last, max: samples.last, mean: samples.last } if samples.size == 1
36
+
37
+ {
38
+ min: samples.min,
39
+ max: samples.max,
40
+ mean: samples.sum / samples.size
41
+ }
42
+ end
43
+
44
+ def run_suite(iterations)
45
+ times = []
46
+ iterations.times do |i|
47
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ ok = yield
49
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
+ abort "benchmark: suite failed (iteration #{i + 1})" unless ok
51
+
52
+ times << (t1 - t0)
53
+ end
54
+ times
55
+ end
56
+
57
+ verity_bin = File.join(root, "bin", "verity")
58
+ results = []
59
+
60
+ results << [
61
+ "Dogfood (bin/verity)",
62
+ summarize(run_suite(bench_iterations) do
63
+ system(ruby, verity_bin)
64
+ end)
65
+ ]
66
+
67
+ minitest_files = Dir.glob("test/**/*_test.rb").sort
68
+ abort "benchmark: no files matched test/**/*_test.rb" if minitest_files.empty?
69
+
70
+ results << [
71
+ "Minitest (test/**/*_test.rb, #{minitest_files.size} files)",
72
+ summarize(run_suite(bench_iterations) do
73
+ minitest_files.all? do |path|
74
+ system(ruby, "-Ilib:test", path)
75
+ end
76
+ end)
77
+ ]
78
+
79
+ results << [
80
+ "RSpec (spec/)",
81
+ summarize(run_suite(bench_iterations) do
82
+ system(ruby, "-S", "rspec", "--format", "progress")
83
+ end)
84
+ ]
85
+
86
+ puts
87
+ puts "Benchmark: #{bench_iterations} iteration(s) per suite, monotonic wall clock (seconds)"
88
+ puts "-" * 72
89
+ results.each do |label, stat|
90
+ line = format("%-48s %7ss", label, fmt_sec(stat[:mean]))
91
+ line += format(" (min #{fmt_sec(stat[:min])} .. max #{fmt_sec(stat[:max])})") if bench_iterations > 1
92
+
93
+ puts line
94
+ end
95
+
96
+ total_mean = results.sum { |_, s| s[:mean] }
97
+ puts "-" * 72
98
+ puts format("%-48s %7ss", "Total (sum of suite means)", fmt_sec(total_mean))
99
+ puts
data/bin/test_all ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rbconfig"
5
+ require "bundler/setup"
6
+
7
+ root = File.expand_path("..", __dir__)
8
+ Dir.chdir(root) or abort("test_all: cannot chdir to #{root}")
9
+
10
+ ruby = RbConfig.ruby
11
+ failed = false
12
+
13
+ def banner(title)
14
+ sep = "=" * 60
15
+ puts "\n#{sep}\n#{title}\n#{sep}\n"
16
+ end
17
+
18
+ verity_bin = File.join(root, "bin", "verity")
19
+
20
+ banner "1/3 Dogfood — bin/verity (verity/**/*_test.rb)"
21
+ failed = true unless system(ruby, verity_bin)
22
+
23
+ banner "2/3 Minitest — test/**/*_test.rb"
24
+ minitest_files = Dir.glob("test/**/*_test.rb").sort
25
+ if minitest_files.empty?
26
+ warn "test_all: no files matched test/**/*_test.rb"
27
+ else
28
+ minitest_files.each do |path|
29
+ puts "\n--- #{path} ---"
30
+ failed = true unless system(ruby, "-Ilib:test", path)
31
+ end
32
+ end
33
+
34
+ banner "3/3 RSpec — spec/"
35
+ failed = true unless system(ruby, "-S", "rspec", "--format", "documentation")
36
+
37
+ if failed
38
+ warn "\ntest_all: one or more suites failed"
39
+ exit 1
40
+ end
41
+
42
+ puts "\ntest_all: all suites passed"
43
+ exit 0
data/bin/verity ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+
6
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
7
+ require "verity"
8
+
9
+ begin
10
+ OptionParser.new do |o|
11
+ o.banner = "Usage: verity [options] [file-or-glob ...]"
12
+ o.separator ""
13
+ o.separator "If file-or-glob arguments are given, only those paths are loaded."
14
+ o.separator "Use path/to/file.rb:LINE to run the test on that line or every test in the group opened there."
15
+ o.separator "Otherwise the configured default is used (see Verity::Configuration#test_globs)."
16
+ o.separator ""
17
+ o.on(
18
+ "-r",
19
+ "--reporter NAME",
20
+ "Output reporter: colored (default), colored_dots, dots, documentation, doc, null, none, silent;",
21
+ "or path/to/reporter.rb:ClassName for a custom class (include Verity::Reporter)."
22
+ ) do |name|
23
+ Verity.configure { |c| c.reporter = Verity.build_reporter(name) }
24
+ end
25
+ o.on(
26
+ "-w",
27
+ "--workers COUNT",
28
+ "Parallel worker processes: a positive integer or cpus (one worker per CPU via Etc.nprocessors)."
29
+ ) do |v|
30
+ Verity.configure { |c| c.worker_count = v.strip }
31
+ end
32
+ o.on("--order MODE", "Test order: random (default; use --seed to fix RNG) or fingerprint (sorted).") do |v|
33
+ mode = v.to_s.strip.downcase
34
+ unless %w[fingerprint random].include?(mode)
35
+ warn "verity: --order must be fingerprint or random (got #{v.inspect})"
36
+ exit 2
37
+ end
38
+ Verity.configure { |c| c.test_order = mode.to_sym }
39
+ end
40
+ o.on("--seed INTEGER", Integer, "RNG seed for shuffled order (printed only when auto-chosen).") do |n|
41
+ Verity.configure { |c| c.shuffle_seed = n }
42
+ end
43
+ o.on("-h", "--help", "Show this help") do
44
+ puts o
45
+ exit 0
46
+ end
47
+ end.parse!
48
+
49
+ if ARGV.any?
50
+ globs = []
51
+ filters = []
52
+ ARGV.each do |raw|
53
+ if (m = raw.match(/\A(.+):(\d+)\z/))
54
+ path_part = m[1]
55
+ line_part = Integer(m[2], 10)
56
+ abs = File.expand_path(path_part)
57
+ unless File.file?(abs)
58
+ warn "verity: #{raw.inspect} — line filter requires an existing file (got #{abs.inspect})"
59
+ exit 2
60
+ end
61
+ globs << abs
62
+ filters << [abs, line_part]
63
+ else
64
+ globs << File.expand_path(raw)
65
+ end
66
+ end
67
+ Verity.configure do |c|
68
+ c.test_globs = globs.uniq
69
+ c.location_filters = filters unless filters.empty?
70
+ end
71
+ end
72
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
73
+ warn "verity: #{e.message}"
74
+ exit 2
75
+ end
76
+
77
+ begin
78
+ exit(Verity.run ? 0 : 1)
79
+ rescue ArgumentError => e
80
+ warn "verity: #{e.message}"
81
+ exit 2
82
+ end