Skip to content

Commit f1e440f

Browse files
committed
Add spec-level hint gate, actionable hints, and concise runner output
Driven by analysis of /tmp/liquid-spec-results.jsonl (6.2M run lines, 8523 specs): find specs that consistently fail at low complexity without actionable guidance. Hints + complexity (#1, #2): - Add specific spec-level hints to 19 consistently-failing specs: the to_liquid-in-conditions/lookups cluster (VariableTest#test_*_calls_to_liquid_value_*, 19 specs at c=220, ~89% fail), the nil-comparison quirk (StatementsTest#test_zero_lq_or_equal_one_involving_nil, c=140, 89% fail), and the nested {% liquid %} spec (LiquidTagTest#test_nested_liquid_tag, c=130, 50% fail). - Raise ForTagTest#test_iterate_with_each_when_no_limit from c=70 to c=200: it uses instantiate:LoaderDrop (drop iteration) and was misplaced in the near-beginner band. Spec-quality gate (#3): - New test_specs_through_220_have_spec_level_hints: requires a spec-LEVEL hint (spec.hint, not suite/file effective_hint) for c<=220, with a frozen per-spec baseline (test/spec_hint_baseline.txt). Bidirectional: fails on new no-hint specs (regressions) AND stale baseline entries (hints added or specs removed), so the grandfather list shrinks instead of accreting dead weight. Threshold stays at 220 per the stated policy; generated/bulk specs are exempted exactly, not by file. - scripts/generate_spec_hint_baseline.rb regenerates the baseline. Filter-matrix hints (#4): - docs/filter_matrix_quirks.md: category-level guide for the 716 c=160 generated filter specs (date rendering, float formatting, type coercion, nil/empty, drops). - tasks/helpers.rb#format_and_write_specs accepts metadata: and writes the _metadata wrapper, so rake generate:standard_filters preserves the file-level hint instead of erasing it. standard_filters.yml metadata hint now references the doc and lists the five failure categories. Runner output (user request): - Default plain-mode output is now a single summary line then the lowest-complexity failures: "Complexity bar cleared: NN, NNNN passes, NNN failures. Next best specs to work on:" + N failure rows (default 5, --max-failures). Each failure row shows [c=N] complexity. Preamble, per-suite progress, and skipped-suite output are verbose-only (-v); --json is unchanged. --add-specs output also gated to verbose. - Updated test/runner_diagnostics_test.rb: new test_plain_output_starts_with_... asserts stdout starts with the summary and omits preamble; prioritized test asserts no "Prioritized Specs" before the header.
1 parent 5a0e72b commit f1e440f

11 files changed

Lines changed: 1909 additions & 52 deletions

File tree

CLAUDE.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ liquid-spec my_adapter.rb -l
3838
liquid-spec my_adapter.rb --list-suites
3939
```
4040

41+
### Default runner output
42+
43+
The standard (`liquid-spec adapter.rb`) run prints a single summary line followed by the
44+
lowest-complexity failures — the "next best specs to work on" (capped at `--max-failures`,
45+
default 5). Preamble, per-suite progress, and skipped-suite lines are verbose-only (`-v`):
46+
47+
```
48+
Complexity bar cleared: 70, 1234 passes, 56 failures. Next best specs to work on:
49+
50+
1) [c=70] ForTagTest#test_iterate_with_each...
51+
...
52+
```
53+
54+
`--json` output is unchanged (machine-readable). `--list-passed` appends passing specs
55+
after the failures.
56+
4157

4258

4359
### Spec Quality Gates
@@ -51,8 +67,15 @@ ruby -Ilib -I$LIQUID -Itest -e 'require File.expand_path("test/spec_quality_test
5167
It currently enforces:
5268
- complexity scores must be <= 1000
5369
- every spec with effective complexity <= 220 must have an effective hint
54-
55-
If it fails, add a useful spec/source-level hint or move the spec later in the ramp.
70+
- every spec with complexity <= 220 must have a spec-LEVEL hint (`spec.hint`), not just
71+
suite/file boilerplate. Existing generated/bulk specs without one are grandfathered in
72+
`test/spec_hint_baseline.txt`; any NEW spec at c<=220 lacking a spec-level hint fails
73+
the gate, and stale baseline entries (specs that gained a hint or were removed) also
74+
fail. After intentionally adding hints, shrink the baseline:
75+
76+
```bash
77+
ruby -Ilib scripts/generate_spec_hint_baseline.rb
78+
```
5679

5780
### `verify_*` scripts
5881

docs/filter_matrix_quirks.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Generated filter-matrix quirks
2+
3+
`specs/liquid_ruby/standard_filters.yml` is generated by `rake generate:standard_filters`
4+
from Shopify/liquid's `StandardFilterTest` / `StandardFiltersTest`. Each filter is exercised
5+
across many input types (strings, integers, floats, booleans, nil, arrays, hashes, drops,
6+
Dates). The specs are scored at complexity 160 — past the curated beginner ramp in
7+
`specs/basics/` — because the interesting failures are rarely about the filter itself and
8+
almost always about one of the following cross-cutting behaviors. If a single filter spec
9+
fails, check which category the mismatch falls into before assuming the filter is wrong.
10+
11+
## 1. Date / Time rendering
12+
13+
Filters that receive a `Date` or `Time` (e.g. `escape`, `capitalize`, `append`) must first
14+
render the date to its Liquid string form before applying. Liquid renders `Date` as
15+
`YYYY-MM-DD` (e.g. `2001-02-03`) and `Time` as `YYYY-MM-DD HH:MM:SS %z` (UTC under the
16+
frozen test time `2024-01-01 00:01:58 UTC`).
17+
18+
- `{{ d | escape }}` with `d = 2001-02-03``2001-02-03` (escape of a date string is a
19+
no-op; the work is in the date→string step).
20+
- A filter that stringifies its input (e.g. `append`) must use the date string, not the
21+
raw object.
22+
23+
**Check:** does your implementation render `Date`/`Time` to the correct string *before*
24+
the filter sees it?
25+
26+
## 2. Float formatting and precision
27+
28+
Liquid outputs floats using Ruby's `Float#to_s`, which trims trailing zeros and uses the
29+
shortest round-trip representation.
30+
31+
- `0.0` renders as `0.0` (not `0` and not `0.00`).
32+
- `0.1 + 0.5` (e.g. via `plus`) renders as `0.6`.
33+
- Integer-valued float results keep the `.0` (`4.0``4.0`, not `4`).
34+
35+
**Check:** does your number rendering preserve the float/integer distinction and match
36+
Ruby's `to_s` trimming?
37+
38+
## 3. Type coercion of inputs
39+
40+
Generated inputs deliberately mix types. A filter's behavior depends on the *coerced*
41+
input type, not the surface type:
42+
43+
- `abs` of `true``0` (booleans coerce to 0/1, then abs).
44+
- `first` / `last` on a coerced string or array.
45+
- `contains` / `has` with type-mismatched operands → `false` (no implicit conversion).
46+
47+
**Check:** are you coercing operands the way Liquid does (boolean→0/1, string→chars for
48+
`first`/`last`, strict `contains` equality) before applying the filter?
49+
50+
## 4. nil / empty handling
51+
52+
- Most filters pass `nil` through as `""` (empty string), not `"nil"`.
53+
- `nil | size``0`. `nil | first``""`.
54+
- Empty array/string inputs produce empty or zero outputs, not errors.
55+
56+
**Check:** does every filter treat `nil` as empty-string (or the filter-specific empty
57+
value) rather than erroring or stringifying `nil`?
58+
59+
## 5. Drops and Ruby objects (ruby_drops / ruby_types)
60+
61+
Inputs may be drops (`instantiate:ToSDrop`, `instantiate:IntegerDrop`, …) or Ruby-typed
62+
hashes (integer keys, symbol keys). These specs are auto-tagged `ruby_drops` / `ruby_types`
63+
by the generator. JSON-RPC adapters that opt out of those features skip them.
64+
65+
- A drop input must have `to_liquid` / `to_s` called before the filter applies.
66+
- Hash-inspect outputs (`{"k"=>"v"}`) are Ruby-interop — see
67+
`docs/ruby_hash_inspect_format.md`.
68+
69+
## When to add a spec-level hint
70+
71+
The file-level `_metadata.hint` (above) points here. A *spec-level* `hint:` is only needed
72+
when a single spec exercises a quirk not covered by the categories above — add it directly
73+
to the spec in `standard_filters.yml`; regeneration preserves existing metadata but
74+
overwrites spec entries, so re-add the spec-level hint in the generator
75+
(`lib/liquid/spec/deps/standard_filter_patch.rb`) if it must survive regen.

lib/liquid/spec/cli/runner.rb

Lines changed: 33 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ def run_specs_frozen(config, options, run_id)
291291

292292
missing_features = config.missing_features
293293
json_mode = options[:json]
294-
puts "Missing features: #{missing_features.join(", ")}" unless json_mode
294+
puts "Missing features: #{missing_features.join(", ")}" if config.verbose && !json_mode
295295

296296
# Count available specs from all sources
297297
has_prioritized = options[:add_specs] && !options[:add_specs].empty?
@@ -303,7 +303,7 @@ def run_specs_frozen(config, options, run_id)
303303
local_specs = discover_local_specs
304304
local_specs = filter_specs(local_specs, config.filter) if config.filter && local_specs.any?
305305
local_specs = filter_by_missing(local_specs, missing_features) if local_specs.any?
306-
if local_specs.any? && !json_mode
306+
if local_specs.any? && config.verbose && !json_mode
307307
puts "Auto-including: #{local_specs.size} specs from ./specs/*.yml"
308308
end
309309

@@ -328,10 +328,9 @@ def run_specs_frozen(config, options, run_id)
328328
known_failures.load_file(options[:known_failures_file])
329329
known_failures.load_patterns(config.known_failures)
330330

331-
unless known_failures.empty? || json_mode
331+
if !known_failures.empty? && config.verbose && !json_mode
332332
puts "Known failures: #{known_failures.size} patterns loaded"
333333
end
334-
puts "" unless json_mode
335334

336335
# Check if --bench flag is provided and any suite has timings enabled
337336
if options[:bench]
@@ -361,7 +360,7 @@ def run_specs_frozen(config, options, run_id)
361360
# Run explicitly added specs FIRST (--add-specs), before suites
362361
if prioritized_specs.any?
363362
add_passed, add_failed, add_errors, add_known_failed, add_known_fixed =
364-
run_prioritized_specs(prioritized_specs, known_failures, all_failures, all_known_failures, all_known_fixed, all_passed, results_by_complexity, log_file, run_id, config, quiet: json_mode)
363+
run_prioritized_specs(prioritized_specs, known_failures, all_failures, all_known_failures, all_known_fixed, all_passed, results_by_complexity, log_file, run_id, config, quiet: json_mode || !config.verbose)
365364
total_passed += add_passed
366365
total_failed += add_failed
367366
total_errors += add_errors
@@ -387,11 +386,10 @@ def run_specs_frozen(config, options, run_id)
387386
next if suite_specs.empty?
388387

389388
suite_name_padded = "#{suite.name} ".ljust(40, ".")
390-
unless json_mode
389+
if config.verbose && !json_mode
391390
print("#{suite_name_padded} ")
392391
$stdout.flush
393392
end
394-
395393
passed = 0
396394
failed = 0
397395
errors = 0
@@ -446,14 +444,13 @@ def run_specs_frozen(config, options, run_id)
446444
end
447445
end
448446

449-
unless json_mode
447+
if config.verbose && !json_mode
450448
parts = ["#{passed}/#{suite_specs.size} passed"]
451449
parts << "#{failed} failed" if failed > 0
452450
parts << "#{errors} errors" if errors > 0
453451
parts << "#{known_failed} known" if known_failed > 0
454452
puts parts.join(", ")
455453
end
456-
457454
total_passed += passed
458455
total_failed += failed
459456
total_errors += errors
@@ -465,21 +462,7 @@ def run_specs_frozen(config, options, run_id)
465462

466463
# Show skipped suites
467464
skipped_suites = skipped_suites_for(config, missing_features)
468-
show_skipped_suites(config, missing_features) unless json_mode
469-
470-
unless json_mode
471-
# Print unexpected failures
472-
print_failures(all_failures, max_failures)
473-
474-
# Print known failures (expected)
475-
print_known_failures(all_known_failures) if all_known_failures.any?
476-
477-
# Print passing specs when requested (diagnostic for ramp audits)
478-
print_passed_specs(all_passed) if options[:list_passed]
479-
480-
# Print warnings about known failures that now pass
481-
print_known_fixed(all_known_fixed) if all_known_fixed.any?
482-
end
465+
show_skipped_suites(config, missing_features) if config.verbose && !json_mode
483466

484467
# Calculate max complexity reached (highest level where all specs pass)
485468
sorted_complexities = results_by_complexity.keys.sort
@@ -515,13 +498,17 @@ def run_specs_frozen(config, options, run_id)
515498
skipped_suites: skipped_suites,
516499
)
517500
else
518-
puts ""
519-
parts = ["#{total_passed} passed"]
520-
parts << "#{total_failed} failed" if total_failed > 0
521-
parts << "#{total_errors} errors" if total_errors > 0
522-
parts << "#{total_known_failed} known failures" if total_known_failed > 0
523-
parts << "#{total_known_fixed} known now passing" if total_known_fixed > 0
524-
puts "Total: #{parts.join(", ")}. Max complexity reached: #{max_complexity_reached}/#{max_possible}"
501+
print_run_summary(
502+
total_passed: total_passed,
503+
total_failed: total_failed,
504+
total_errors: total_errors,
505+
max_complexity_reached: max_complexity_reached,
506+
failures: all_failures,
507+
known_fixed: all_known_fixed,
508+
passed_specs: all_passed,
509+
list_passed: options[:list_passed],
510+
max_failures: max_failures
511+
)
525512
end
526513

527514
# Determine exit code
@@ -1417,13 +1404,22 @@ def sort_result_entries(entries)
14171404
end
14181405
end
14191406

1407+
# Default plain-mode output: a single summary line followed by the
1408+
# lowest-complexity failures (the "next best specs to work on").
1409+
# All preamble/progress/skipped output is verbose-only so stdout
1410+
# starts with the "Complexity bar cleared" line.
1411+
def print_run_summary(total_passed:, total_failed:, total_errors:, max_complexity_reached:, failures:, known_fixed:, passed_specs:, list_passed:, max_failures:)
1412+
failures_count = total_failed + total_errors
1413+
puts "Complexity bar cleared: #{max_complexity_reached}, #{total_passed} passes, #{failures_count} failures. Next best specs to work on:"
1414+
puts ""
1415+
print_failures(failures, max_failures)
1416+
print_known_fixed(known_fixed) if known_fixed.any?
1417+
print_passed_specs(passed_specs) if list_passed
1418+
end
1419+
14201420
def print_failures(failures, max_failures = nil)
14211421
return if failures.empty?
14221422

1423-
puts ""
1424-
puts "Failures:"
1425-
puts ""
1426-
14271423
shown_hints = Set.new
14281424
sorted_failures = sort_result_entries(failures)
14291425

@@ -1438,8 +1434,8 @@ def print_failures(failures, max_failures = nil)
14381434
location = spec.source_file
14391435
location = "#{location}:#{spec.line_number}" if spec.line_number
14401436
puts "\e[2m#{location}\e[0m"
1441-
1442-
puts "#{i + 1}) #{spec.name}"
1437+
complexity = spec.complexity || 1000
1438+
puts "#{i + 1}) [c=#{complexity}] #{spec.name}"
14431439
print_labeled_value("Template", spec.template)
14441440

14451441
# Show environment if present
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# Regenerate test/spec_hint_baseline.txt: the frozen list of specs at
5+
# complexity <= 220 that have no spec-level hint (spec.hint). These are existing
6+
# generated/bulk specs grandfathered out of the spec-level-hint gate. Any NEW
7+
# spec at c<=220 without a spec-level hint is caught by the gate (not in this
8+
# baseline). Re-run after intentionally adding spec-level hints to baseline
9+
# entries (it shrinks the baseline).
10+
#
11+
# Usage: ruby -Ilib scripts/generate_spec_hint_baseline.rb
12+
13+
require "liquid"
14+
require "liquid/spec"
15+
require "liquid/spec/suite"
16+
require "liquid/spec/spec_loader"
17+
require "liquid/spec/deps/liquid_ruby"
18+
19+
root = File.expand_path("..", __dir__)
20+
baseline_path = File.join(root, "test", "spec_hint_baseline.txt")
21+
22+
entries = []
23+
Liquid::Spec::Suite.all.each do |suite|
24+
Liquid::Spec::SpecLoader.load_suite(suite).each do |spec|
25+
c = spec.complexity || suite.minimum_complexity || 1000
26+
next unless c <= 220
27+
next unless spec.hint.to_s.strip.empty?
28+
rel = spec.source_file.to_s.sub(root + "/", "")
29+
entries << "#{rel}\t#{spec.name}"
30+
end
31+
end
32+
entries.sort!
33+
File.write(baseline_path, entries.join("\n") + "\n")
34+
puts "wrote #{entries.size} entries to #{baseline_path}"

0 commit comments

Comments
 (0)