wayfarer 0.4.10 → 0.5.1

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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.env +8 -8
  3. data/.github/CODEOWNERS +1 -0
  4. data/.github/actions/setup-mise/action.yaml +22 -0
  5. data/.github/workflows/push.yaml +102 -0
  6. data/.github/workflows/release.yaml +2 -2
  7. data/.github/workflows/terraform.yaml +78 -0
  8. data/.gitignore +1 -0
  9. data/AGENTS.md +291 -0
  10. data/CLAUDE.md +1 -0
  11. data/Dockerfile +3 -1
  12. data/Gemfile.lock +5 -1
  13. data/MAINTENANCE.md +17 -0
  14. data/docker-compose.yml +4 -4
  15. data/docs/cookbook/batch_routing.md +3 -1
  16. data/docs/cookbook/consent_screen.md +3 -1
  17. data/docs/cookbook/executing_javascript.md +9 -3
  18. data/docs/cookbook/navigation.md +9 -3
  19. data/docs/cookbook/querying_html.md +9 -3
  20. data/docs/cookbook/screenshots.md +6 -2
  21. data/docs/guides/{development.md → architecture.md} +1 -9
  22. data/docs/guides/cli.md +1 -1
  23. data/docs/guides/debugging.md +1 -1
  24. data/docs/guides/handlers.md +1 -1
  25. data/docs/guides/jobs.md +13 -9
  26. data/docs/guides/middleware.md +134 -0
  27. data/docs/guides/navigation.md +6 -3
  28. data/docs/guides/networking/capybara.md +3 -1
  29. data/docs/guides/networking/custom_adapters.md +3 -3
  30. data/docs/guides/networking/ferrum.md +3 -1
  31. data/docs/guides/networking/http.md +4 -2
  32. data/docs/guides/networking/selenium.md +7 -3
  33. data/docs/guides/pages.md +4 -2
  34. data/docs/guides/routing.md +16 -8
  35. data/docs/guides/tasks.md +1 -1
  36. data/docs/guides/tutorial.md +75 -2
  37. data/docs/guides/user_agents.md +10 -10
  38. data/docs/index.md +1 -1
  39. data/examples/open_systems.rb +23 -0
  40. data/lib/wayfarer/base.rb +6 -10
  41. data/lib/wayfarer/cli.rb +5 -2
  42. data/lib/wayfarer/gc.rb +0 -1
  43. data/lib/wayfarer/handler.rb +7 -5
  44. data/lib/wayfarer/middleware/controller.rb +9 -8
  45. data/lib/wayfarer/middleware/dependency_graph.rb +181 -0
  46. data/lib/wayfarer/routing/matchers/query.rb +3 -2
  47. data/lib/wayfarer.rb +23 -2
  48. data/mise.toml +5 -0
  49. data/mkdocs.yml +3 -2
  50. data/rake/lint.rake +16 -0
  51. data/rake/terraform.rake +18 -0
  52. data/spec/wayfarer/cli/routing_spec.rb +36 -6
  53. data/spec/wayfarer/integration/dependency_graph_spec.rb +111 -0
  54. data/spec/wayfarer/middleware/controller_spec.rb +34 -9
  55. data/spec/wayfarer/middleware/dependency_graph_spec.rb +367 -0
  56. data/spec/wayfarer/routing/integration_spec.rb +18 -0
  57. data/spec/wayfarer/routing/matchers/query_spec.rb +18 -0
  58. data/terraform/.gitignore +6 -0
  59. data/terraform/.terraform.lock.hcl +24 -0
  60. data/terraform/actions.tf +10 -0
  61. data/terraform/branch_protection.tf +48 -0
  62. data/terraform/github.tfstate +257 -0
  63. data/terraform/main.tf +31 -0
  64. data/terraform/variables.tf +11 -0
  65. data/terraform/versions.tf +14 -0
  66. data/wayfarer.gemspec +1 -1
  67. metadata +25 -4
  68. data/.github/workflows/lint.yaml +0 -27
  69. data/.github/workflows/tests.yaml +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39fd7cefe2a4a7e07e2c3f69c0d13f96603e983432c56616444407a04af55442
4
- data.tar.gz: 465b842759a03128413d66ff59b5116f0f7d444b193ad0929ed3a3914f55d5ad
3
+ metadata.gz: 775539013b38816a1cc15583b6416cd596a28ce72866cdbc624c2b045b4ca740
4
+ data.tar.gz: '001657096ad53a1c860bc261a80efd3204fc6683f44093fb30a8d51d4937aea6'
5
5
  SHA512:
6
- metadata.gz: 21111b9d58ab7d0286f2f0a8c575485f0553cc4ddb8f19ebcad9f82f67e70dd53290832de6a4da137839a22151f0d06a47b0c606bc139b1491c5ce3d2accf188
7
- data.tar.gz: '049cc40ee6973ecc66ce921d1ca8bf746ae9066ef88ebe3a364a0b859d125d9857daba149dfffd0b630f9addf88d2330ca3f9de5e57dab521d1dc2908a1d5cde'
6
+ metadata.gz: fc734cccedfc842f528b71adc961c01d2d104af58556b508bbd98ed80c635dff6385765b14223e765e6d40ab7c273c807b42cd9cfe44fbcb1e670a325319d53c
7
+ data.tar.gz: b4916f7291ec3cf62935cf69cc078fbe259f1b5dc5c8c877b8256b660f19d93515a76289157dff147df1f0725a4e95c457e38bef3a048adae1cda02ac5a0d34f
data/.env CHANGED
@@ -1,17 +1,17 @@
1
1
  CI=true
2
2
  WAYFARER_PORT=9876
3
3
 
4
- REDIS_IMAGE=redis
5
- REDIS_VERSION=latest
4
+ # redis:8-alpine
5
+ REDIS_IMAGE=redis@sha256:8096655e437712b07503796fb64d81359256cfcff0ab29d95a7da72863786efb
6
6
 
7
- CHROME_IMAGE=browserless/chrome
8
- CHROME_VERSION=latest
7
+ # browserless/chrome:1.61.1-chrome-stable
8
+ CHROME_IMAGE=browserless/chrome@sha256:efac47cfff3830d9a50b27d29f8bbb61949058ae336c823fbe9bd3c0d1debcc8
9
9
  CHROME_PORT=3000
10
10
 
11
- FIREFOX_IMAGE=selenium/standalone-firefox
12
- FIREFOX_VERSION=latest
11
+ # selenium/standalone-firefox:4.46.0
12
+ FIREFOX_IMAGE=selenium/standalone-firefox@sha256:68754beb3956735905fe9a49004d71c3e1de7b410885151fd8f139618e9537dc
13
13
  FIREFOX_PORT=4444
14
14
 
15
- DOCS_IMAGE=squidfunk/mkdocs-material
16
- DOCS_VERSION=latest
15
+ # squidfunk/mkdocs-material:9.7.7
16
+ DOCS_IMAGE=squidfunk/mkdocs-material@sha256:51b87149d227691486b5f08993d28c65ca7e4990010664b697265b8e6fcd5287
17
17
  DOCS_PORT=8000
@@ -0,0 +1 @@
1
+ /terraform/ @bauerd
@@ -0,0 +1,22 @@
1
+ name: Setup mise
2
+ description: Installs mise and tools
3
+ runs:
4
+ using: composite
5
+ steps:
6
+ - name: Install mise
7
+ shell: bash
8
+ run: |
9
+ sudo apt update -y && sudo apt install -y curl
10
+ sudo install -dm 755 /etc/apt/keyrings
11
+ curl -fSs https://mise.jdx.dev/gpg-key.pub | sudo tee /etc/apt/keyrings/mise-archive-keyring.asc 1> /dev/null
12
+ echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.asc] https://mise.jdx.dev/deb stable main" | sudo tee /etc/apt/sources.list.d/mise.list
13
+ sudo apt update -y
14
+ sudo apt install -y mise
15
+
16
+ - name: Add mise shims to PATH
17
+ shell: bash
18
+ run: echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
19
+
20
+ - name: Install tools
21
+ shell: bash
22
+ run: mise install -y
@@ -0,0 +1,102 @@
1
+ name: Push
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - '*'
7
+
8
+ permissions:
9
+ contents: read
10
+ pages: write
11
+ id-token: write
12
+
13
+ concurrency:
14
+ group: "pages"
15
+ cancel-in-progress: false
16
+
17
+ jobs:
18
+ lint:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
22
+
23
+ - name: Start services
24
+ run: docker compose up -d wayfarer
25
+
26
+ - name: RuboCop
27
+ run: docker compose run --rm --name test wayfarer bundle exec rake lint:rubocop
28
+
29
+ - name: Verify RubyGem platforms
30
+ if: success() || failure()
31
+ run: docker compose run --rm wayfarer bundle exec rake lint:platforms
32
+
33
+ - name: Setup mise
34
+ if: success() || failure()
35
+ uses: ./.github/actions/setup-mise
36
+
37
+ - name: Vale
38
+ if: success() || failure()
39
+ env:
40
+ VALE_STYLES_PATH: /tmp/value
41
+ run: |
42
+ vale sync
43
+ vale --glob="*.md" $(git diff --name-only origin/${{ github.base_ref }}...HEAD)
44
+
45
+ unit-tests:
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
49
+
50
+ - name: Set up Docker Compose
51
+ run: docker compose up -d
52
+
53
+ - name: Run tests
54
+ run: docker compose exec -T -e CI=$CI wayfarer bundle exec rake test
55
+
56
+ - name: Verify gem version
57
+ run: docker compose exec -T -e CI=true wayfarer bundle exec rake release:guard_versions
58
+
59
+ check-links:
60
+ runs-on: ubuntu-latest
61
+ steps:
62
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
63
+
64
+ - name: Setup mise
65
+ uses: ./.github/actions/setup-mise
66
+
67
+ - name: Install dependencies
68
+ run: pip install -r requirements.txt
69
+
70
+ - name: Build site (Strict)
71
+ run: python -m mkdocs build --strict
72
+
73
+ - name: Upload artifact
74
+ if: github.ref_name == github.event.repository.default_branch
75
+ uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
76
+ with:
77
+ path: 'site'
78
+
79
+ secret-scan:
80
+ runs-on: ubuntu-latest
81
+ steps:
82
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
83
+ with:
84
+ fetch-depth: 0
85
+
86
+ - name: Setup mise
87
+ uses: ./.github/actions/setup-mise
88
+
89
+ - name: gitleaks
90
+ run: gitleaks detect --source . --redact --no-banner
91
+
92
+ deploy-docs:
93
+ needs: check-links
94
+ if: github.ref_name == github.event.repository.default_branch
95
+ environment:
96
+ name: github-pages
97
+ url: ${{ steps.deployment.outputs.page_url }}
98
+ runs-on: ubuntu-latest
99
+ steps:
100
+ - name: Deploy to GitHub Pages
101
+ id: deployment
102
+ uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
@@ -4,10 +4,10 @@ on: workflow_dispatch
4
4
 
5
5
  jobs:
6
6
  release:
7
- if: github.ref_name == 'master' || github.ref_name == 'develop'
7
+ if: github.ref_name == 'master'
8
8
  runs-on: ubuntu-latest
9
9
  steps:
10
- - uses: actions/checkout@v2
10
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
11
11
 
12
12
  - run: docker compose up -d
13
13
 
@@ -0,0 +1,78 @@
1
+ name: Terraform
2
+
3
+ on:
4
+ push:
5
+ paths:
6
+ - 'terraform/**'
7
+ - 'mise.toml'
8
+ - '.github/workflows/terraform.yaml'
9
+ - '.github/actions/setup-mise/**'
10
+
11
+ jobs:
12
+ lint-terraform:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
16
+
17
+ - name: Setup mise
18
+ uses: ./.github/actions/setup-mise
19
+
20
+ - name: terraform fmt
21
+ run: terraform -chdir=terraform fmt -check -recursive -diff
22
+
23
+ - name: terraform init
24
+ if: success() || failure()
25
+ run: terraform -chdir=terraform init -backend=false -input=false
26
+
27
+ - name: terraform validate
28
+ if: success() || failure()
29
+ run: terraform -chdir=terraform validate
30
+
31
+ - name: checkov
32
+ if: success() || failure()
33
+ run: checkov --directory terraform --framework terraform --quiet --compact --skip-download --skip-check CKV_GIT_3
34
+
35
+ terraform-plan:
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
39
+
40
+ - name: Setup mise
41
+ uses: ./.github/actions/setup-mise
42
+
43
+ - name: terraform init
44
+ run: terraform -chdir=terraform init -input=false
45
+
46
+ - name: terraform plan
47
+ env:
48
+ GITHUB_TOKEN: ${{ secrets.TF_GITHUB_TOKEN }}
49
+ run: terraform -chdir=terraform plan -input=false -no-color
50
+
51
+ terraform-apply:
52
+ needs: [lint-terraform, terraform-plan]
53
+ if: github.ref_name == github.event.repository.default_branch
54
+ runs-on: ubuntu-latest
55
+ environment: terraform-apply
56
+ concurrency:
57
+ group: terraform-apply
58
+ cancel-in-progress: false
59
+ permissions:
60
+ contents: write
61
+ steps:
62
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
63
+ with:
64
+ token: ${{ secrets.TF_APPLY_TOKEN }}
65
+
66
+ - name: Setup mise
67
+ uses: ./.github/actions/setup-mise
68
+
69
+ - name: terraform init
70
+ run: terraform -chdir=terraform init -input=false
71
+
72
+ - name: terraform apply
73
+ env:
74
+ GITHUB_TOKEN: ${{ secrets.TF_APPLY_TOKEN }}
75
+ run: terraform -chdir=terraform apply -auto-approve -input=false -no-color
76
+
77
+ - name: commit updated state
78
+ run: rake -f rake/terraform.rake terraform:commit_state
data/.gitignore CHANGED
@@ -9,3 +9,4 @@ yard
9
9
  .tool-versions
10
10
  *.tar.gz
11
11
  styles
12
+ .opencode
data/AGENTS.md ADDED
@@ -0,0 +1,291 @@
1
+ # Wayfarer -- AI Assistant Guide
2
+
3
+ ## Project Overview
4
+
5
+ Wayfarer is a Ruby gem (v0.5.1) for versatile web crawling, built on top of **ActiveJob**. It provides a middleware-based pipeline for fetching, parsing, deduplicating, and routing web pages, with support for plain HTTP, headless Chrome (Ferrum/Capybara), and Selenium browser automation.
6
+
7
+ - **Gem name:** `wayfarer`
8
+ - **Ruby version:** 3.4.4 (enforced via `mise.toml`)
9
+ - **License:** MIT
10
+
11
+ ---
12
+
13
+ ## Technology Stack
14
+
15
+ | Layer | Technology |
16
+ |---|---|
17
+ | Language | Ruby 3.4.4 |
18
+ | Job framework | ActiveJob 7.1+ |
19
+ | Deduplication/state | Redis 5.4+ |
20
+ | Plain HTTP | net-http-persistent 4.0+ |
21
+ | Headless Chrome (CDP) | Ferrum 0.17+ |
22
+ | Browser (Selenium) | selenium-webdriver 4.35+ |
23
+ | Browser wrapper | Capybara 3.4+ |
24
+ | HTML/XML parsing | Nokogiri 1.18+ |
25
+ | Meta-tag parsing | MetaInspector 5.16+ |
26
+ | URL routing/matching | Mustermann 3.0+ |
27
+ | URL normalization | Addressable 2.8+ |
28
+ | Autoloading | Zeitwerk 2.7+ |
29
+ | CLI | Thor 1.0+ |
30
+ | Connection pooling | connection_pool 2.5+ |
31
+ | Testing | RSpec 3.13+ |
32
+ | Linting | RuboCop 1.80+ |
33
+ | Documentation | MkDocs (Material theme) + YARD |
34
+
35
+ ---
36
+
37
+ ## Development Workflow
38
+
39
+ ### Initial Setup
40
+
41
+ ```bash
42
+ mise install # Install Ruby 3.4.4, Python 3.13, vale
43
+ bundle install
44
+ ```
45
+
46
+ ### Docker (recommended for CI-equivalent runs)
47
+
48
+ ```bash
49
+ docker compose up -d
50
+ docker compose exec -T -e CI=true wayfarer bundle exec rake test
51
+ docker compose run --rm wayfarer bundle exec rake lint:rubocop
52
+ ```
53
+
54
+ ### Interactive Console
55
+
56
+ ```bash
57
+ bundle exec rake console # Pry REPL with wayfarer loaded
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Running Tests
63
+
64
+ Tests use **RSpec** with tag-based filtering. Always run via Rake, not `rspec` directly.
65
+
66
+ ```bash
67
+ bundle exec rake test # All tests
68
+ bundle exec rake test:isolated # No browser/Redis -- fastest
69
+ bundle exec rake test:integration # Redis + CLI
70
+ bundle exec rake test:selenium # Selenium browser
71
+ bundle exec rake test:ferrum # Ferrum (Chrome CDP)
72
+ bundle exec rake test:cli # CLI tests
73
+ ```
74
+
75
+ **RSpec tags:** `:selenium`, `:ferrum`, `:cli`, `:redis`. Untagged tests run in `test:isolated`.
76
+
77
+ A Sinatra test server starts automatically on port 9876 before the suite. FactoryBot factories are in `spec/factories/`. The shared context `"with Redis"` is auto-included for `:redis`-tagged examples.
78
+
79
+ ---
80
+
81
+ ## Linting
82
+
83
+ ```bash
84
+ bundle exec rake lint:rubocop
85
+ ```
86
+
87
+ Key RuboCop settings: 120-char line limit, double-quoted strings, outdented access modifiers, max 20-line methods, AbcSize <= 30, RSpec max 5 expectations/example and max 5 nested groups.
88
+
89
+ ---
90
+
91
+ ## Common Ruby Patterns
92
+
93
+ ### ActiveSupport::Concern for Mixins
94
+
95
+ The codebase uses `extend ActiveSupport::Concern` throughout to structure reusable modules with `included` and `class_methods` blocks. The `included` block runs on inclusion; `class_methods` defines class-level behavior without verbose `def self.included` boilerplate.
96
+
97
+ ```ruby
98
+ module Base
99
+ extend ActiveSupport::Concern
100
+
101
+ included do
102
+ include Wayfarer::Middleware::Controller
103
+ use Wayfarer::Middleware::Redis
104
+ use Wayfarer::Middleware::UriParser
105
+ # ...
106
+ end
107
+
108
+ class_methods do
109
+ def crawl(url, batch: SecureRandom.uuid)
110
+ Task.new(url, batch).tap { |task| perform_later(task) }
111
+ end
112
+ end
113
+ end
114
+ ```
115
+
116
+ ### Middleware Chain via Recursive Lambda
117
+
118
+ `Middleware::Chain` composes middleware as a recursive lambda. Each middleware receives a block representing the rest of the chain, and calls `yield` to continue or returns early to halt.
119
+
120
+ ```ruby
121
+ def call(task)
122
+ traverse = lambda do |i|
123
+ if middlewares[i]
124
+ middlewares[i].call(task) { traverse.call(i + 1) }
125
+ elsif block_given?
126
+ yield
127
+ end
128
+ end
129
+ traverse.call(0)
130
+ end
131
+ ```
132
+
133
+ ### Strategy Pattern via Module Inclusion
134
+
135
+ `Networking::Strategy` defines the abstract interface (raising `NotImplementedError`) and shared helpers (`success`, `redirect`). Concrete agents (`HTTP`, `Ferrum`, `Selenium`, `Capybara`) include the mixin and override the required methods.
136
+
137
+ ```ruby
138
+ module Strategy
139
+ extend ActiveSupport::Concern
140
+
141
+ def fetch(instance, url) = navigate(instance, url).then { live(instance) }
142
+ def navigate(_instance, _url) = raise NotImplementedError
143
+ def create = raise NotImplementedError
144
+ def destroy(_instance); end
145
+
146
+ private
147
+ def success(...) = Networking::Result::Success.new(Page.new(...))
148
+ def redirect(...) = Networking::Result::Redirect.new(...)
149
+ end
150
+ ```
151
+
152
+ ### Fluent Routing DSL
153
+
154
+ `Routing::DSL` provides chainable builder methods. Each method calls `child_route`, which creates a child `Route` and appends it to `children`. Inline keyword arguments and blocks are both supported, enabling two equivalent syntaxes:
155
+
156
+ ```ruby
157
+ # Inline
158
+ route.host("example.com", path: "/articles/:id", to: :article)
159
+
160
+ # Block
161
+ route.host("example.com") do
162
+ path "/articles/:id", to: :article
163
+ end
164
+ ```
165
+
166
+ The method signature uses `**` and `&` forwarding to keep DSL methods concise. Last `.to()` call wins when routes conflict.
167
+
168
+ ### Visitor Pattern for Tree Traversal
169
+
170
+ Routes form a tree searched depth-first by `Routing::PathFinder`. Routes call `accept(visitor)`, which calls `enter`, `visit`, and `leave` on the visitor. This separates traversal logic from matching logic while allowing the visitor to accumulate mutable state (params, matched actions).
171
+
172
+ ```ruby
173
+ # In Route
174
+ def accept(visitor)
175
+ visitor.enter(self)
176
+ return visitor.leave unless visitor.visit(self)
177
+ children.each { |child| child.accept(visitor) }
178
+ visitor.leave
179
+ end
180
+ ```
181
+
182
+ ### Dynamic Method Generation via Module Subclasses
183
+
184
+ `Logging.emit` returns an anonymous `Module` subclass whose `included` hook uses `define_method` to inject a `log` method. This lets each middleware declare its log messages declaratively at the class level.
185
+
186
+ ```ruby
187
+ class Router
188
+ include Wayfarer::Logging.emit(
189
+ mismatch: [:info, "No matching route"],
190
+ match: [:info, "Routing to %<action>s"],
191
+ )
192
+ end
193
+ ```
194
+
195
+ ### Singleton + Connection Pooling
196
+
197
+ `Networking::Pool` is a `Singleton` wrapping `ConnectionPool`. Cleanup is registered via `at_exit`. A `class_attribute :finalizer` lambda allows customizing shutdown behavior without subclassing. `ConnectionPool::TimeoutError` is wrapped into the gem's own error class at the boundary.
198
+
199
+ ### Configuration Syntax
200
+
201
+ Use bracket syntax `Wayfarer.config[:key][:subkey]` instead of `dig` for predictable configuration access.
202
+
203
+ ### Value Objects with Declarative Stringify
204
+
205
+ Value objects (`Task`, `Page`) include lightweight mixins like `Stringify` and `KV`. `stringify :url, :batch` registers which attributes appear in `#to_s`/`#inspect`. Custom `ActiveJob::Serializers::ObjectSerializer` subclasses handle serialization to/from JSON.
206
+
207
+ ### Inline Error Classes
208
+
209
+ Lightweight custom errors are defined inline at module scope:
210
+
211
+ ```ruby
212
+ UserAgentTimeoutError = Class.new(StandardError)
213
+ ```
214
+
215
+ Rescue lists are built dynamically from configuration:
216
+
217
+ ```ruby
218
+ rescue *strategy.class.renew_on, *Wayfarer.config[:network][:renew_on] => e
219
+ renew
220
+ end
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Documentation
226
+
227
+ ### Writing Style
228
+
229
+ Documentation in `docs/` must strictly mimic the voice, tone, and typography of existing guides. Key rules:
230
+
231
+ - **Address the reader directly** using second person ("you can", "use the")
232
+ - **Use contractions** ("isn't", "doesn't", "it's") -- Vale enforces `Google.Contractions`
233
+ - **Prefer active voice** over passive voice ("the graph compiles" not "the chain is compiled")
234
+ - **Use ASCII characters** -- write `word--word` not Unicode em dashes. Avoid Unicode dashes, quotes, and other non-ASCII punctuation unless explicitly required.
235
+ - **Headings use sentence-style capitalization** -- `## Adding middleware` not `## Adding Middleware`
236
+ - **Keep paragraphs short** -- one concept per paragraph, 2-3 sentences max
237
+ - **Code references use backticks** -- `` `use` ``, `` `dependency_graph.remove` ``
238
+ - **Cross-link related guides** -- `[tasks](tasks.md)`, `[handlers](handlers.md)`
239
+ - **Use mkdocs-material admonitions** for callouts -- `!!! note`, `!!! warning`, `!!! danger`
240
+
241
+ ### Linting with Vale
242
+
243
+ Documentation is linted with [Vale](https://vale.sh) using the Google style guide. Configuration lives in `.vale.ini` and domain vocabulary in `styles/config/vocabularies/Wayfarer/accept.txt`.
244
+
245
+ ```bash
246
+ vale sync # Download style packages (run once)
247
+ vale docs/guides/middleware.md # Lint a specific file
248
+ vale docs/ # Lint all documentation
249
+ ```
250
+
251
+ Fix all errors and warnings. Suggestions (passive voice, acronym expansion) are acceptable when they match the existing documentation style.
252
+
253
+ ---
254
+
255
+ ## Pinning external artifacts
256
+
257
+ Reference every container image and OCI artifact by immutable `@sha256:...`
258
+ digest, not by tag. Tags are mutable and can silently change what runs; digests
259
+ can't. This applies to `Dockerfile` `FROM` lines, image references in
260
+ `docker-compose.yml` and `.env`, and anything else in the repository that pulls
261
+ an image.
262
+
263
+ Put the human-readable tag on a comment line above the pinned reference so
264
+ future upgrades know which version was previously in use. Resolve a digest with
265
+ a manifest HEAD request against Docker Hub, or with `crane digest <image>:<tag>`
266
+ or `regctl image digest <image>:<tag>`.
267
+
268
+ Pin GitHub Actions the same way. Use the commit SHA in the `uses:` line with the
269
+ release tag as a trailing comment, e.g.:
270
+
271
+ ```yaml
272
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
273
+ ```
274
+
275
+ Tools installed via mise are exempt; the mise lock file already pins their
276
+ integrity. Terraform providers are exempt for the same reason: `.terraform.lock.hcl`
277
+ pins them by version and hash.
278
+
279
+ ---
280
+
281
+ ## Key Conventions
282
+
283
+ - **All files:** `# frozen_string_literal: true` at the top
284
+ - **Strings:** Always double-quoted
285
+ - **Modules vs Classes:** Namespaced under `Wayfarer::*`; use `ActiveSupport::Concern` for mixins
286
+ - **Autoloading:** Zeitwerk with custom inflections -- file names must match constant names after inflection (`cli` -> `CLI`, `dsl` -> `DSL`, `http` -> `HTTP`, `uri` -> `URI`, `url` -> `URL`, etc.)
287
+ - **Numeric comparisons:** Use `== 0` not `.zero?`
288
+ - **No module documentation cop** -- use YARD `@param`/`@return`/`@yield`/`@example`/`@see` tags to document complex logic, public APIs, and overridden template methods instead. Use comments to explain *why* something is done.
289
+ - **Specs:** Tag browser tests (`:selenium`, `:ferrum`), Redis tests (`:redis`), CLI tests (`:cli`); all others run isolated
290
+ - **Do not skip RuboCop** without an inline disable comment with justification
291
+ - **Version consistency:** `VERSION::STRING` in `lib/wayfarer.rb` must match `wayfarer.gemspec`; CI enforces via `rake release:guard_versions`
data/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ AGENTS.md
data/Dockerfile CHANGED
@@ -1,7 +1,9 @@
1
- FROM ruby:3.4.4-alpine
1
+ # ruby:3.4.4-alpine
2
+ FROM ruby@sha256:81096866ac15f906adc79867da3ed97a2aa271d6149363e216a174701345c53b
2
3
  RUN apk add --no-cache build-base git yaml-dev zlib-dev
3
4
  RUN git config --global --add safe.directory /opt/app
4
5
  WORKDIR /opt/app
6
+ ENV BUNDLE_FROZEN=true
5
7
  COPY Gemfile Gemfile.lock *.gemspec ./
6
8
  RUN bundle install
7
9
  COPY . .
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- wayfarer (0.4.10)
4
+ wayfarer (0.5.1)
5
5
  activejob (>= 7.1)
6
6
  addressable (~> 2.8)
7
7
  capybara (~> 3.4)
@@ -98,6 +98,7 @@ GEM
98
98
  concurrent-ruby (~> 1.1)
99
99
  webrick (~> 1.7)
100
100
  websocket-driver (~> 0.7)
101
+ ffi (1.17.2-arm64-darwin)
101
102
  ffi (1.17.2-x86_64-darwin)
102
103
  ffi (1.17.2-x86_64-linux-musl)
103
104
  globalid (1.2.1)
@@ -148,6 +149,8 @@ GEM
148
149
  net-http-persistent (4.0.6)
149
150
  connection_pool (~> 2.2, >= 2.2.4)
150
151
  nio4r (2.7.4)
152
+ nokogiri (1.18.9-arm64-darwin)
153
+ racc (~> 1.4)
151
154
  nokogiri (1.18.9-x86_64-darwin)
152
155
  racc (~> 1.4)
153
156
  nokogiri (1.18.9-x86_64-linux-musl)
@@ -302,6 +305,7 @@ GEM
302
305
  zlib (3.2.1)
303
306
 
304
307
  PLATFORMS
308
+ arm64-darwin-25
305
309
  x86_64-darwin-24
306
310
  x86_64-linux-musl
307
311
 
data/MAINTENANCE.md ADDED
@@ -0,0 +1,17 @@
1
+ # Maintenance
2
+
3
+ ## CI environment variables
4
+
5
+ | Key | Value | Description |
6
+ |---|---|---|
7
+ | `GEM_HOST_API_KEY` | RubyGems API key | Publishes the `wayfarer` gem to rubygems.org from the `release` workflow. |
8
+ | `TF_GITHUB_TOKEN` | Fine-grained PAT | Renders `terraform plan` against the live GitHub API in the `terraform-plan` job. Scopes: Metadata (R), Administration (R), Actions (R). |
9
+ | `TF_APPLY_TOKEN` | Fine-grained PAT | Runs `terraform apply` and pushes updated state back to `master` in the `terraform-apply` job. Scopes: Metadata (R), Administration (RW), Actions (RW), Contents (RW). |
10
+
11
+ ## Releases
12
+
13
+ 1. Bump versions:
14
+ * `Wayfarer::VERSION` in `lib/wayfarer.rb`
15
+ * RubyGem version in `wayfarer.gemspec`
16
+ * Run `bundle install` to regenerate `Gemfile.lock`
17
+ 2. Push to `master` and run the manual `release` workflow
data/docker-compose.yml CHANGED
@@ -16,22 +16,22 @@ services:
16
16
  - docs
17
17
 
18
18
  redis:
19
- image: ${REDIS_IMAGE}:${REDIS_VERSION}
19
+ image: ${REDIS_IMAGE}
20
20
 
21
21
  chrome:
22
- image: ${CHROME_IMAGE}:${CHROME_VERSION}
22
+ image: ${CHROME_IMAGE}
23
23
  ports:
24
24
  - "${CHROME_PORT}:${CHROME_PORT}"
25
25
 
26
26
  firefox:
27
- image: ${FIREFOX_IMAGE}:${FIREFOX_VERSION}
27
+ image: ${FIREFOX_IMAGE}
28
28
  ports:
29
29
  - "${FIREFOX_PORT}:${FIREFOX_PORT}"
30
30
  volumes:
31
31
  - /dev/shm:/dev/shm
32
32
 
33
33
  docs:
34
- image: ${DOCS_IMAGE}:${DOCS_VERSION}
34
+ image: ${DOCS_IMAGE}
35
35
  volumes:
36
36
  - ./:/docs
37
37
  ports:
@@ -4,7 +4,9 @@
4
4
  # Create a record in an external database and store the hostname
5
5
  record = Database::Row.create(hostname: "example.com")
6
6
 
7
- class DummyJob < Wayfarer::Base
7
+ class DummyJob < ActiveJob::Base
8
+ include Wayfarer::Base
9
+
8
10
  route do |hostname|
9
11
  host hostname, to: :index
10
12
  end
@@ -8,7 +8,9 @@ iframe, clicked, and makes the live page behind the screen accessible to
8
8
  ```ruby
9
9
  Wayfarer.config[:network][:agent] = :ferrum
10
10
 
11
- class DummyJob < Wayfarer::Base
11
+ class DummyJob < ActiveJob::Base
12
+ include Wayfarer::Base
13
+
12
14
  route.to :index, host: "example.com"
13
15
 
14
16
  before_action if: :consent_required? do