Skip to content

Commit 3e2a614

Browse files
committed
Run compatible specs in strict and strict2
Assisted-By: devx/a8210bca-b84d-4916-bd2f-849956155755
1 parent 97d7738 commit 3e2a614

23 files changed

Lines changed: 238 additions & 102 deletions

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ ruby -Ilib scripts/verifiers/spec_name_collisions.rb # advisory: no two specs sh
143143
ruby -Ilib scripts/verifiers/filesystem_extensions.rb # advisory: filesystem keys should have valid extensions
144144
ruby -Ilib scripts/verifiers/minimum_complexity.rb # specs with advanced features sit above the beginner ramp
145145
ruby -Ilib scripts/verifiers/sequential_complexity.rb # opted-in curated files advance exactly one level per spec
146+
ruby -Ilib scripts/verifiers/strict_mode_compatibility.rb # strict/strict2-only specs must need their single-mode restriction
146147
```
147148

148149
**Blocking verifiers** (must be green before pushing):
@@ -165,6 +166,9 @@ ruby -Ilib scripts/verifiers/sequential_complexity.rb # opted-in curated files a
165166
`_metadata.sequential_complexity: true` must advance exactly one complexity
166167
level per spec. This prevents one-lesson ramps from collapsing back into
167168
round-number buckets.
169+
- `strict_mode_compatibility` — a strict- or strict2-only spec must actually
170+
differ in the other mode; otherwise it declares `[strict2, strict]` and runs
171+
for adapters supporting either strictness.
168172
- `jsonrpc_portability` — specs must not send non-primitive Ruby types
169173
(non-string keys, symbols, unregistered objects) over JSON-RPC, which would
170174
trigger `_rpc_drop` markers. Time/Date/DateTime are whitelisted (sent as

lib/liquid/spec/cli/matrix.rb

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,15 +211,16 @@ def run_comparison(specs, adapters, reference_name, options)
211211
matched = 0
212212
skipped = 0
213213
checked = 0
214+
variants = expand_error_mode_variants(specs, adapters)
214215

215216
max_failures = options[:max_failures]
216217
verbose = options[:verbose]
217218

218-
print("Running #{specs.size} specs: ")
219+
print("Running #{variants.size} specs: ")
219220
$stdout.flush
220221

221222
TimeFreezer.freeze_spec_time do
222-
specs.each do |spec|
223+
variants.each do |spec|
223224
# Run on ALL adapters that can run this spec
224225
outputs = {}
225226
adapters.each do |name, adapter|
@@ -296,11 +297,41 @@ def run_comparison(specs, adapters, reference_name, options)
296297

297298
# Print results (limited by max_failures for display only)
298299
print_results_v2(differences, adapters, options, max_failures)
299-
print_summary(matched, differences.size, skipped, checked, specs.size, adapters)
300+
print_summary(matched, differences.size, skipped, checked, variants.size, adapters)
300301

301302
exit(1) if differences.any?
302303
end
303304

305+
# Matrix executes each explicit compatible parse mode independently.
306+
# Ordinary specs use strict2, the harness-wide default, so adapters
307+
# that only implement strict/lax are not compared against a different
308+
# parser contract by accident.
309+
def expand_error_mode_variants(specs, adapters)
310+
supported = LiquidSpec::PARSE_ERROR_MODES.select do |mode|
311+
feature = :"#{mode}_parsing"
312+
adapters.values.any? { |adapter| !adapter.missing_features.include?(feature) }
313+
end
314+
315+
specs.flat_map do |spec|
316+
declared = declared_error_modes(spec)
317+
modes = if declared.empty?
318+
supported.include?(:strict2) ? [:strict2] : [supported.first].compact
319+
else
320+
LiquidSpec::PARSE_ERROR_MODES.select { |mode| declared.include?(mode) && supported.include?(mode) }
321+
end
322+
323+
modes.map { |mode| spec.with_error_mode(mode, label: declared.length > 1) }
324+
end
325+
end
326+
327+
def declared_error_modes(spec)
328+
return spec.error_modes unless spec.error_modes.empty?
329+
330+
LiquidSpec::PARSE_ERROR_MODES.select do |mode|
331+
spec.features.include?(:"#{mode}_parsing")
332+
end
333+
end
334+
304335

305336

306337

lib/liquid/spec/cli/runner.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2166,7 +2166,7 @@ def expand_error_mode_variants(specs)
21662166
supported = config.error_modes
21672167

21682168
specs.flat_map do |spec|
2169-
declared = spec.respond_to?(:error_modes) ? spec.error_modes : []
2169+
declared = declared_error_modes(spec)
21702170
modes = if declared.nil? || declared.empty?
21712171
[supported.first]
21722172
else
@@ -2180,6 +2180,19 @@ def expand_error_mode_variants(specs)
21802180
end
21812181
end
21822182

2183+
# Older generated specs can carry a parse-mode feature tag without an
2184+
# explicit error_mode field. Treat that tag as the declaration while
2185+
# migrating the corpus; otherwise expansion would silently replace a
2186+
# lax requirement with the strict2 default.
2187+
def declared_error_modes(spec)
2188+
declared = spec.respond_to?(:error_modes) ? spec.error_modes : []
2189+
return declared unless declared.nil? || declared.empty?
2190+
2191+
LiquidSpec::PARSE_ERROR_MODES.select do |mode|
2192+
spec.features.include?(:"#{mode}_parsing")
2193+
end
2194+
end
2195+
21832196
def count_unrunnable_specs(specs, missing_features)
21842197
missing_set = Set.new(missing_features.map(&:to_sym))
21852198
specs.count do |spec|

lib/liquid/spec/verifiers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module Verifiers
2323
"sequential_complexity" => "SequentialComplexityVerifier",
2424
"spec_name_collisions" => "SpecNameCollisionVerifier",
2525
"spec_schema" => "SpecSchemaVerifier",
26+
"strict_mode_compatibility" => "StrictModeCompatibilityVerifier",
2627
}.freeze
2728

2829
Result = Struct.new(:name, :exit_code, :advisory, keyword_init: true)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# Lint: a spec restricted to strict or strict2 must actually need that
5+
# restriction. If liquid-ruby produces the same status and output in both
6+
# modes, declare error_mode: [strict2, strict] so adapters supporting either
7+
# strictness exercise it.
8+
9+
require "liquid/spec/spec_loader"
10+
require "liquid/spec/adapter_runner"
11+
require "liquid/spec/time_freezer"
12+
13+
module StrictModeCompatibilityVerifier
14+
class << self
15+
def run
16+
adapter = Liquid::Spec::AdapterRunner.new(name: "strict-mode-verifier")
17+
.load_dsl(reference_adapter)
18+
adapter.ensure_setup!
19+
20+
offenders = []
21+
Liquid::Spec::TimeFreezer.freeze_spec_time do
22+
Liquid::Spec::SpecLoader.load_all.each do |spec|
23+
next unless spec.error_modes.length == 1
24+
next unless [:strict, :strict2].include?(spec.error_mode)
25+
26+
strict2 = result_for(adapter, spec, :strict2)
27+
strict = result_for(adapter, spec, :strict)
28+
next unless strict2 == strict
29+
30+
offenders << spec
31+
end
32+
end
33+
34+
if offenders.empty?
35+
puts "OK: all strict/strict2-only specs require their single-mode declaration."
36+
return 0
37+
end
38+
39+
puts "Found #{offenders.size} strict/strict2 spec(s) compatible with both modes:\n\n"
40+
offenders.each do |spec|
41+
puts " #{relative_path(spec.source_file)}:#{spec.line_number} #{spec.name}"
42+
end
43+
puts "\nDeclare error_mode: [strict2, strict] for each compatible spec."
44+
1
45+
end
46+
47+
private
48+
49+
def result_for(adapter, spec, mode)
50+
result = adapter.run_single(spec.with_error_mode(mode))
51+
[result.status, result.output.to_s]
52+
end
53+
54+
def reference_adapter
55+
File.expand_path("../../examples/liquid_ruby.rb", __dir__)
56+
end
57+
58+
def relative_path(path)
59+
path.sub(%r{\A#{Regexp.escape(Dir.pwd)}/}, "")
60+
end
61+
end
62+
end
63+
64+
exit StrictModeCompatibilityVerifier.run if $PROGRAM_NAME == __FILE__

specs/basics/drops.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ specs:
351351
render_error:
352352
- ErrorDrop
353353
complexity: 200
354-
error_mode: strict
354+
error_mode: [strict2, strict]
355355
hint: |
356356
ErrorDrop raises on any property access. The error should propagate
357357
as a render error.

specs/basics/dynamic-partials.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ specs:
492492
render_error:
493493
- Illegal template name
494494
complexity: 550
495-
error_mode: strict
495+
error_mode: [strict2, strict]
496496
hint: |
497497
When the variable resolving the partial name is nil/undefined,
498498
an "Illegal template name" error should be raised as an exception.
@@ -507,7 +507,7 @@ specs:
507507
render_errors: true
508508
features: [inline_errors]
509509
complexity: 550
510-
error_mode: strict
510+
error_mode: [strict2, strict]
511511
hint: |
512512
Same as dynamic_include_nil_name but with inline error rendering.
513513
When render_errors is true, the error message is rendered into the
@@ -519,7 +519,7 @@ specs:
519519
render_error:
520520
- Could not find
521521
complexity: 550
522-
error_mode: strict
522+
error_mode: [strict2, strict]
523523
hint: |
524524
An empty string resolves to a file lookup for "" which doesn't exist.
525525
This produces a "Could not find asset" exception.
@@ -532,13 +532,13 @@ specs:
532532
render_errors: true
533533
features: [inline_errors]
534534
complexity: 550
535-
error_mode: strict
535+
error_mode: [strict2, strict]
536536
hint: |
537537
Same as dynamic_include_empty_string_name but with inline error rendering.
538538
539539
- name: dynamic_include_missing_partial
540540
template: "{% assign tpl = 'nonexistent' %}{% include tpl %}"
541-
error_mode: strict
541+
error_mode: [strict2, strict]
542542
errors:
543543
render_error:
544544
- Could not find
@@ -555,7 +555,7 @@ specs:
555555
render_errors: true
556556
features: [inline_errors]
557557
complexity: 550
558-
error_mode: strict
558+
error_mode: [strict2, strict]
559559
hint: |
560560
Same as dynamic_include_missing_partial but with inline error rendering.
561561

specs/basics/dynamic_partials.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ specs:
4040
- name: dynamic_include_missing_partial_error
4141
complexity: 1000
4242
template: "{% include tpl %}"
43-
error_mode: strict
43+
error_mode: [strict2, strict]
4444
filesystem: {}
4545
environment:
4646
tpl: "missing"
@@ -51,7 +51,7 @@ specs:
5151
- name: dynamic_include_missing_partial_inline_error
5252
complexity: 1000
5353
template: "{% include tpl %}"
54-
error_mode: strict
54+
error_mode: [strict2, strict]
5555
filesystem: {}
5656
environment:
5757
tpl: "missing"
@@ -64,7 +64,7 @@ specs:
6464
- name: dynamic_include_illegal_name_error
6565
complexity: 1000
6666
template: "{% include tpl %}"
67-
error_mode: strict
67+
error_mode: [strict2, strict]
6868
filesystem:
6969
x: "X"
7070
environment:
@@ -76,7 +76,7 @@ specs:
7676
- name: dynamic_include_illegal_name_inline_error
7777
complexity: 1000
7878
template: "{% include tpl %}"
79-
error_mode: strict
79+
error_mode: [strict2, strict]
8080
filesystem:
8181
x: "X"
8282
environment:

specs/basics/error-handling.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
environment: {}
4848
expected: ''
4949
complexity: 230
50-
error_mode: strict
50+
error_mode: [strict2, strict]
5151
hint: |
5252
In liquid-ruby, strict mode only affects parsing, not runtime variable
5353
lookup. Undefined variables still render as empty strings. Other
@@ -69,7 +69,7 @@
6969
environment: {}
7070
expected: ''
7171
complexity: 245
72-
error_mode: strict
72+
error_mode: [strict2, strict]
7373
hint: |
7474
In liquid-ruby, strict mode only affects parsing. Property access on
7575
undefined variables still returns nil (empty output). Other implementations
@@ -115,7 +115,7 @@
115115
template: "{{ 'hello' | nonexistent_filter }}"
116116
expected: hello
117117
complexity: 270
118-
error_mode: strict
118+
error_mode: [strict2, strict]
119119
hint: |
120120
In liquid-ruby, unknown filters are ignored even in strict mode.
121121
The filter passes through its input unchanged, like lax mode.
@@ -158,7 +158,7 @@
158158
- name: unknown_tag_strict_parse_error
159159
template: "{% nonexistent_tag %}content{% endnonexistent_tag %}"
160160
complexity: 300
161-
error_mode: strict
161+
error_mode: [strict2, strict]
162162
errors:
163163
parse_error:
164164
- Unknown tag
@@ -169,7 +169,7 @@
169169
- name: unknown_tag_strict2_parse_error
170170
template: "{% nonexistent_tag %}content{% endnonexistent_tag %}"
171171
complexity: 300
172-
error_mode: strict2
172+
error_mode: [strict2, strict]
173173
errors:
174174
parse_error:
175175
- Unknown tag
@@ -300,7 +300,7 @@
300300
profile:
301301
expected: Anonymous
302302
complexity: 295
303-
error_mode: strict
303+
error_mode: [strict2, strict]
304304
hint: |
305305
Even in strict mode, nil-safe property access should work when the value
306306
is explicitly nil (not undefined). Here 'profile' exists but is nil, so
@@ -484,7 +484,7 @@
484484
c
485485
d
486486
{{ foo | }}
487-
error_mode: :strict
487+
error_mode: [strict2, strict]
488488
errors:
489489
parse_error:
490490
- "Expected id"
@@ -500,7 +500,7 @@
500500
line 1
501501
line 2
502502
{{ 'oops }}
503-
error_mode: :strict
503+
error_mode: [strict2, strict]
504504
errors:
505505
parse_error:
506506
- "Unexpected character"

specs/basics/filter-edge-cases.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@
191191
output:
192192
- "cannot sort"
193193
complexity: 100
194-
error_mode: strict
194+
error_mode: [strict2, strict]
195195
hint: |
196196
Sorting arrays with mixed types (numbers, strings, booleans) raises
197197
an error because comparison between different types is undefined.

0 commit comments

Comments
 (0)