Skip to content

Commit 2dc2294

Browse files
committed
Tag Ruby-content specs + lint + hash-inspect doc
Add scripts/verify_ruby_type_tags.rb lint enforcing that any spec carrying Ruby-specific content (Hash#inspect `=>` output, :symbol keys, non-string hash keys, invalid-UTF-8 bytes) declares a ruby feature tag and sits above complexity 100. Fix all 88 violations it found: 83 specs tagged ruby_types, 5 base64-decode specs producing invalid-UTF-8 bytes tagged binary_data. These specs test Ruby-specific rendering/transport behavior that JSON-RPC adapters cannot produce or receive faithfully. Tagging them lets JSON-RPC adapters (which opt out of ruby_types/binary_data by default) skip them, while a Ruby adapter or an opt-in implementation can still run them. - docs/ruby_hash_inspect_format.md: precise spec of Ruby Hash#inspect/to_s format so opt-in implementers can reproduce it. - init.rb generic template: ruby_types stays ENABLED (not in missing_features) with a commented opt-out hint; jsonrpc template keeps ruby_types in missing_features (disabled by default). - AGENTS.md: document the verify_* scripts, run them all before pushing, and add new ones for new cross-cutting rules.
1 parent 384d308 commit 2dc2294

12 files changed

Lines changed: 474 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ It currently enforces:
5454

5555
If it fails, add a useful spec/source-level hint or move the spec later in the ramp.
5656

57+
### `verify_*` scripts
58+
59+
`scripts/` contains standalone lint/audit scripts named `verify_*.rb` that check
60+
cross-cutting spec invariants the quality-gate test doesn't cover:
61+
62+
```bash
63+
ruby -Ilib scripts/verify_lax_placement.rb # lax-only specs live in liquid_ruby_lax
64+
ruby -Ilib scripts/verify_ruby_type_tags.rb # Ruby-content specs carry a ruby feature tag + complexity > 100
65+
```
66+
67+
**Run every `verify_*` script before pushing.** They exit non-zero on violation:
68+
69+
```bash
70+
for s in scripts/verify_*.rb; do ruby -Ilib "$s" || exit 1; done
71+
```
72+
73+
When you introduce a new cross-cutting rule (e.g. "every spec with marker X must
74+
have tag Y"), add a `verify_*.rb` script for it rather than a one-off check, so
75+
the rule stays enforced. Keep each script self-contained (parse spec files
76+
directly), print `OK: ...` on success, and exit non-zero with a per-spec
77+
offender list on failure.
78+
5779
### Dumb Adapter Ramp Audits
5880

5981
When changing early complexity scores or adding beginner specs, play dumb and verify the harness still behaves like an implementation curriculum:

docs/ruby_hash_inspect_format.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Ruby `Hash#inspect` / `Hash#to_s` format
2+
3+
Several specs render a hash (or array of hashes) through `{{ var }}` and expect
4+
Ruby's `Hash#inspect` output — **not** JSON. In Ruby, `Hash#to_s` is an alias
5+
for `Hash#inspect`, so `{{ my_hash }}` (which calls `to_s` on the resolved
6+
value) produces the format below. A non-Ruby Liquid implementation must
7+
reproduce it exactly to pass these specs.
8+
9+
This is a cross-language correctness bar: Liquid defers object rendering to the
10+
host language's `to_s`, and the reference implementation is Ruby, so the
11+
expected strings are Ruby's `inspect` output.
12+
13+
## Grammar
14+
15+
```
16+
hash_inspect = "{" pairs? "}"
17+
pairs = pair (", " pair)*
18+
pair = key_inspect "=>" value_inspect
19+
array_inspect = "[" elements? "]"
20+
elements = elem_inspect (", " elem_inspect)*
21+
```
22+
23+
`=>` is the **rocket**, never `:`. Pairs are in **insertion order**. The
24+
separator is `", "` (comma + one space). The whole thing is wrapped in `{` `}`.
25+
26+
## Scalar inspect rules
27+
28+
| Ruby type | Inspect output | Notes |
29+
|-------------|-----------------------|-------|
30+
| `String` | `"foo"` | double-quoted, with escapes (below) |
31+
| `Symbol` | `:foo` | leading colon, no quotes |
32+
| `Integer` | `42` | bare |
33+
| `Float` | `1.0` | always has a decimal part (`1.0`, not `1`) |
34+
| `nil` | `nil` | the literal word |
35+
| `true` | `true` | |
36+
| `false` | `false` | |
37+
| `Array` | `[a, b, c]` | each element via its own inspect, `, ` separated |
38+
| `Hash` | `{k=>v, ...}` | recursive |
39+
| self-ref | `{...}` | a hash that refers to an ancestor in the same inspect call renders as `{...}` |
40+
41+
## String escapes (inside double quotes)
42+
43+
The string's `inspect` doubles `\\` and `\"`, and turns control characters into
44+
their escaped forms: `\n`, `\t`, `\r`, `\f`, `\b`, `\a`, `\e`, `\v`, `\0` /
45+
`\u0000`-style for other non-printables. Printable ASCII is emitted literally.
46+
This matters for specs where the hash is piped through `escape`/`url_encode`
47+
*after* being stringified — the inspect output is the input to those filters.
48+
49+
## Worked examples (all drawn from real specs)
50+
51+
| Input (Ruby) | Expected output |
52+
|-----------------------------------------------|----------------------------------------|
53+
| `{}` | `{}` |
54+
| `{"key1"=>"value1", "key2"=>"value2"}` | `{"key1"=>"value1", "key2"=>"value2"}` |
55+
| `{"numbers"=>[1, 2, 3]}` | `{"numbers"=>[1, 2, 3]}` |
56+
| `{"numbers"=>[]}` | `{"numbers"=>[]}` |
57+
| `{"outer"=>{"inner"=>"value"}}` | `{"outer"=>{"inner"=>"value"}}` |
58+
| `{{"foo"=>"bar"}=>42}` (hash key) | `{{"foo"=>"bar"}=>42}` |
59+
| `{:key1=>1, :key2=>2}` (symbol keys) | `{:key1=>1, :key2=>2}` |
60+
| recursive `{"self"=>{"self"=>{...}}}` | `{"self"=>{"self"=>{...}}}` |
61+
62+
## How filters interact
63+
64+
When a hash is the input to a string filter, the hash is **first** stringified
65+
via `inspect`, **then** the filter runs on that string. Examples:
66+
67+
- `{{ my_hash | upcase }}``{"KEY1"=>"VALUE1", ...}` (inspect, then uppercased)
68+
- `{{ my_hash | downcase }}``{"key1"=>"value1", ...}`
69+
- `{{ my_hash | append: " text" }}``{"Key"=>"Value"} text`
70+
- `{{ my_hash | replace: "key", "KEY" }}` → replaces inside the inspect string
71+
- `{{ my_hash | escape }}` → HTML-escapes the inspect string
72+
(`{"Key"=>"Value"}`)
73+
74+
So the inspect format is the *substrate* every string filter operates on when the
75+
input is a hash.
76+
77+
## Pseudocode for a non-Ruby implementation
78+
79+
```text
80+
function rubyInspect(value, seen = new Set()):
81+
if value is a Hash/Map:
82+
if value in seen: return "{...}" # cycle guard
83+
seen.add(value)
84+
if value is empty: return "{}"
85+
parts = []
86+
for (k, v) in value in insertion order:
87+
parts.push(rubyInspect(k, seen) + "=>" + rubyInspect(v, seen))
88+
return "{" + parts.join(", ") + "}"
89+
if value is an Array:
90+
if value is empty: return "[]"
91+
return "[" + value.map(rubyInspect).join(", ") + "]"
92+
if value is a String: return rubyStringInspect(value) # double-quoted + escapes
93+
if value is a Symbol: return ":" + value.name # no quotes
94+
if value is null/nil: return "nil"
95+
if value is Boolean: return value ? "true" : "false"
96+
if value is Integer: return String(value)
97+
if value is Float: return floatInspect(value) # always show a decimal
98+
# other objects: defer to the host's inspect/to_s if you emulate ruby_types
99+
```
100+
101+
`rubyStringInspect(s)` wraps `s` in `"..."`, escaping `\\` and `"` and control
102+
chars as above. `floatInspect` must always include a decimal point (`1.0`, not
103+
`1`; `0.2`, not `0.2` rendered as a bare fraction — match Ruby's `Float#inspect`,
104+
which uses the shortest representation that round-trips).
105+
106+
## When you need this
107+
108+
These specs are gated behind the **Ruby/reference-quirk** complexity band
109+
(~220+) and most are tagged `features: [ruby_types]`. JSON-RPC adapters skip
110+
`ruby_types` by default. If you opt in (remove `:ruby_types` from
111+
`missing_features`), the JSON-RPC transport delivers non-string hash keys to you
112+
as their Ruby `inspect` string, and your engine is expected to render hashes and
113+
those keys per the rules above.

lib/liquid/spec/cli/init.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ module Init
4646
4747
# Optional: filter specs by name pattern
4848
# config.filter = /assign/
49+
# Specs that need Ruby-specific behavior (Hash#inspect output format
50+
# `{"k"=>"v"}`, symbol keys, binary bytes) are tagged `ruby_types`.
51+
# A Ruby implementation should leave these ENABLED (do not list them
52+
# here). A non-Ruby implementation that is not yet emulating Ruby's
53+
# inspect/to_s format can opt out temporarily:
54+
# config.missing_features = [:ruby_types, :binary_data]
55+
# See docs/ruby_hash_inspect_format.md for what opting in requires.
4956
end
5057
5158
# Called to compile a template string into your implementation's template object.

scripts/verify_ruby_type_tags.rb

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# Lint: every spec that carries Ruby-specific content must declare a Ruby
5+
# feature tag AND sit above complexity 100 (out of the beginner ramp).
6+
#
7+
# Ruby-specific content is anything a JSON/JS implementation cannot produce or
8+
# receive faithfully without deliberately emulating Ruby semantics:
9+
# - expected output containing Ruby `Hash#inspect` notation (`=>` rocket,
10+
# or `:symbol` keys rendered as `{:foo=>...}`)
11+
# - an environment with a non-String Hash key (Symbol, Integer, Float,
12+
# Hash, Array, etc.) at any nesting depth — JSON object keys are strings
13+
# - expected output containing invalid-UTF-8 bytes (binary data)
14+
#
15+
# A spec matching any of these must:
16+
# 1. list at least one Ruby feature tag
17+
# (ruby_types / ruby_drops / binary_data / runtime_drops / template_factory)
18+
# 2. have complexity > 100
19+
#
20+
# Usage: ruby -Ilib scripts/verify_ruby_type_tags.rb
21+
# Exit code is non-zero if any violation is found.
22+
23+
require "yaml"
24+
25+
RUBY_FEATURES = %w[ruby_types ruby_drops binary_data runtime_drops template_factory].freeze
26+
COMPLEXITY_FLOOR = 100
27+
28+
module RubyTypeTagVerifier
29+
class << self
30+
def run
31+
offenders = []
32+
each_spec do |spec|
33+
markers = detect_markers(spec)
34+
next if markers.empty?
35+
reasons = []
36+
feats = spec[:features] || []
37+
unless (feats & RUBY_FEATURES).any?
38+
reasons << "missing ruby feature tag (needs one of: #{RUBY_FEATURES.join(", ")})"
39+
end
40+
c = spec[:complexity]
41+
if c && c <= COMPLEXITY_FLOOR
42+
reasons << "complexity #{c} <= #{COMPLEXITY_FLOOR} (Ruby-quirk specs belong above the beginner ramp)"
43+
end
44+
next if reasons.empty?
45+
offenders << {
46+
file: spec[:file],
47+
line: spec[:line],
48+
name: spec[:name],
49+
markers: markers,
50+
reasons: reasons,
51+
}
52+
end
53+
54+
if offenders.empty?
55+
puts "OK: all Ruby-content specs are tagged and above complexity #{COMPLEXITY_FLOOR}."
56+
return 0
57+
end
58+
59+
puts "Found #{offenders.size} spec(s) with Ruby-specific content that violate the rules:\n\n"
60+
offenders.each do |o|
61+
puts " #{o[:file]}:#{o[:line]} #{o[:name]}"
62+
puts " markers: #{o[:markers].join(", ")}"
63+
o[:reasons].each { |r| puts " - #{r}" }
64+
puts
65+
end
66+
1
67+
end
68+
69+
private
70+
71+
# Yield a hash for every spec across all spec files:
72+
# { file, line, name, expected, environment, features, complexity }
73+
def each_spec
74+
Dir.glob("specs/**/*.yml").sort.each do |file|
75+
doc = YAML.unsafe_load(File.read(file)) rescue next
76+
specs = specs_of(doc)
77+
next unless specs.is_a?(Array)
78+
# line numbers: scan the raw file for "- name:" anchors
79+
name_lines = index_name_lines(file)
80+
specs.each_with_index do |s, i|
81+
next unless s.is_a?(Hash)
82+
yield(
83+
file: file,
84+
line: name_lines[s["name"]],
85+
name: s["name"],
86+
expected: s["expected"],
87+
environment: s["environment"],
88+
features: (s["features"] || []).map(&:to_s),
89+
complexity: s["complexity"],
90+
)
91+
end
92+
end
93+
end
94+
95+
def specs_of(doc)
96+
return doc if doc.is_a?(Array)
97+
return doc["specs"] || doc["tests"] if doc.is_a?(Hash)
98+
nil
99+
end
100+
101+
# name -> first line number (1-based) of the spec block containing it.
102+
# Handles both formats: blocks starting with `- name:` and blocks where
103+
# `name:` is a later field (e.g. `- template:` first).
104+
def index_name_lines(file)
105+
map = {}
106+
lines = File.readlines(file)
107+
# block starts: any list-item mapping opener ("- <key>:")
108+
starts = lines.each_index.select { |i| lines[i] =~ /^- [A-Za-z]/ }
109+
starts.each_with_index do |st, k|
110+
en = (k + 1 < starts.size) ? starts[k + 1] : lines.size
111+
(st...en).each do |i|
112+
if lines[i] =~ /^ name:\s*(.+?)\s*$/
113+
map[$1] = st + 1 unless map.key?($1)
114+
break
115+
end
116+
end
117+
end
118+
map
119+
end
120+
121+
def detect_markers(spec)
122+
markers = []
123+
exp = spec[:expected]
124+
markers << "hash-inspect (=>)" if exp.is_a?(String) && exp.include?("=>")
125+
markers << "symbol-inspect (:sym)" if exp.is_a?(String) && exp.match?(/[{:]\s*:[a-z_][a-z0-9_]*[=>,}]/)
126+
if exp.is_a?(String) && exp.bytes.any? { |b| b > 127 } && !exp.dup.force_encoding("UTF-8").valid_encoding?
127+
markers << "invalid-utf8 (binary)"
128+
end
129+
env = spec[:environment]
130+
markers.concat(non_string_key_markers(env, 0)) if env
131+
markers.uniq
132+
end
133+
134+
# Walk a value looking for Hashes whose keys are not all Strings.
135+
def non_string_key_markers(obj, depth)
136+
return [] if depth > 40
137+
case obj
138+
when Hash
139+
out = []
140+
weird = obj.keys.reject { |k| k.is_a?(String) }
141+
weird.group_by { |k| k.class }.each do |klass, keys|
142+
out << "non-string hash key (#{klass}; #{keys.size})"
143+
end
144+
obj.values.each { |v| out.concat(non_string_key_markers(v, depth + 1)) }
145+
out
146+
when Array
147+
obj.flat_map { |e| non_string_key_markers(e, depth + 1) }
148+
else
149+
[]
150+
end
151+
end
152+
end
153+
end
154+
155+
exit RubyTypeTagVerifier.run if $PROGRAM_NAME == __FILE__

specs/basics/nested-parameters.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ specs:
312312
This is used constantly in themes to filter collections by settings.
313313
314314
# ── case/when with dotted paths ─────────────────────────────────────────
315+
features:
316+
- ruby_types
315317

316318
- name: case_dotted_variable
317319
template: "{% case item.status %}{% when 'active' %}ON{% when 'inactive' %}OFF{% endcase %}"

specs/liquid_ruby/array_to_s.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@
77
- foo
88
- bar
99
expected: "hellohel\nlo{\"nested\"=>[\"foo\", \"bar\"]}"
10-
name: Array to_s handles escape sequences
10+
name: Array to_s handles escape sequences
11+
features:
12+
- ruby_types

0 commit comments

Comments
 (0)