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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -7
- data/CONTRIBUTING.md +134 -19
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -25
- data/docs/CONSOLE_MCP_SETUP.md +82 -30
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +20 -15
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +153 -38
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +56 -30
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/embedded_executor.rb +30 -5
- data/lib/woods/console/rack_middleware.rb +29 -1
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +334 -11
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
- data/plugin/skills/woods-setup/SKILL.md +53 -6
- 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
|
|
17
|
-
# when
|
|
17
|
+
# `GraphQL::Schema.types`) and retain limited file-based discovery
|
|
18
|
+
# when graphql-ruby is unavailable.
|
|
18
19
|
#
|
|
19
20
|
# We extract:
|
|
20
|
-
# -
|
|
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
|
-
@
|
|
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
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
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
|
|
96
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
231
|
+
@schema_classes.any?
|
|
220
232
|
end
|
|
221
233
|
|
|
222
|
-
# Find
|
|
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
|
|
225
|
-
def
|
|
226
|
-
return
|
|
237
|
+
# @return [Array<Class>]
|
|
238
|
+
def find_schema_classes
|
|
239
|
+
return [] unless defined?(GraphQL::Schema)
|
|
227
240
|
|
|
228
|
-
GraphQL::Schema.descendants.
|
|
229
|
-
klass.name && !klass.name.start_with?('GraphQL::')
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
@
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
#
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
#
|
|
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
|
-
|
|
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.
|
|
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 @
|
|
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?(
|
|
384
|
+
return :graphql_mutation if source.match?(/<\s*(?:::)?(GraphQL::Schema::Mutation|Mutations::Base|BaseMutation)/)
|
|
330
385
|
|
|
331
|
-
return :graphql_resolver if source.match?(
|
|
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?(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
62
|
+
# Extract the complete library owner containing this physical file.
|
|
64
63
|
#
|
|
65
|
-
# Returns nil
|
|
66
|
-
#
|
|
67
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|