woods 1.0.0 → 1.1.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab164a85b76d9c97fc6142836da5349a444e9c62f507622fb327f5cc8f434ed4
4
- data.tar.gz: 66752a95ddb4183a6f78d47417690242cfc3ad2bdfc622b8740fe2fbc388658e
3
+ metadata.gz: ec72d151147ef4b43df866b6ebee48f52a26915efdb44df7bed08c24646cfb7a
4
+ data.tar.gz: f03a813c3f525eeba7a95269770ee80ee4c4fd91017561529800745a241cfd9c
5
5
  SHA512:
6
- metadata.gz: 2d53024eefb62544ba536f23b1c9f36bebab988fc75223ef72e1d2ffd1d2ed0b46b2507781b040726b8059d14c9f6eefa3faa1c4d6b0a4b6c5019905ef41675d
7
- data.tar.gz: 8d5c7a1e7ab4c7b401e61140a9ec5bea06848244d08192f05b0cc088a93980b3208cf3f22a0319545857051dc0b2a234f4d4c2ef8a5789ef108080f179aa6f99
6
+ metadata.gz: 75608caf708f2f4ef913653af543e983ac68abb9ab0028f9e967bda13af4e5b0c47f73a1d440696b453fb08afc69ab56cf2d70d366aa29949676e29abb6cdc85
7
+ data.tar.gz: a0f0e086045967cee236f4d98bd6131e5ca8c99daf7a150aa395c60dc56665bb3f4cc5a615d101480c1f8da752fd38a35893662ebf4cda02f49f008aeb6bc081
data/README.md CHANGED
@@ -65,6 +65,40 @@ Woods boots your Rails app, introspects everything using runtime APIs, and write
65
65
 
66
66
  Your `User` model includes `Auditable`, `Searchable`, and `SoftDeletable`. An AI tool reading `app/models/user.rb` sees 40 lines. Woods inlines all three concerns directly into the extracted unit — the AI sees the full 200-line behavioral surface area in one block.
67
67
 
68
+ ```ruby
69
+ # What your AI sees (app/models/user.rb) — 4 lines:
70
+ class User < ApplicationRecord
71
+ include Auditable
72
+ include Searchable
73
+ end
74
+
75
+ # What Woods produces — full source with schema + inlined concerns:
76
+ # == Schema Information
77
+ # email :string not null
78
+ # name :string
79
+ #
80
+ # class User < ApplicationRecord
81
+ # include Auditable
82
+ # include Searchable
83
+ # validates :email, presence: true, uniqueness: true
84
+ # ...
85
+ # end
86
+ #
87
+ # ┌─────────────────────────────────────────────────────────────────────┐
88
+ # │ Included from: Auditable │
89
+ # └─────────────────────────────────────────────────────────────────────┘
90
+ # def audit_trail ...
91
+ # ─────────────────────────── End Auditable ───────────────────────────
92
+ #
93
+ # ┌─────────────────────────────────────────────────────────────────────┐
94
+ # │ Included from: Searchable │
95
+ # └─────────────────────────────────────────────────────────────────────┘
96
+ # scope :search, ->(q) { where("name ILIKE ?", "%#{q}%") }
97
+ # ─────────────────────────── End Searchable ───────────────────────────
98
+ ```
99
+
100
+ The `metadata[:inlined_concerns]` array lists which concerns were resolved, so retrieval can filter by concern inclusion.
101
+
68
102
  ### Schema Prepending
69
103
 
70
104
  Model source gets a header with actual column types, indexes, and foreign keys pulled from the live database. No more guessing whether `name` is a `string` or `text`, or whether there's an index on `email`.
@@ -83,6 +117,122 @@ Controller source gets a route map prepended showing the real HTTP verb + path +
83
117
 
84
118
  ---
85
119
 
120
+ ## Examples
121
+
122
+ ### Extracted Model with Schema and Associations
123
+
124
+ After extraction, each model is a self-contained JSON file with schema, associations, validations, and inlined concern source:
125
+
126
+ ```json
127
+ {
128
+ "type": "model",
129
+ "identifier": "Order",
130
+ "file_path": "app/models/order.rb",
131
+ "source_code": "# == Schema Information\n# id :bigint not null, pk\n# user_id :bigint not null, fk\n# status :string default(\"pending\")\n# total_cents :integer\n#\nclass Order < ApplicationRecord\n belongs_to :user\n has_many :line_items\n validates :status, inclusion: { in: %w[pending paid shipped] }\n ...\nend\n\n# ┌───────────────────────────────────────────────────────────────────┐\n# │ Included from: Auditable │\n# └───────────────────────────────────────────────────────────────────┘\n# module Auditable\n# ...\n# end\n# ──────────────────────── End Auditable ────────────────────────────",
132
+ "metadata": {
133
+ "associations": [
134
+ { "type": "belongs_to", "name": "user", "target": "User" },
135
+ { "type": "has_many", "name": "line_items", "target": "LineItem" }
136
+ ],
137
+ "validations": [
138
+ { "attribute": "status", "type": "inclusion", "options": { "in": ["pending", "paid", "shipped"] } }
139
+ ],
140
+ "enums": { "status": { "pending": 0, "active": 1, "shipped": 2 } },
141
+ "scopes": [{ "name": "active", "source": "-> { where(status: :active) }" }],
142
+ "inlined_concerns": ["Auditable"]
143
+ },
144
+ "dependencies": [
145
+ { "type": "model", "target": "User", "via": "belongs_to" },
146
+ { "type": "model", "target": "LineItem", "via": "has_many" }
147
+ ]
148
+ }
149
+ ```
150
+
151
+ ### Callback Chain with Side-Effects
152
+
153
+ Woods resolves the full callback chain in execution order and detects side-effects — which columns get written, which jobs get enqueued, which mailers fire:
154
+
155
+ ```json
156
+ "callbacks": [
157
+ { "type": "before_validation", "filter": "normalize_email", "kind": "before", "conditions": {} },
158
+ { "type": "before_save", "filter": "set_slug", "kind": "before", "conditions": {},
159
+ "side_effects": { "columns_written": ["slug"], "jobs_enqueued": [], "services_called": [], "mailers_triggered": [], "database_reads": [], "operations": [] } },
160
+ { "type": "after_commit", "filter": "send_welcome", "kind": "after", "conditions": {},
161
+ "side_effects": { "columns_written": [], "jobs_enqueued": ["WelcomeEmailJob"], "services_called": [], "mailers_triggered": ["UserMailer"], "database_reads": [], "operations": [] } }
162
+ ]
163
+ ```
164
+
165
+ Side-effects are detected by `CallbackAnalyzer`, which scans callback method bodies for patterns like `self.col =` (column writes), `perform_later` (job enqueues), and `deliver_later` (mailer triggers). This is the #1 thing AI tools get wrong about Rails models.
166
+
167
+ ### Route-to-Controller Lookup
168
+
169
+ Every route becomes its own `ExtractedUnit` with the controller and action bound from the live routing table:
170
+
171
+ ```json
172
+ {
173
+ "type": "route",
174
+ "identifier": "POST /checkout",
175
+ "metadata": {
176
+ "controller": "orders",
177
+ "action": "create",
178
+ "route_name": "checkout"
179
+ }
180
+ }
181
+ ```
182
+
183
+ To find which controller handles a URL, use the MCP `search` tool:
184
+
185
+ ```json
186
+ { "tool": "search", "params": { "query": "/checkout", "types": ["route"] } }
187
+ ```
188
+
189
+ This returns all matching route units with their controller and action — no guessing about custom routes, nested resources, or engine mount points.
190
+
191
+ ### Looking Up a Model's Full Structure
192
+
193
+ Use the MCP `lookup` tool to get a model's complete JSON representation — schema, associations, validations, callbacks, and inlined concerns in one call:
194
+
195
+ ```json
196
+ { "tool": "lookup", "params": { "identifier": "Order", "include_source": true } }
197
+ ```
198
+
199
+ Returns the full `ExtractedUnit` JSON shown in the example above, including `source_code` (with schema header and inlined concerns), `metadata` (associations, callbacks, validations, enums, scopes), `dependencies`, and `dependents`.
200
+
201
+ To get just the structured metadata without source code:
202
+
203
+ ```json
204
+ { "tool": "lookup", "params": { "identifier": "Order", "include_source": false, "sections": ["metadata"] } }
205
+ ```
206
+
207
+ ### Finding Jobs Enqueued by a Service
208
+
209
+ Use the MCP `dependencies` tool to trace what a service triggers:
210
+
211
+ ```json
212
+ { "tool": "dependencies", "params": { "identifier": "CheckoutService", "depth": 2, "types": ["job"] } }
213
+ ```
214
+
215
+ Returns all job units reachable from `CheckoutService` within 2 hops — including jobs triggered indirectly via model callbacks (e.g., `CheckoutService` → `Order` → `OrderConfirmationJob`).
216
+
217
+ ### Runtime-Generated Method Detection
218
+
219
+ Because Woods runs inside the booted Rails process, it captures every method Rails generates dynamically — enum predicates, association builders, attribute accessors, and scope methods that static analysis tools cannot see:
220
+
221
+ ```json
222
+ {
223
+ "identifier": "Order",
224
+ "metadata": {
225
+ "enums": { "status": { "pending": 0, "active": 1, "shipped": 2 } },
226
+ "scopes": [{ "name": "active", "source": "-> { where(status: :active) }" }],
227
+ "associations": [{ "type": "has_many", "name": "line_items", "target": "LineItem" }]
228
+ }
229
+ }
230
+ ```
231
+
232
+ Static tools miss `status_active?`, `status_pending?`, `build_line_item`, `create_line_item!`, and dynamically registered scopes. Woods captures all of these because it queries the runtime class via `instance_methods(false)` after Rails has processed every DSL declaration.
233
+
234
+ ---
235
+
86
236
  ## Connect to Your AI Tool
87
237
 
88
238
  Woods ships two MCP servers. Most users only need the **Index Server**.
@@ -223,6 +373,26 @@ Woods is backend-agnostic. Your app database, vector store, embedding provider,
223
373
 
224
374
  See [Backend Matrix](docs/BACKEND_MATRIX.md) for supported combinations and [Configuration Reference](docs/CONFIGURATION_REFERENCE.md) for every option with defaults.
225
375
 
376
+ ### Environment-Specific Configuration
377
+
378
+ ```ruby
379
+ Woods.configure do |config|
380
+ config.output_dir = Rails.root.join('tmp/woods')
381
+
382
+ # CI: only extract models and controllers for faster builds
383
+ config.extractors = %i[models controllers] if ENV['CI']
384
+
385
+ # Environment-conditional embedding provider
386
+ if ENV['OPENAI_API_KEY']
387
+ config.embedding_provider = :openai
388
+ config.embedding_options = { api_key: ENV['OPENAI_API_KEY'] }
389
+ else
390
+ config.embedding_provider = :ollama
391
+ config.embedding_options = { base_url: 'http://localhost:11434' }
392
+ end
393
+ end
394
+ ```
395
+
226
396
  ---
227
397
 
228
398
  ## Keeping the Index Current
@@ -289,12 +459,15 @@ Everything flows through `ExtractedUnit` — the universal data structure. Each
289
459
  |-------|-----------------|
290
460
  | `identifier` | Class name or descriptive key (`"User"`, `"POST /orders"`) |
291
461
  | `type` | Category (`:model`, `:controller`, `:service`, `:job`, etc.) |
462
+ | `file_path` | Source file location relative to Rails root |
463
+ | `namespace` | Module namespace (`"Admin"`, `nil` for top-level) |
292
464
  | `source_code` | Annotated source with inlined concerns and schema |
293
465
  | `metadata` | Structured data — associations, callbacks, routes, fields |
294
466
  | `dependencies` | What this unit depends on (forward edges) |
295
467
  | `dependents` | What depends on this unit (reverse edges) |
296
468
  | `chunks` | Semantic sub-sections for large units |
297
- | `estimated_tokens` | Token count for LLM context budgeting |
469
+ | `extracted_at` | ISO 8601 timestamp of extraction |
470
+ | `source_hash` | SHA-256 digest for change detection |
298
471
 
299
472
  ### Output Structure
300
473
 
@@ -323,7 +496,7 @@ tmp/woods/
323
496
  │ │
324
497
  │ ┌────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
325
498
  │ │ Extract │───>│ Resolve │───>│ Write JSON │ │
326
- │ │ 34 types │ │ graph + │ │ per unit │ │
499
+ │ │ 33 types │ │ graph + │ │ per unit │ │
327
500
  │ │ │ │ git data │ │ │ │
328
501
  │ └────────────┘ └─────────────┘ └──────────────────────┘ │
329
502
  └──────────────────────────────────────────────────────────────────┘
@@ -17,6 +17,10 @@ require_relative '../lib/woods/console/server'
17
17
  config_path = ENV.fetch('WOODS_CONSOLE_CONFIG', File.expand_path('~/.woods/console.yml'))
18
18
  config = File.exist?(config_path) ? YAML.safe_load_file(config_path) : {}
19
19
 
20
+ # Suppress json-schema MultiJSON deprecation notice that would pollute stderr
21
+ # during MCP stdio transport. The notice fires when multi_json is in the bundle.
22
+ JSON::Validator.use_multi_json = false if defined?(JSON::Validator) && JSON::Validator.respond_to?(:use_multi_json=)
23
+
20
24
  server = Woods::Console::Server.build(config: config)
21
25
  transport = MCP::Server::Transports::StdioTransport.new(server)
22
26
  transport.open
data/exe/woods-mcp CHANGED
@@ -19,6 +19,10 @@ require_relative '../lib/woods/mcp/bootstrapper'
19
19
  require_relative '../lib/woods/embedding/text_preparer'
20
20
  require_relative '../lib/woods/embedding/indexer'
21
21
 
22
+ # Suppress json-schema MultiJSON deprecation notice that would pollute stderr
23
+ # during MCP stdio transport. The notice fires when multi_json is in the bundle.
24
+ JSON::Validator.use_multi_json = false if defined?(JSON::Validator) && JSON::Validator.respond_to?(:use_multi_json=)
25
+
22
26
  index_dir = Woods::MCP::Bootstrapper.resolve_index_dir(ARGV)
23
27
  retriever = Woods::MCP::Bootstrapper.build_retriever
24
28
  snapshot_store = Woods::MCP::Bootstrapper.build_snapshot_store(index_dir)
@@ -327,6 +327,9 @@ module Woods
327
327
  callbacks: extract_callbacks(model),
328
328
  scopes: extract_scopes(model, source),
329
329
  enums: extract_enums(model),
330
+ inlined_concerns: extract_included_modules(model)
331
+ .select { |mod| mod.name && concern_source(mod) }
332
+ .map { |mod| mod.name.demodulize },
330
333
 
331
334
  # API surface
332
335
  class_methods: model.methods(false).sort,
@@ -611,7 +614,7 @@ module Woods
611
614
  def extract_dependencies(model, source = nil)
612
615
  # Associations point to other models
613
616
  deps = model.reflect_on_all_associations.filter_map do |assoc|
614
- { type: :model, target: assoc.class_name, via: :association }
617
+ { type: :model, target: assoc.class_name, via: assoc.macro }
615
618
  rescue NameError => e
616
619
  @warnings << "[#{model.name}] Skipping broken association dep #{assoc.name}: #{e.message}"
617
620
  nil
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: woods
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Armstrong
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-13 00:00:00.000000000 Z
11
+ date: 2026-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mcp