woods 2.0.1 → 2.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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -7
  3. data/CONTRIBUTING.md +134 -19
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -25
  10. data/docs/CONSOLE_MCP_SETUP.md +82 -30
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +20 -15
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +153 -38
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  33. data/lib/tasks/woods.rake +23 -7
  34. data/lib/tasks/woods_checks.rake +2 -2
  35. data/lib/woods/agent_configuration/cli.rb +1 -1
  36. data/lib/woods/agent_configuration/layout.rb +16 -2
  37. data/lib/woods/agent_configuration/plan.rb +13 -3
  38. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  39. data/lib/woods/agent_configuration/preflight.rb +5 -3
  40. data/lib/woods/builder.rb +17 -57
  41. data/lib/woods/cache/cache_middleware.rb +56 -30
  42. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  43. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  44. data/lib/woods/console/connection_manager.rb +56 -3
  45. data/lib/woods/console/embedded_executor.rb +30 -5
  46. data/lib/woods/console/rack_middleware.rb +29 -1
  47. data/lib/woods/dependency_graph.rb +34 -10
  48. data/lib/woods/embedding/fake.rb +12 -0
  49. data/lib/woods/embedding/indexer.rb +195 -98
  50. data/lib/woods/embedding/input_budget.rb +67 -0
  51. data/lib/woods/embedding/openai.rb +70 -20
  52. data/lib/woods/embedding/provider.rb +37 -25
  53. data/lib/woods/embedding/text_preparer.rb +76 -32
  54. data/lib/woods/embedding/token_counter.rb +18 -81
  55. data/lib/woods/embedding/vector_configuration.rb +48 -0
  56. data/lib/woods/extraction_identities.rb +175 -0
  57. data/lib/woods/extractor.rb +304 -107
  58. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  59. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  60. data/lib/woods/extractors/class_declarations.rb +121 -0
  61. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  62. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  63. data/lib/woods/extractors/event_extractor.rb +8 -0
  64. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  65. data/lib/woods/extractors/job_extractor.rb +5 -1
  66. data/lib/woods/extractors/lib_extractor.rb +132 -15
  67. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  68. data/lib/woods/extractors/manager_extractor.rb +7 -21
  69. data/lib/woods/extractors/migration_declaration.rb +87 -0
  70. data/lib/woods/extractors/migration_extractor.rb +5 -39
  71. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  72. data/lib/woods/extractors/policy_extractor.rb +9 -5
  73. data/lib/woods/extractors/poro_extractor.rb +112 -53
  74. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  75. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  76. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  77. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  78. data/lib/woods/extractors/source_nesting.rb +142 -106
  79. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  80. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  81. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  82. data/lib/woods/flow_assembler.rb +4 -1
  83. data/lib/woods/generation.rb +25 -0
  84. data/lib/woods/hooks/context_hint.rb +7 -2
  85. data/lib/woods/mcp/bootstrapper.rb +33 -7
  86. data/lib/woods/mcp/config_resolver.rb +26 -7
  87. data/lib/woods/mcp/index_reader.rb +125 -24
  88. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  89. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  90. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  91. data/lib/woods/mcp/search_results.rb +7 -1
  92. data/lib/woods/mcp/server.rb +24 -4
  93. data/lib/woods/module_reconciliation.rb +151 -0
  94. data/lib/woods/path_dispatcher.rb +7 -2
  95. data/lib/woods/rake_helpers.rb +43 -11
  96. data/lib/woods/release.rb +1 -1
  97. data/lib/woods/resilience/index_validator.rb +8 -3
  98. data/lib/woods/resilience/retryable_provider.rb +18 -1
  99. data/lib/woods/resolved_config.rb +68 -8
  100. data/lib/woods/retrieval/context_assembler.rb +3 -3
  101. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  102. data/lib/woods/retrieval/scope.rb +18 -2
  103. data/lib/woods/retrieval/source_evidence.rb +14 -2
  104. data/lib/woods/source_contributor_validation.rb +78 -0
  105. data/lib/woods/source_contributors.rb +116 -0
  106. data/lib/woods/source_inputs/handoff.rb +37 -0
  107. data/lib/woods/source_inputs/launcher.rb +53 -13
  108. data/lib/woods/source_inputs/manifest.rb +84 -3
  109. data/lib/woods/source_inputs/private_key.rb +44 -12
  110. data/lib/woods/source_inputs/scanner.rb +98 -27
  111. data/lib/woods/source_inputs/scopes.rb +1 -1
  112. data/lib/woods/source_inputs/session.rb +147 -15
  113. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  114. data/lib/woods/source_inputs/status.rb +40 -8
  115. data/lib/woods/source_inputs/verifier.rb +28 -5
  116. data/lib/woods/source_path_encoding.rb +33 -0
  117. data/lib/woods/source_references/cache.rb +284 -0
  118. data/lib/woods/source_references/collector.rb +120 -0
  119. data/lib/woods/source_references/extraction.rb +185 -0
  120. data/lib/woods/source_references/inputs.rb +134 -0
  121. data/lib/woods/source_references/parser_adapter.rb +134 -0
  122. data/lib/woods/source_references/pass.rb +152 -0
  123. data/lib/woods/source_references/prism_adapter.rb +116 -0
  124. data/lib/woods/source_references/registry.rb +178 -0
  125. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  126. data/lib/woods/source_references/value_class.rb +82 -0
  127. data/lib/woods/storage/metadata_store.rb +4 -1
  128. data/lib/woods/storage/qdrant.rb +2 -2
  129. data/lib/woods/unblocked/client.rb +12 -7
  130. data/lib/woods/unblocked/document_builder.rb +4 -1
  131. data/lib/woods/unblocked/exporter.rb +127 -37
  132. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  133. data/lib/woods/unblocked/uri_migration.rb +105 -0
  134. data/lib/woods/util/host_guard.rb +3 -2
  135. data/lib/woods/version.rb +1 -1
  136. data/lib/woods/watch/catch_up.rb +138 -0
  137. data/lib/woods/watch/claim_lease.rb +150 -0
  138. data/lib/woods/watch/cli.rb +26 -2
  139. data/lib/woods/watch/daemon.rb +80 -59
  140. data/lib/woods/watch/installation/options.rb +1 -1
  141. data/lib/woods/watch/installation/receipt.rb +6 -1
  142. data/lib/woods/watch/managed_child.rb +1 -1
  143. data/lib/woods/watch/supervisor.rb +1 -1
  144. data/lib/woods/watch/tree_scan.rb +14 -2
  145. data/plugin/.claude-plugin/plugin.json +1 -1
  146. data/plugin/hooks/adapters/normalize.rb +3 -2
  147. data/plugin/hooks/woods-input-rules.sh +4 -0
  148. data/plugin/hooks/woods-refresh.sh +15 -7
  149. data/plugin/hooks/woods-session-start.sh +60 -3
  150. data/plugin/skills/woods-diagnose/SKILL.md +334 -11
  151. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  152. data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
  153. data/plugin/skills/woods-setup/SKILL.md +53 -6
  154. metadata +32 -5
@@ -5,19 +5,20 @@ require_relative '../source_inputs/consumer_errors'
5
5
  require_relative 'shared_utility_methods'
6
6
  require_relative 'shared_dependency_scanner'
7
7
  require_relative 'source_nesting'
8
+ require_relative '../source_references/runtime_lookup'
8
9
 
9
10
  module Woods
10
11
  module Extractors
11
- # GraphQLExtractor handles graphql-ruby type and mutation extraction.
12
+ # GraphQLExtractor handles graphql-ruby schema, type and mutation extraction.
12
13
  #
13
14
  # GraphQL schemas are rich in structure — types, fields, arguments,
14
15
  # resolvers, and mutations form a typed API layer over the domain.
15
16
  # We extract these with runtime introspection when available (via
16
- # `GraphQL::Schema.types`) and fall back to file-based discovery
17
- # when the schema isn't fully loadable.
17
+ # `GraphQL::Schema.types`) and retain limited file-based discovery
18
+ # when graphql-ruby is unavailable.
18
19
  #
19
20
  # We extract:
20
- # - Object types, input types, enum types, interface types, union types, scalar types
21
+ # - Schema classes and object, input, enum, interface, union and scalar types
21
22
  # - Mutations and their arguments/return fields
22
23
  # - Query fields and resolvers
23
24
  # - Standalone resolver classes
@@ -43,28 +44,38 @@ module Woods
43
44
 
44
45
  def initialize
45
46
  @graphql_dir = defined?(Rails) ? Rails.root.join(GRAPHQL_DIRECTORY) : nil
46
- @schema_class = find_schema_class
47
+ @runtime_lookup = SourceReferences::RuntimeLookup.new
48
+ @runtime_discovery_complete = true
49
+ @schema_classes = find_schema_classes
50
+ @query_roots = []
47
51
  @runtime_types = load_runtime_types
48
52
  end
49
53
 
50
- # The type classes runtime introspection can reach.
51
- #
52
- # Shared with the incremental path's class reconciliation (#167), which
53
- # uses it for **additions only**. A runtime-defined type — built by a
54
- # schema builder or a DSL rather than a literal `class ... <
55
- # Types::BaseObject` — has no source file, so no changed path ever
56
- # dispatches to it and `extract_graphql_file` cannot reproduce it. Only a
57
- # full extraction emitted such units, which meant an incremental run
58
- # could never create them and an index built incrementally from empty
59
- # never had them at all.
54
+ # Current application schemas and the named type classes they expose.
55
+ # Incremental reconciliation adds runtime-defined units that the file
56
+ # pass cannot discover and updates known types whose query-root role
57
+ # changed. It does not infer removals from absence in this inventory.
60
58
  #
61
59
  # Empty when graphql-ruby is not loaded or the app has no schema, which is
62
60
  # why the reconciliation must not read absence here as deletion — see
63
61
  # `reconcile_removals: false` in {Extractor::CLASS_BASED_DISCOVERY}.
64
62
  #
65
- # @return [Array<Class>]
63
+ # @return [Array<Class, Module>]
66
64
  def discoverable_classes
67
- @runtime_types.values.reject { |type_class| type_class.name.nil? }
65
+ (@schema_classes + @runtime_types.values).uniq(&:name)
66
+ end
67
+
68
+ # @return [Boolean] whether every discovered schema exposed its inventory
69
+ def runtime_discovery_complete?
70
+ @runtime_discovery_complete
71
+ end
72
+
73
+ # Shared with incremental reconciliation when a schema changes the role
74
+ # of an unchanged type (ordinary object versus query root).
75
+ # @param klass [Class, Module] current loaded GraphQL declaration
76
+ # @return [Symbol, nil] existing public GraphQL unit type
77
+ def runtime_unit_type(klass)
78
+ classify_runtime_type(klass) if current_runtime_class?(klass) && graphql_runtime_class?(klass)
68
79
  end
69
80
 
70
81
  # Extract all GraphQL types, mutations, queries, and resolvers
@@ -72,28 +83,24 @@ module Woods
72
83
  # Returns an empty array when the app has neither an `app/graphql`
73
84
  # directory nor a loadable schema class.
74
85
  #
75
- # Deliberately **not** gated on graphql-ruby being loaded. The file pass
76
- # is static — a regex over source plus a `safe_constantize` that is
77
- # allowed to fail — so it works perfectly well without the gem, and
78
- # `app/graphql/*.rb` is real source an agent wants indexed either way.
79
- # Gating it produced a genuine full-vs-incremental divergence: the
80
- # `PathDispatcher` rule routes those paths at `extract_graphql_file`,
81
- # which never had the gate, so an app whose GraphQL gem was not loaded at
82
- # extraction time got its types indexed incrementally and dropped by a
83
- # full run. Runtime introspection stays optional and additive — it makes
84
- # the units richer when the gem is there, it does not decide whether they
85
- # exist.
86
+ # Without graphql-ruby, the file pass retains its limited source-form
87
+ # fallback. With loaded declarations, verified GraphQL ancestry governs
88
+ # admission, alongside legacy Ruby Resolvers::Base helpers; unresolved
89
+ # constants are never autoloaded by this extractor.
86
90
  #
87
91
  # @return [Array<ExtractedUnit>] List of GraphQL units
88
92
  def extract_all
93
+ unless runtime_discovery_complete?
94
+ raise Woods::ExtractionError, 'GraphQL runtime discovery incomplete; fix the logged schema error and retry'
95
+ end
89
96
  return [] unless graphql_source_present?
90
97
 
91
98
  units = []
92
99
  seen_identifiers = Set.new
93
100
 
94
101
  # First pass: runtime introspection (most accurate)
95
- if @runtime_types.any?
96
- @runtime_types.each_value do |type_class|
102
+ if discoverable_classes.any?
103
+ discoverable_classes.each do |type_class|
97
104
  unit = extract_from_runtime_type(type_class)
98
105
  next unless unit
99
106
  next if seen_identifiers.include?(unit.identifier)
@@ -103,7 +110,7 @@ module Woods
103
110
  end
104
111
  end
105
112
 
106
- # Second pass: file-based discovery (catches everything)
113
+ # Second pass: governed declarations, including unattached resolvers.
107
114
  if @graphql_dir&.directory?
108
115
  Dir[@graphql_dir.join('**/*.rb')].each do |file_path|
109
116
  unit = extract_graphql_file(file_path)
@@ -124,13 +131,16 @@ module Woods
124
131
  # @return [ExtractedUnit, nil] The extracted unit, or nil if the file
125
132
  # does not contain a recognizable GraphQL class
126
133
  def extract_graphql_file(file_path)
134
+ SourceInputs::ConsumerErrors.record(self) unless runtime_discovery_complete?
127
135
  source = File.read(file_path)
128
136
  class_name = extract_class_name(file_path, source)
129
137
 
130
138
  return nil unless class_name
131
- return nil unless graphql_class?(source)
132
139
 
133
- runtime_class = class_name.safe_constantize
140
+ runtime_class = loaded_graphql_constant(class_name)
141
+ return nil if runtime_class && !graphql_runtime_class?(runtime_class) && !ruby_resolver_helper?(runtime_class)
142
+ return nil unless runtime_class || graphql_class?(source)
143
+
134
144
  # Classify from the resolved runtime class first, matching
135
145
  # {#extract_from_runtime_type}'s classifier. Keeps the two passes
136
146
  # from disagreeing on a type's unit_type (a mutation that looks like
@@ -173,9 +183,11 @@ module Woods
173
183
  # @param type_class [Class] A graphql-ruby type class
174
184
  # @return [ExtractedUnit, nil]
175
185
  def extract_from_runtime_type(type_class)
186
+ SourceInputs::ConsumerErrors.record(self) unless runtime_discovery_complete?
176
187
  return nil unless type_class.respond_to?(:name) && type_class.name
177
188
  # Skip anonymous or internal graphql-ruby classes
178
189
  return nil if type_class.name.start_with?('GraphQL::')
190
+ return nil unless current_runtime_class?(type_class) && graphql_runtime_class?(type_class)
179
191
 
180
192
  file_path = source_file_for_class(type_class)
181
193
  source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
@@ -216,70 +228,113 @@ module Woods
216
228
  def graphql_source_present?
217
229
  return true if @graphql_dir&.directory?
218
230
 
219
- !@schema_class.nil?
231
+ @schema_classes.any?
220
232
  end
221
233
 
222
- # Find the application's schema class (descendant of GraphQL::Schema)
234
+ # Find every current, named application schema. Stale reload descendants
235
+ # and schemas defined in installed gems cannot claim application ownership.
223
236
  #
224
- # @return [Class, nil]
225
- def find_schema_class
226
- return nil unless defined?(GraphQL::Schema)
237
+ # @return [Array<Class>]
238
+ def find_schema_classes
239
+ return [] unless defined?(GraphQL::Schema)
227
240
 
228
- GraphQL::Schema.descendants.find do |klass|
229
- klass.name && !klass.name.start_with?('GraphQL::')
230
- end
231
- rescue StandardError
232
- nil
241
+ GraphQL::Schema.descendants.select do |klass|
242
+ next false unless klass.name && !klass.name.start_with?('GraphQL::') && current_runtime_class?(klass)
243
+
244
+ path = source_file_for_class(klass)
245
+ path.nil? || (File.expand_path(path).start_with?("#{Rails.root}/") && app_source?(path, Rails.root.to_s))
246
+ end.sort_by(&:name)
247
+ rescue StandardError => e
248
+ log_discovery_failure('schema inventory', e)
249
+ []
233
250
  end
234
251
 
235
252
  # Load types from the runtime schema for introspection
236
253
  #
237
254
  # @return [Hash{String => Class}] Map of type name to type class
238
255
  def load_runtime_types
239
- return {} unless @schema_class
240
-
241
256
  types = {}
242
- @schema_class.types.each do |name, type_class|
243
- # Skip built-in introspection types
244
- next if name.start_with?('__')
245
- next unless type_class.respond_to?(:name) && type_class.name
257
+ @schema_classes.each do |schema|
258
+ query = schema.query if schema.respond_to?(:query)
259
+ schema_types = {}
260
+ schema.types.each do |name, type_class|
261
+ next if name.start_with?('__') || !current_runtime_class?(type_class)
262
+ next unless graphql_runtime_class?(type_class)
263
+
264
+ # GraphQL names are schema-local; two schemas may both have Query.
265
+ schema_types[type_class.name] = type_class
266
+ end
267
+ types.merge!(schema_types)
268
+ @query_roots << query if query
269
+ rescue StandardError => e
270
+ log_discovery_failure(schema.name, e)
271
+ end
272
+ types
273
+ end
274
+
275
+ def log_discovery_failure(name, error)
276
+ @runtime_discovery_complete = false
277
+ SourceInputs::ConsumerErrors.record(self)
278
+ return unless defined?(Rails)
279
+
280
+ Rails.logger.warn("[Woods] GraphQL runtime discovery incomplete for #{name}: #{error.message}")
281
+ end
282
+
283
+ def loaded_graphql_constant(name)
284
+ result = @runtime_lookup.call("::#{name}", allow_private: true)
285
+ result[:value] if result[:status] == :resolved
286
+ end
287
+
288
+ def current_runtime_class?(klass)
289
+ return false unless @runtime_lookup.module_object?(klass)
290
+
291
+ name = @runtime_lookup.reflect(klass, :name)
292
+ name && SourceReferences::RuntimeLookup::CORE_EQUAL.bind(klass).call(loaded_graphql_constant(name))
293
+ end
294
+
295
+ def graphql_runtime_class?(klass)
296
+ return false unless defined?(GraphQL::Schema) && @runtime_lookup.module_object?(klass)
246
297
 
247
- types[name] = type_class
298
+ ancestors = @runtime_lookup.reflect(klass, :ancestors)
299
+ return true if ancestors.include?(GraphQL::Schema) && klass != GraphQL::Schema
300
+
301
+ %i[Object InputObject Enum Union Scalar Mutation Resolver Interface].any? do |name|
302
+ GraphQL::Schema.const_defined?(name, false) && ancestors.include?(GraphQL::Schema.const_get(name, false))
248
303
  end
304
+ end
249
305
 
250
- types
251
- rescue StandardError
252
- {}
306
+ # Older file discovery admitted ordinary Resolvers::Base subclasses.
307
+ # Keep those application helpers under their historical graphql_type
308
+ # identity even though schema introspection cannot discover them. Verify
309
+ # real inheritance; directory names and example strings are not evidence.
310
+ def ruby_resolver_helper?(klass)
311
+ return false unless @runtime_lookup.class_object?(klass)
312
+
313
+ base = loaded_graphql_constant('Resolvers::Base')
314
+ return false unless @runtime_lookup.class_object?(base)
315
+
316
+ @runtime_lookup.reflect(klass, :ancestors).drop(1).any? do |ancestor|
317
+ SourceReferences::RuntimeLookup::CORE_EQUAL.bind(ancestor).call(base)
318
+ end
253
319
  end
254
320
 
255
321
  # Locate the file this type was defined in, or nil when there is none.
256
322
  #
257
- # nil, not a fabricated convention path. A runtime-defined type
258
- # (built by a schema builder or DSL) has no file, and inventing
259
- # `app/graphql/<constant>.rb` for it created a unit indistinguishable from a
260
- # file-defined one whose file had since been deleted. That ambiguity is
261
- # unresolvable downstream: the conventional location of `Types::FooType`
262
- # *is* `app/graphql/types/foo_type.rb`, so no path comparison can separate
263
- # "never had a file" from "file was deleted".
264
- #
265
- # Returning nil resolves it at the only point where the answer is known.
266
- # `DependencyGraph#register` skips nil paths, so such a unit never enters
267
- # `file_map` and the deletion sweep — which walks registered paths — cannot
268
- # reach it. No predicate required, and a full extraction produces the same
269
- # nil, so the two paths still agree.
270
- #
271
- # `resolve_source_location` is tried first and has three tiers of real
272
- # discovery (`const_source_location`, then instance and singleton method
273
- # locations). It reaches its fallback only when Ruby cannot place the class
274
- # in app source at all, which is precisely the no-file case.
323
+ # Prefer the real definition or method location, including classes built
324
+ # in initializers, over a convention-named file. Fall back only to an
325
+ # existing convention path. A genuinely fileless type keeps nil and stays
326
+ # out of the graph's file_map and path-based deletion sweep.
275
327
  #
276
328
  # @param klass [Class]
277
329
  # @return [String, nil]
278
330
  def source_file_for_class(klass)
331
+ actual = resolve_source_location(klass, app_root: Rails.root.to_s, fallback: nil)
332
+ return actual if actual
333
+
279
334
  convention_path = Rails.root.join("#{GRAPHQL_DIRECTORY}/#{klass.name.underscore}.rb").to_s
280
335
  return convention_path if File.exist?(convention_path)
281
336
 
282
- resolve_source_location(klass, app_root: Rails.root.to_s, fallback: nil)
337
+ nil
283
338
  end
284
339
 
285
340
  # ──────────────────────────────────────────────────────────────────────
@@ -299,7 +354,7 @@ module Woods
299
354
  :graphql_type
300
355
  elsif defined?(GraphQL::Schema::Union) && type_class < GraphQL::Schema::Union
301
356
  :graphql_type
302
- elsif defined?(GraphQL::Schema::Interface) && type_class.is_a?(Module) && type_class.respond_to?(:fields)
357
+ elsif defined?(GraphQL::Schema::Interface) && type_class.ancestors.include?(GraphQL::Schema::Interface)
303
358
  :graphql_type
304
359
  elsif defined?(GraphQL::Schema::InputObject) && type_class < GraphQL::Schema::InputObject
305
360
  :graphql_type
@@ -307,7 +362,7 @@ module Woods
307
362
  :graphql_type
308
363
  elsif defined?(GraphQL::Schema::Object) && type_class < GraphQL::Schema::Object
309
364
  # Check if this is the Query root type
310
- if @schema_class.respond_to?(:query) && @schema_class.query == type_class
365
+ if @query_roots.include?(type_class)
311
366
  :graphql_query
312
367
  else
313
368
  :graphql_type
@@ -326,9 +381,9 @@ module Woods
326
381
  return :graphql_mutation if file_path.include?('/mutations/')
327
382
  return :graphql_resolver if file_path.include?('/resolvers/')
328
383
 
329
- return :graphql_mutation if source.match?(/< (GraphQL::Schema::Mutation|Mutations::Base|BaseMutation)/)
384
+ return :graphql_mutation if source.match?(/<\s*(?:::)?(GraphQL::Schema::Mutation|Mutations::Base|BaseMutation)/)
330
385
 
331
- return :graphql_resolver if source.match?(/< (GraphQL::Schema::Resolver|Resolvers::Base|BaseResolver)/)
386
+ return :graphql_resolver if source.match?(/<\s*(?:::)?(GraphQL::Schema::Resolver|Resolvers::Base|BaseResolver)/)
332
387
 
333
388
  # Query type is usually the root query object
334
389
  return :graphql_query if file_path.match?(/query_type\.rb$/) || source.match?(/class QueryType\b/)
@@ -341,7 +396,7 @@ module Woods
341
396
  # @param source [String]
342
397
  # @return [Boolean]
343
398
  def graphql_class?(source)
344
- source.match?(/< GraphQL::Schema::(Object|InputObject|Enum|Union|Scalar|Mutation|Resolver|Interface|RelayClassicMutation)/) ||
399
+ source.match?(/<\s*(?:::)?GraphQL::Schema(?:\b(?!::)|::(?:Object|InputObject|Enum|Union|Scalar|Mutation|Resolver|Interface|RelayClassicMutation)\b)/) ||
345
400
  source.match?(/< (Types::Base\w+|Base(Type|InputObject|Enum|Union|Scalar|Mutation|Resolver|Interface))/) ||
346
401
  source.match?(/< (Mutations::Base|Resolvers::Base)/) ||
347
402
  source.match?(/include GraphQL::Schema::Interface/) ||
@@ -484,6 +539,7 @@ module Woods
484
539
  # @return [Symbol]
485
540
  def detect_graphql_kind(source, runtime_class)
486
541
  if runtime_class
542
+ return :schema if defined?(GraphQL::Schema) && runtime_class < GraphQL::Schema
487
543
  return :enum if defined?(GraphQL::Schema::Enum) && runtime_class < GraphQL::Schema::Enum
488
544
  return :union if defined?(GraphQL::Schema::Union) && runtime_class < GraphQL::Schema::Union
489
545
  return :input_object if defined?(GraphQL::Schema::InputObject) && runtime_class < GraphQL::Schema::InputObject
@@ -497,6 +553,7 @@ module Woods
497
553
  end
498
554
 
499
555
  # Fall back to source analysis
556
+ return :schema if source.match?(/<\s*(?:::)?GraphQL::Schema\b(?!::)/)
500
557
  return :enum if source.match?(/< .*Enum\b/) || source.match?(/value\s+["']/)
501
558
  return :union if source.match?(/< .*Union\b/) || source.match?(/possible_types\s/)
502
559
  return :input_object if source.match?(/< .*InputObject\b/)
@@ -5,6 +5,7 @@ require_relative '../source_inputs/consumer_errors'
5
5
  require_relative 'reference_patterns'
6
6
  require_relative 'shared_utility_methods'
7
7
  require_relative 'shared_dependency_scanner'
8
+ require_relative '../source_references/runtime_lookup'
8
9
 
9
10
  module Woods
10
11
  module Extractors
@@ -82,7 +83,10 @@ module Woods
82
83
  def discoverable_classes
83
84
  return [] unless defined?(ApplicationJob)
84
85
 
85
- ApplicationJob.descendants.reject { |job_class| job_class.name.nil? }
86
+ lookup = SourceReferences::RuntimeLookup.new
87
+ ApplicationJob.descendants.select do |klass|
88
+ klass.name && lookup.call("::#{klass.name}", allow_private: true)[:value].equal?(klass)
89
+ end
86
90
  end
87
91
 
88
92
  # Extract a job from its file
@@ -5,6 +5,11 @@ require_relative '../source_inputs/consumer_errors'
5
5
  require_relative 'shared_utility_methods'
6
6
  require_relative 'shared_dependency_scanner'
7
7
  require_relative 'source_nesting'
8
+ require_relative '../source_references/collector'
9
+ require_relative 'assigned_value_discovery'
10
+ require_relative '../source_contributors'
11
+ require_relative '../source_references/registry'
12
+ require_relative 'class_declarations'
8
13
 
9
14
  module Woods
10
15
  module Extractors
@@ -51,25 +56,48 @@ module Woods
51
56
  #
52
57
  # @return [Array<ExtractedUnit>] List of lib units
53
58
  def extract_all
54
- return [] unless @lib_dir.directory?
55
-
56
- Dir[@lib_dir.join('**/*.rb')].filter_map do |file|
57
- next if excluded_path?(file)
58
-
59
- extract_lib_file(file)
60
- end
59
+ library_groups.flat_map { |group| aggregate(group) || group.map { |entry| copy_unit(entry[:unit]) } }
61
60
  end
62
61
 
63
- # Extract a single lib file.
62
+ # Extract the complete library owner containing this physical file.
64
63
  #
65
- # Returns nil if the file cannot be read or yields no extractable unit.
66
- # Module-only files are extracted (unlike some other extractors) since
67
- # lib/ commonly contains standalone utility modules.
64
+ # Returns nil when the file yields no extractable unit. Discovery errors
65
+ # or incompatible contributors refuse extraction instead of returning a
66
+ # partial owner. This entry point never loads application source.
68
67
  #
69
68
  # @param file_path [String] Absolute path to the Ruby file
70
- # @return [ExtractedUnit, nil] The extracted unit or nil on failure
69
+ # @return [ExtractedUnit, nil] The complete owner or nil when absent
70
+ # @raise [Woods::ExtractionError] when complete ownership cannot be established
71
71
  def extract_lib_file(file_path)
72
- source = File.read(file_path)
72
+ group = library_groups.find { |entries| entries.any? { |entry| entry[:unit].file_path == file_path.to_s } }
73
+ return unless group
74
+ return copy_unit(group.first[:unit]) if group.one?
75
+
76
+ aggregate(group) || raise(Woods::ExtractionError, "Ambiguous library owner: #{group.first[:unit].identifier}")
77
+ end
78
+
79
+ private
80
+
81
+ def library_groups
82
+ @library_groups ||= begin
83
+ files = @lib_dir.directory? ? Dir.glob('**/*.rb', base: @lib_dir).sort : []
84
+ entries = files.filter_map do |relative|
85
+ path = @lib_dir.join(relative).to_s
86
+ next if excluded_path?(path)
87
+
88
+ build_file(path)
89
+ end
90
+ if SourceInputs::ConsumerErrors.failed?(self)
91
+ raise Woods::ExtractionError, 'Cannot establish the complete library contributor set after a source failure'
92
+ end
93
+
94
+ entries.group_by { |entry| entry[:unit].identifier }.values
95
+ end
96
+ end
97
+
98
+ def build_file(file_path)
99
+ source = File.read(file_path, encoding: 'UTF-8')
100
+ raise Woods::ExtractionError, 'Source is not valid UTF-8' unless source.valid_encoding?
73
101
 
74
102
  class_name = infer_class_name(file_path, source)
75
103
  return nil unless class_name
@@ -87,13 +115,96 @@ module Woods
87
115
  unit.metadata = extract_metadata(source, parent_class)
88
116
  unit.dependencies = extract_dependencies(source)
89
117
 
90
- unit
118
+ { unit: unit, source: source, analysis: SourceReferences::Collector.new.call(source) }
91
119
  rescue StandardError => e
92
120
  SourceInputs::ConsumerErrors.log(self, "Failed to extract lib file #{file_path}: #{e.message}")
93
121
  nil
94
122
  end
95
123
 
96
- private
124
+ def copy_unit(original)
125
+ original.dup.tap do |unit|
126
+ unit.metadata = original.metadata.dup
127
+ unit.dependencies = original.dependencies.map(&:dup)
128
+ end
129
+ end
130
+
131
+ def aggregate(group)
132
+ return copy_unit(group.first[:unit]) if group.one?
133
+ return unless compatible_contributors?(group)
134
+
135
+ primary = group.first[:unit]
136
+ unit = ExtractedUnit.new(type: :lib, identifier: primary.identifier, file_path: primary.file_path)
137
+ unit.namespace = primary.namespace
138
+ unit.source_code = +''
139
+ records = group.map { |entry| append_contributor(unit, entry) }
140
+ unit.metadata = common_facts(group)
141
+ unit.metadata[:source_contributors_version] = SourceContributors::VERSION
142
+ unit.metadata[:source_contributors] = records
143
+ unit.metadata[:defined_in] = records.map { |record| record['file_path'] }
144
+ unit.dependencies = group.flat_map { |entry| entry[:unit].dependencies }.uniq
145
+ unit
146
+ end
147
+
148
+ def compatible_contributors?(group)
149
+ units = group.map { |entry| entry[:unit] }
150
+ sources = group.to_h { |entry| [entry[:unit].file_path, entry[:analysis]] }
151
+ registry = SourceReferences::Registry.new(units: units, sources: sources, root: Rails.root)
152
+ kinds = group.map do |entry|
153
+ unit = entry[:unit]
154
+ declarations = entry[:analysis].fetch('declarations', []).select { |decl| decl['owner'] == unit.identifier }
155
+ return false if declarations.empty? || declarations.any? { |decl| decl['constructor'] }
156
+ return false unless registry.owner?(unit.identifier, file_path: unit.file_path)
157
+
158
+ declarations.map { |decl| decl['kind'] }.uniq
159
+ end
160
+ kinds.flatten.uniq.one? && compatible_parents?(group)
161
+ end
162
+
163
+ def compatible_parents?(group)
164
+ parents = group.flat_map do |entry|
165
+ ClassDeclarations.read(entry[:source]).filter_map do |declaration|
166
+ next unless declaration[:identifier] == entry[:unit].identifier
167
+ next unless declaration[:node].superclass
168
+
169
+ parent = ClassDeclarations.parent_name(declaration)
170
+ return false unless parent
171
+
172
+ parent.delete_prefix('::')
173
+ end
174
+ end
175
+ parents.uniq.size <= 1
176
+ rescue ClassDeclarations::Unresolved
177
+ false
178
+ end
179
+
180
+ def append_contributor(unit, entry)
181
+ path = entry[:unit].file_path.delete_prefix("#{Rails.root}/")
182
+ source = entry[:source]
183
+ unit.source_code << "# Library contributor: #{path}\n"
184
+ start_byte = unit.source_code.bytesize
185
+ start_line = unit.source_code.count("\n") + 1
186
+ unit.source_code << source
187
+ record = { 'file_path' => path, 'source_sha256' => Digest::SHA256.hexdigest(source),
188
+ 'source_start_line' => 1, 'source_end_line' => source.lines.size,
189
+ 'published_start_byte' => start_byte, 'published_end_byte' => unit.source_code.bytesize,
190
+ 'published_start_line' => start_line, 'published_end_line' => start_line + source.lines.size - 1,
191
+ 'facts' => entry[:unit].metadata }
192
+ unit.source_code << "\n\n"
193
+ record
194
+ end
195
+
196
+ # Conflicting scalar/list facts remain available per contributor; sorted
197
+ # paths are display order and cannot establish runtime override order.
198
+ def common_facts(group)
199
+ facts = group.first[:unit].metadata.select do |key, value|
200
+ group.all? { |entry| entry[:unit].metadata[key] == value }
201
+ end
202
+ %i[public_methods class_methods entry_points].each do |key|
203
+ facts[key] = group.flat_map { |entry| entry[:unit].metadata.fetch(key) }.uniq
204
+ end
205
+ %i[loc method_count].each { |key| facts[key] = group.sum { |entry| entry[:unit].metadata.fetch(key) } }
206
+ facts
207
+ end
97
208
 
98
209
  # ──────────────────────────────────────────────────────────────────────
99
210
  # Path Filtering
@@ -135,6 +246,12 @@ module Woods
135
246
  # closed before the class opened, no longer pollutes the identifier
136
247
  # (#174). lib/ files are typically unmanaged, so the governed lookup
137
248
  # returns nil and the source scan decides.
249
+ analysis = SourceReferences::Collector.new.call(source)
250
+ expected = managed_constant_path(file_path.to_s) || path_based_class_name(file_path)
251
+ assigned = AssignedValueDiscovery.new.call(file_path, analysis: analysis, expected: expected,
252
+ preserve_modules: true)
253
+ return assigned if assigned
254
+
138
255
  qualified = governed_class_name(file_path, source) || qualified_first_class_name(source)
139
256
  return qualified if qualified
140
257
 
@@ -29,9 +29,7 @@ module Woods
29
29
  include RouteHelperResolver
30
30
 
31
31
  def initialize
32
- if defined?(ActionMailer::Base)
33
- @mailer_base = defined?(ApplicationMailer) ? ApplicationMailer : ActionMailer::Base
34
- end
32
+ @mailer_base = ActionMailer::Base if defined?(ActionMailer::Base)
35
33
  build_route_helper_map
36
34
  end
37
35
 
@@ -90,11 +88,11 @@ module Woods
90
88
  # Discovery and direct incremental extraction must agree on ownership;
91
89
  # otherwise a gem class with a fabricated path becomes a phantom unit.
92
90
  def app_defined_mailer?(mailer)
93
- return false unless @mailer_base && mailer.name
91
+ return false unless @mailer_base && mailer.name && mailer < @mailer_base
94
92
  return false if mailer == ActionMailer::Base
95
93
 
96
94
  path = source_file_for(mailer)
97
- path && File.file?(path) && app_source?(path, Rails.root.to_s)
95
+ app_source_file?(path)
98
96
  end
99
97
 
100
98
  # Locate the source file for a mailer class.
@@ -4,6 +4,7 @@ require_relative '../source_inputs/consumer_errors'
4
4
 
5
5
  require_relative 'shared_utility_methods'
6
6
  require_relative 'shared_dependency_scanner'
7
+ require_relative 'declaration_ancestry'
7
8
 
8
9
  module Woods
9
10
  module Extractors
@@ -56,7 +57,9 @@ module Woods
56
57
  class_name = extract_class_name(file_path, source, 'managers')
57
58
 
58
59
  return nil unless class_name
59
- return nil unless manager_file?(source)
60
+
61
+ ancestry = DeclarationAncestry.new(source: source, identifier: class_name, file_path: file_path)
62
+ return nil unless ancestry.manager?
60
63
 
61
64
  unit = ExtractedUnit.new(
62
65
  type: :manager,
@@ -66,7 +69,7 @@ module Woods
66
69
 
67
70
  unit.namespace = extract_namespace(class_name)
68
71
  unit.source_code = annotate_source(source, class_name)
69
- unit.metadata = extract_metadata(source, class_name)
72
+ unit.metadata = extract_metadata(source, class_name, ancestry)
70
73
  unit.dependencies = extract_dependencies(source, class_name)
71
74
 
72
75
  unit
@@ -77,16 +80,6 @@ module Woods
77
80
 
78
81
  private
79
82
 
80
- # ──────────────────────────────────────────────────────────────────────
81
- # Class Discovery
82
- # ──────────────────────────────────────────────────────────────────────
83
-
84
- def manager_file?(source)
85
- source.match?(/< SimpleDelegator/) ||
86
- source.match?(/< DelegateClass\(/) ||
87
- source.match?(/include Delegator/)
88
- end
89
-
90
83
  # ──────────────────────────────────────────────────────────────────────
91
84
  # Source Annotation
92
85
  # ──────────────────────────────────────────────────────────────────────
@@ -108,10 +101,10 @@ module Woods
108
101
  # Metadata Extraction
109
102
  # ──────────────────────────────────────────────────────────────────────
110
103
 
111
- def extract_metadata(source, class_name)
104
+ def extract_metadata(source, class_name, ancestry)
112
105
  {
113
106
  wrapped_model: detect_wrapped_model(source, class_name),
114
- delegation_type: detect_delegation_type(source),
107
+ delegation_type: ancestry.delegation_type,
115
108
  public_methods: extract_public_methods(source),
116
109
  class_methods: extract_class_methods(source),
117
110
  initialize_params: extract_initialize_params(source),
@@ -162,13 +155,6 @@ module Woods
162
155
  name.respond_to?(:camelize) ? name.camelize : name.split('_').map(&:capitalize).join
163
156
  end
164
157
 
165
- def detect_delegation_type(source)
166
- return :delegate_class if source.match?(/< DelegateClass\(/)
167
- return :simple_delegator if source.match?(/< SimpleDelegator/)
168
-
169
- :unknown
170
- end
171
-
172
158
  def extract_delegated_methods(source)
173
159
  methods = []
174
160