Skip to content

Commit 85b2f06

Browse files
committed
Reframe liquid-spec as an agent implementation curriculum
1 parent 740d14f commit 85b2f06

20 files changed

Lines changed: 318 additions & 137 deletions

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,14 @@ When changing early complexity scores or adding beginner specs, play dumb and ve
197197

198198
```bash
199199
# Source echo adapter: should pass only raw text, then fail on first object output
200-
liquid-spec run /tmp/echo_adapter.rb -s basics --list-passed
200+
liquid-spec run /tmp/echo_adapter.rb --list-passed
201201

202202
# Always-empty adapter: may pass many empty-output specs accidentally; check max complexity
203-
liquid-spec run /tmp/empty_adapter.rb -s basics --json --list-passed > /tmp/empty-results.json
203+
liquid-spec run /tmp/empty_adapter.rb --json --list-passed > /tmp/empty-results.json
204204

205205
# Always-raise adapters: should fail at complexity 0 with clear Error + Hint output
206-
liquid-spec run /tmp/raise_compile_adapter.rb -s basics
207-
liquid-spec run /tmp/raise_render_adapter.rb -s basics
206+
liquid-spec run /tmp/raise_compile_adapter.rb
207+
liquid-spec run /tmp/raise_render_adapter.rb
208208
```
209209

210210
Use `--list-passed` to inspect accidental passes and `--json` for tooling. Prefer `Complexity level cleared` (or JSON `max_complexity_reached`) over raw pass count when judging partial or deliberately naive adapters.

README.md

Lines changed: 170 additions & 63 deletions
Large diffs are not rendered by default.

SPECS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -472,14 +472,14 @@ Saved specs go to `/tmp/liquid-spec-{date}.yml`. Browse them:
472472
# Run all specs
473473
bundle exec rake run
474474
475-
# Run specific suite
476-
liquid-spec run examples/liquid_ruby.rb -s basics
475+
# Run the default ordered ramp
476+
liquid-spec run examples/liquid_ruby.rb
477477
478478
# Filter by name pattern
479479
liquid-spec run examples/liquid_ruby.rb -n tablerow
480480
481481
# Compare multiple adapters
482-
liquid-spec matrix --adapters=a.rb,b.rb -s basics
482+
liquid-spec matrix --adapters=a.rb,b.rb
483483
```
484484

485485
---

docs/implementers/curriculum.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Useful checkpoints:
123123
liquid-spec run my_adapter.rb
124124

125125
# Audit accidental passes when building early behavior
126-
liquid-spec run my_adapter.rb -s basics --list-passed --json
126+
liquid-spec run my_adapter.rb --list-passed --json
127127

128128
# Validate liquid-spec/spec changes when contributing to this repository
129129
rake check

docs/json-rpc-protocol.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Called once when the connection starts.
3737
"version": "1.0",
3838
"implementation": "my-liquid",
3939
"liquid_version": "6.0.0",
40-
"features": ["runtime_drops"]
40+
"features": ["drops"]
4141
}
4242
}
4343
```
@@ -47,10 +47,10 @@ Called once when the connection starts.
4747
`features` is informational metadata reported by the subprocess. The Ruby adapter file still controls which specs run via `config.missing_features` because spec selection happens in liquid-spec, not inside the subprocess.
4848

4949
Common feature names:
50-
- `runtime_drops` - Supports bidirectional drop callbacks (see Drop Callbacks section)
50+
- `drops` - Supports Liquid drop objects. Portable standard test drops are documented in `docs/test_drops.md`; the callback protocol below supports host-owned runtime drops.
5151
- `lax_parsing` - Supports `error_mode: lax`
5252

53-
For a minimal JSON-RPC server, start with `features: []` and set the adapter's `config.missing_features` to skip unsupported capabilities such as `:runtime_drops`, `:lax_parsing`, `:ruby_types`, `:ruby_drops`, `:binary_data`, and Shopify-specific features.
53+
For a minimal JSON-RPC server, start with `features: []` and set the adapter's `config.missing_features` to skip unsupported capabilities such as `:drops`, `:lax_parsing`, `:ruby_types`, `:ruby_drops`, `:binary_data`, and Shopify-specific features.
5454

5555
### `compile`
5656

docs/json-rpc-type-transport.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ params. The `instantiate:ClassName.new(arg)` format passes a single argument.
4343
| `LoaderDrop` | Lazy-loading collection | `ruby_drops` |
4444
| `ArrayDrop` | Enumerable drop | `ruby_drops` |
4545
| `TestEnumerable` | Enumerable for loop tests | `ruby_drops` |
46-
| `ErrorDrop` | Raises errors during render | `runtime_drops` |
46+
| `ErrorDrop` | Raises errors during render | `drops` |
4747
| `StubTemplateFactory` | Template factory for partials | `template_factory` |
4848
| `SettingsDrop` | Shopify settings drop | `shopify_objects` |
4949
| `SecurityVictimDrop` | Security boundary tests | `ruby_drops` |
@@ -122,12 +122,12 @@ function unwrap_value(value):
122122
return value
123123
```
124124

125-
> **Note on `runtime_drops`:** The bidirectional RPC callback mechanism
126-
> (`drop_get`, `drop_call`, `drop_iterate`) is an alternative to boxed
127-
> objects. It's more flexible (the drop logic stays in Ruby) but slower
128-
> (every access is a round-trip) and more complex. For most implementations,
129-
> boxed objects are recommended. Only use `runtime_drops` if you need
130-
> drop behavior that can't be replicated with a static boxed implementation.
125+
> **Note on `drops`:** Portable standard test drops should normally be
126+
> implemented as native boxed objects as described in `docs/test_drops.md`.
127+
> The bidirectional RPC callback mechanism (`drop_get`, `drop_call`,
128+
> `drop_iterate`) is available for host-owned runtime drops whose behavior
129+
> cannot be reproduced statically. Both forms are part of drop support; there
130+
> is no separate feature for runtime-backed drops.
131131

132132
**With `ruby_types`** (adapter handles Ruby-specific value formats):
133133
- You must handle `_ruby_type` markers (see below).
@@ -281,15 +281,15 @@ Do you need Shopify production compatibility?
281281
| (none) | Basic compile + render | ~4900 | Moderate |
282282
| `ruby_drops` | Drop callbacks + `_rpc_drop` handling | ~200 | Hard |
283283
| `ruby_types` | `_ruby_type` markers + Hash#inspect format | ~50 | Very hard (non-Ruby) |
284-
| `runtime_drops` | Bidirectional drop callbacks | ~20 | Hard |
284+
| `drops` | Standard test drops; callbacks when host-owned runtime objects are needed | ~20 | Hard |
285285
| `template_factory` | Template creation callbacks | ~10 | Moderate |
286286
| `lax_parsing` | Lax error mode (inline errors) | ~100 | Moderate |
287287
| `strict2_parsing` | Strict2 error mode | ~50 | Easy |
288288
289289
**Recommendation for non-Ruby implementations:**
290-
1. Start with `missing_features = [:ruby_types, :ruby_drops, :binary_data, :runtime_drops, :template_factory, :lax_parsing, ...shopify_*]`
291-
2. Remove `:lax_parsing` first (easy, big gain).
292-
3. Remove `:ruby_drops` next (requires drop callbacks, big gain).
293-
4. Remove `:runtime_drops` (requires bidirectional callbacks).
290+
1. Start with `missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :lax_parsing, ...shopify_*]`
291+
2. Remove `:lax_parsing` when legacy lenient parsing is part of your target.
292+
3. Remove `:drops` after implementing the portable standard test-drop library.
293+
4. Remove `:ruby_drops` only when Ruby-specific host objects are part of your target.
294294
5. Remove `:ruby_types` last (requires Ruby inspect emulation, small gain, hard).
295295
6. Only remove `:ruby_types` if you must be Shopify-production-compatible.

examples/json_rpc_ruby_liquid.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
end
4444

4545
LiquidSpec.configure do |config|
46-
# JSON-RPC adapters support core features including runtime_drops
46+
# This JSON-RPC adapter supports core behavior including drops.
4747
# because they implement bidirectional callbacks
4848
config.missing_features = [:ruby_types, :ruby_drops, :binary_data, :lax_parsing, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access]
4949
end

examples/liquid_c.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def parse_expression(markup, safe: false)
3636
end
3737

3838
LiquidSpec.configure do |config|
39-
config.missing_features = [:runtime_drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access]
39+
config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access]
4040
end
4141

4242
LiquidSpec.compile do |ctx, source, parse_options|

examples/liquid_c_strict.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
end
2525

2626
LiquidSpec.configure do |config|
27-
config.missing_features = [:runtime_drops, :ruby_types, :ruby_drops, :binary_data, :lax_parsing, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access]
27+
config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :lax_parsing, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access]
2828
end
2929

3030
LiquidSpec.compile do |ctx, source, parse_options|

lib/liquid/spec/cli/init.rb

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ module Init
6161
# @param ctx [Hash] Adapter context (from setup block)
6262
# @param source [String] The Liquid template source code
6363
# @param options [Hash] Parse options (e.g., :error_mode, :line_numbers)
64-
# @return [Object] Your compiled template object (passed to render)
64+
# Store the compiled template in ctx for the render block.
6565
#
6666
# ERROR MODES
6767
# -----------
@@ -90,18 +90,17 @@ module Init
9090
options[:error_mode] ||= :strict2
9191
9292
# Example for Shopify/liquid:
93-
# Liquid::Template.parse(source, options)
93+
# ctx[:template] = Liquid::Template.parse(source, options)
9494
#
9595
# Example for a custom implementation:
96-
# MyLiquid::Template.new(source, error_mode: options[:error_mode])
96+
# ctx[:template] = MyLiquid::Template.new(source, error_mode: options[:error_mode])
9797
#
9898
raise NotImplementedError, "Implement LiquidSpec.compile to parse templates"
9999
end
100100
101-
# Called to render a compiled template with the given context.
101+
# Called to render the template stored in ctx by the compile block.
102102
#
103103
# @param ctx [Hash] Adapter context (from setup block)
104-
# @param template [Object] The compiled template (from compile block)
105104
# @param assigns [Hash] Variables available as {{ var }}
106105
# @param options [Hash] Render options (:registers, :strict_errors, :error_mode)
107106
# @return [String] The rendered output
@@ -113,17 +112,17 @@ module Init
113112
# to strict_errors: true (raise) — only specs that opt into render_errors
114113
# set it to false. Pass it through to your implementation so both modes work.
115114
#
116-
LiquidSpec.render do |ctx, template, assigns, options|
115+
LiquidSpec.render do |ctx, assigns, options|
117116
# Example for Shopify/liquid:
118117
# context = Liquid::Context.build(
119118
# static_environments: assigns,
120119
# registers: Liquid::Registers.new(options[:registers] || {}),
121120
# rethrow_errors: options[:strict_errors]
122121
# )
123-
# template.render(context)
122+
# ctx[:template].render(context)
124123
#
125124
# Example for a custom implementation:
126-
# template.render(assigns, raise_errors: options[:strict_errors])
125+
# ctx[:template].render(assigns, raise_errors: options[:strict_errors])
127126
#
128127
raise NotImplementedError, "Implement LiquidSpec.render to render templates"
129128
end
@@ -348,24 +347,26 @@ module Init
348347
LiquidSpec.compile do |ctx, source, options|
349348
options[:error_mode] ||= :strict2
350349
ctx[:adapter].spec_context = ctx
351-
ctx[:adapter].compile(source, options)
350+
ctx[:template_id] = ctx[:adapter].compile(source, options)
352351
end
353352
354353
# Render errors are raised by default (strict_errors: true). Only specs
355354
# that opt into render_errors set strict_errors to false.
356-
LiquidSpec.render do |ctx, template_id, assigns, options|
355+
LiquidSpec.render do |ctx, assigns, options|
357356
ctx[:adapter].spec_context = ctx
358-
ctx[:adapter].render(template_id, assigns, options)
357+
ctx[:adapter].render(ctx[:template_id], assigns, options)
359358
end
360359
RUBY
361360

362-
def self.agents_md_content(filename, json_rpc: false)
361+
def self.agents_md_content(filename, json_rpc: false, both: false)
362+
json_rpc_guide = json_rpc || both
363+
363364
<<~MARKDOWN
364365
# Implementing a Liquid Template Engine
365366
366367
You are building a production-grade [Liquid](https://shopify.github.io/liquid/)
367368
template engine. This directory is wired to **liquid-spec**, which defines what
368-
"correct" means: 7,000+ executable specs, ordered by complexity from trivial
369+
"correct" means: thousands of executable specs, ordered by complexity from trivial
369370
passthrough (score 0) to full production compatibility (score 1000). The suite
370371
is a curriculum for observable behavior, not a mandated internal architecture:
371372
tree-walking, bytecode, compiled templates, strict-first, and compatibility-first
@@ -379,7 +380,7 @@ def self.agents_md_content(filename, json_rpc: false)
379380
shims that exist only so liquid-spec can exercise your implementation. Build your
380381
library as a library; wire up the adapter afterward.
381382
382-
#{json_rpc ? non_ruby_notice : ""}
383+
#{json_rpc_guide ? non_ruby_notice : ""}
383384
## Which adapter file?
384385
385386
- **Implementing in Ruby?** Use `liquid_adapter.rb`. It calls your code directly
@@ -517,7 +518,7 @@ def self.agents_md_content(filename, json_rpc: false)
517518
518519
A common low-risk route is tokenizer → parser → node tree → tree-walking
519520
renderer with a scope stack. A compiler/VM is also fine if that fits your project,
520-
but correctness across 7,000+ specs is the hard part, so keep the observable
521+
but correctness across thousands of specs is the hard part, so keep the observable
521522
semantics easy to test. Two things ARE worth designing in from day one, because
522523
they weave through everything and are painful to retrofit:
523524
@@ -541,7 +542,13 @@ def self.agents_md_content(filename, json_rpc: false)
541542
| 4 | 190-400 | Partials/filesystem, scope interactions, generated compatibility breadth |
542543
| 5 | 500-900 | Parser error matrices, error-model matrix, recursion/deep nesting, security/date/time/Ruby quirks |
543544
| 6 | 1000 | Production recordings and unscored mature-compatibility checks |
544-
#{json_rpc ? json_rpc_section(filename) : ruby_adapter_section}
545+
#{if both
546+
"#{ruby_adapter_section}\n#{json_rpc_section(filename)}"
547+
elsif json_rpc
548+
json_rpc_section(filename)
549+
else
550+
ruby_adapter_section
551+
end}
545552
546553
## Local specs for development
547554
@@ -569,7 +576,6 @@ def self.agents_md_content(filename, json_rpc: false)
569576
liquid-spec run #{filename} # the loop
570577
liquid-spec run #{filename} -n case # only specs matching a pattern
571578
liquid-spec run #{filename} -n /^case_b/ # regex form
572-
liquid-spec run #{filename} -s basics # one suite
573579
liquid-spec run #{filename} --list-suites # what suites exist
574580
liquid-spec run #{filename} -l # list specs without running
575581
liquid-spec run #{filename} --list-passed # audit accidental passes
@@ -608,9 +614,10 @@ def self.ruby_adapter_section
608614
end
609615
```
610616
611-
### compile(ctx, source, options) → template
617+
### compile(ctx, source, options)
612618
613-
Parse the source string into your template representation. Called once per template.
619+
Parse the source string and store your template representation in `ctx`.
620+
Called once per template.
614621
615622
```ruby
616623
LiquidSpec.compile do |ctx, source, options|
@@ -619,21 +626,21 @@ def self.ruby_adapter_section
619626
# :line_numbers - true/false
620627
# :file_system - for include/render tags
621628
options[:error_mode] ||= :strict2
622-
MyLiquid::Template.parse(source, environment: ctx[:environment])
629+
ctx[:template] = MyLiquid::Template.parse(source, environment: ctx[:environment])
623630
end
624631
```
625632
626-
### render(ctx, template, assigns, options) → string
633+
### render(ctx, assigns, options) → string
627634
628-
Render a compiled template with variables. Called for each test.
635+
Render the compiled template from `ctx` with variables. Called for each test.
629636
630637
```ruby
631-
LiquidSpec.render do |ctx, template, assigns, options|
638+
LiquidSpec.render do |ctx, assigns, options|
632639
# assigns: Hash of variables available as {{ var }}
633640
# options may include:
634641
# :registers - internal state (file_system, etc.)
635642
# :strict_errors - whether to raise or capture errors
636-
template.render(assigns)
643+
ctx[:template].render(assigns)
637644
end
638645
```
639646
MARKDOWN
@@ -850,18 +857,18 @@ def self.json_rpc_section(filename)
850857
# for new implementations unless you need that compatibility.
851858
LiquidSpec.compile do |ctx, source, options|
852859
options[:error_mode] ||= :strict2
853-
Liquid::Template.parse(source, **options)
860+
ctx[:template] = Liquid::Template.parse(source, **options)
854861
end
855862
856863
# Render errors are raised by default (strict_errors: true). Only specs
857864
# that opt into render_errors set strict_errors to false.
858-
LiquidSpec.render do |ctx, template, assigns, options|
865+
LiquidSpec.render do |ctx, assigns, options|
859866
context = Liquid::Context.build(
860867
static_environments: assigns,
861868
registers: Liquid::Registers.new(options[:registers] || {}),
862869
rethrow_errors: options[:strict_errors]
863870
)
864-
template.render(context)
871+
ctx[:template].render(context)
865872
end
866873
RUBY
867874

@@ -898,8 +905,10 @@ def self.run(args)
898905
generate_adapter("liquid_adapter.rb", :basic)
899906
generate_adapter("liquid_adapter_jsonrpc.rb", :json_rpc)
900907

901-
# Create AGENTS.md once (covers both adapters)
902-
create_agents_md("liquid_adapter.rb", json_rpc: false)
908+
# Create AGENTS.md once. Lead with the cross-language adapter because
909+
# agents building in TypeScript/Rust/Go/Python need the protocol guide;
910+
# include the direct Ruby adapter section as well.
911+
create_agents_md("liquid_adapter_jsonrpc.rb", json_rpc: true, both: true)
903912

904913
puts ""
905914
puts "Next steps:"
@@ -938,12 +947,12 @@ def self.generate_adapter(filename, template_type)
938947
puts "Created #{filename} (executable)"
939948
end
940949

941-
def self.create_agents_md(filename, json_rpc:)
950+
def self.create_agents_md(filename, json_rpc:, both: false)
942951
agents_filename = "AGENTS.md"
943952
if File.exist?(agents_filename)
944953
puts "AGENTS.md already exists, skipping"
945954
else
946-
agents_md = agents_md_content(filename, json_rpc: json_rpc)
955+
agents_md = agents_md_content(filename, json_rpc: json_rpc, both: both)
947956
File.write(agents_filename, agents_md)
948957
puts "Created AGENTS.md"
949958
end

0 commit comments

Comments
 (0)