woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -57,7 +57,12 @@ module Woods
57
57
  # @param event_map [Hash] Mutable map of event_name => {publishers:, subscribers:, pattern:}
58
58
  # @return [void]
59
59
  def scan_file(file_path, event_map)
60
- source = File.read(file_path)
60
+ source = cached_source(file_path)
61
+ unless source
62
+ Rails.logger.error("Failed to scan #{file_path} for events: file unreadable")
63
+ return
64
+ end
65
+
61
66
  scan_active_support_notifications(source, file_path, event_map)
62
67
  scan_wisper_patterns(source, file_path, event_map)
63
68
  rescue StandardError => e
@@ -97,16 +102,43 @@ module Woods
97
102
  # @return [void]
98
103
  def scan_wisper_patterns(source, file_path, event_map)
99
104
  if source.match?(/include\s+Wisper/)
100
- source.scan(/\b(?:publish|broadcast)\s+:(\w+)/) do |m|
105
+ # `\s*\(?\s*` accepts both call styles. Requiring whitespace before
106
+ # the symbol missed `broadcast(:order_created, order)` — Wisper's
107
+ # README-canonical form — so no publisher was registered and, with
108
+ # no subscriber naming the event either, the event unit did not
109
+ # exist at all (EXTB-3). The AS::Notifications and `.on(` scans
110
+ # already accept parens.
111
+ source.scan(/\b(?:publish|broadcast)\s*\(?\s*:(\w+)/) do |m|
101
112
  register_publisher(event_map, m[0], file_path, :wisper)
102
113
  end
103
114
  end
104
115
 
116
+ # `.on(:sym)` is far too common a shape to treat as evidence on its own:
117
+ # `socket.on(:message)`, `emitter.on(:close)`, and every other
118
+ # callback-registration API in the ecosystem match it, each minting a
119
+ # phantom event unit and its edges. Publishers were already gated on
120
+ # Wisper context in the file; subscribers were not. Require the file to
121
+ # mention Wisper somewhere before believing a bare `.on`.
122
+ return unless wisper_context?(source)
123
+
105
124
  source.scan(/\.on\s*\(\s*:(\w+)/) do |m|
106
125
  register_subscriber(event_map, m[0], file_path, :wisper)
107
126
  end
108
127
  end
109
128
 
129
+ # Does this file give any indication it is using Wisper?
130
+ #
131
+ # Deliberately broader than the publisher gate's `include Wisper` —
132
+ # subscribers commonly live in files that reference Wisper without
133
+ # including it (`Wisper.subscribe(listener)`, `extend
134
+ # Wisper::Publisher`, a global-listener initializer).
135
+ #
136
+ # @param source [String] Ruby source code
137
+ # @return [Boolean]
138
+ def wisper_context?(source)
139
+ source.match?(/\bWisper\b/)
140
+ end
141
+
110
142
  # ──────────────────────────────────────────────────────────────────────
111
143
  # Event Map Mutation
112
144
  # ──────────────────────────────────────────────────────────────────────
@@ -179,11 +211,30 @@ module Woods
179
211
  # @param file_paths [Array<String>] File paths to read
180
212
  # @return [String] Combined source
181
213
  def load_source_files(file_paths)
182
- file_paths.filter_map do |path|
214
+ file_paths.filter_map { |path| cached_source(path) }.join("\n")
215
+ end
216
+
217
+ # One read per distinct path per extractor instance (audit P2).
218
+ #
219
+ # Pass 2 ({#build_unit}) recombines the same publisher/subscriber files
220
+ # for every event that references them, so a widely-shared file was
221
+ # re-read once per event on top of the pass-1 {#scan_file} read. The
222
+ # bytes cannot change mid-run, so the first read answers the rest.
223
+ #
224
+ # A failed read is memoized as nil, matching the per-event skip it
225
+ # produced before; the output is identical either way.
226
+ #
227
+ # @param path [String] File path
228
+ # @return [String, nil] File contents, or nil when unreadable
229
+ def cached_source(path)
230
+ cache = (@source_files ||= {})
231
+ return cache[path] if cache.key?(path)
232
+
233
+ cache[path] = begin
183
234
  File.read(path)
184
235
  rescue StandardError
185
236
  nil
186
- end.join("\n")
237
+ end
187
238
  end
188
239
 
189
240
  # Build annotated source annotation for the event unit.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'line_neutralizer'
3
4
  require_relative 'shared_utility_methods'
4
5
  require_relative 'shared_dependency_scanner'
5
6
 
@@ -64,6 +65,13 @@ module Woods
64
65
  # transient attributes. Each factory block (including nested factories within
65
66
  # a parent factory) produces one entry in the returned array.
66
67
  #
68
+ # Block accounting runs against the {LineNeutralizer}-neutralized line —
69
+ # comment, string, and heredoc bodies blanked — while names are still
70
+ # read from the raw line, where their literals live. Counting keywords in
71
+ # raw source meant `title { "things to do" }` opened a `do` block nothing
72
+ # closed; two such attributes left the factory on the stack at EOF and
73
+ # the unit was dropped entirely (EXTB-2).
74
+ #
67
75
  # @param source [String] Factory file source code
68
76
  # @return [Array<Hash>] Parsed factory data hashes
69
77
  def parse_factories(source)
@@ -72,26 +80,38 @@ module Woods
72
80
  depth = 0
73
81
  in_transient = false
74
82
  transient_depth = nil
83
+ scannable = LineNeutralizer.neutralize_lines(source)
75
84
 
76
85
  source.lines.each_with_index do |line, index|
77
86
  stripped = line.strip
78
-
79
- # Factory definition — push new factory onto stack
87
+ code = scannable[index].to_s.strip
88
+ next if code.empty?
89
+
90
+ # Factory definition. A factory with a block goes on the stack and is
91
+ # completed when its `end` brings depth back; a blockless one —
92
+ # `factory :admin, parent: :user` — has nothing to wait for and is
93
+ # complete as soon as it is parsed. Requiring a block used to drop
94
+ # those silently, which is the very shape the parent-inheritance
95
+ # parsing below exists to support.
80
96
  if (factory_data = match_factory(stripped, depth, index + 1))
81
- factory_stack.push(factory_data)
82
- depth += 1
97
+ if opens_block?(code)
98
+ factory_stack.push(factory_data)
99
+ depth += 1
100
+ else
101
+ completed << factory_data
102
+ end
83
103
  next
84
104
  end
85
105
 
86
106
  # Trait definition — record trait in current factory, open block
87
- if (trait_match = stripped.match(/\Atrait\s+:(\w+)\s+do/))
107
+ if (trait_match = code.match(/\Atrait\s+:(\w+)\s+do/))
88
108
  factory_stack.last[:traits] << trait_match[1] if factory_stack.any?
89
109
  depth += 1
90
110
  next
91
111
  end
92
112
 
93
113
  # Transient block — start collecting transient attributes
94
- if stripped.match?(/\Atransient\s+do/)
114
+ if code.match?(/\Atransient\s+do/)
95
115
  in_transient = true
96
116
  transient_depth = depth
97
117
  depth += 1
@@ -119,12 +139,12 @@ module Woods
119
139
  end
120
140
 
121
141
  # Generic block openers — factory/trait/transient already handled above with next
122
- if block_opener?(stripped)
142
+ if block_opener?(code)
123
143
  depth += 1
124
144
  next
125
145
  end
126
146
 
127
- next unless stripped == 'end'
147
+ next unless code == 'end'
128
148
 
129
149
  depth -= 1
130
150
 
@@ -150,13 +170,17 @@ module Woods
150
170
  # factory :name, class: ClassName do
151
171
  # factory :name, class: 'ClassName' do
152
172
  # factory :name, parent: :other do
173
+ # factory :name, parent: :other # blockless
174
+ #
175
+ # Whether the line opens a block is the caller's concern — see
176
+ # {#parse_factories}.
153
177
  #
154
178
  # @param line [String] Stripped source line
155
179
  # @param depth [Integer] Current block depth when factory would be opened
156
180
  # @param line_number [Integer] 1-based line number
157
181
  # @return [Hash, nil] Initialized factory data or nil if not a factory line
158
182
  def match_factory(line, depth, line_number)
159
- return nil unless line.match?(/\Afactory\s+:/) && line.match?(/\bdo\b/)
183
+ return nil unless line.match?(/\Afactory\s+:/)
160
184
 
161
185
  name_match = line.match(/\Afactory\s+:(\w+)/)
162
186
  return nil unless name_match
@@ -194,6 +218,19 @@ module Woods
194
218
  name.split('_').map(&:capitalize).join
195
219
  end
196
220
 
221
+ # Does this factory line open a `do` block?
222
+ #
223
+ # Separate from {#block_opener?}, which deliberately excludes factory
224
+ # lines so they can carry their own depth bookkeeping. A factory without
225
+ # a block (`factory :admin, parent: :user`) is complete on the line it
226
+ # appears and must not push a frame nothing will pop.
227
+ #
228
+ # @param stripped [String] Stripped line content
229
+ # @return [Boolean]
230
+ def opens_block?(stripped)
231
+ stripped.match?(/\bdo\b/)
232
+ end
233
+
197
234
  # Check if a stripped line opens a new block.
198
235
  #
199
236
  # Excludes factory, trait, and transient lines — those are handled
@@ -207,7 +244,7 @@ module Woods
207
244
  return false if stripped.match?(/\Atransient\s+do/)
208
245
  return true if stripped.match?(/\b(do|def|case|begin|class|module|while|until|for)\b.*(?<!\bend)\s*$/)
209
246
 
210
- stripped.match?(/\A(if|unless)\b/)
247
+ stripped.match?(/(?:\A|=\s*)(?:if|unless)\b.*(?<!\bend)\s*$/)
211
248
  end
212
249
 
213
250
  # Build an ExtractedUnit from parsed factory data.
@@ -254,7 +291,8 @@ module Woods
254
291
  sequences: factory_data[:sequences],
255
292
  parent_factory: factory_data[:parent_factory],
256
293
  callbacks: factory_data[:callbacks].uniq,
257
- transient_attributes: factory_data[:transient_attributes]
294
+ transient_attributes: factory_data[:transient_attributes],
295
+ line_number: factory_data[:line_number]
258
296
  }
259
297
  end
260
298
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'shared_utility_methods'
4
4
  require_relative 'shared_dependency_scanner'
5
+ require_relative 'source_nesting'
5
6
 
6
7
  module Woods
7
8
  module Extractors
@@ -30,6 +31,7 @@ module Woods
30
31
  class GraphQLExtractor
31
32
  include SharedUtilityMethods
32
33
  include SharedDependencyScanner
34
+ include SourceNesting
33
35
 
34
36
  # Standard directory for graphql-ruby applications
35
37
  GRAPHQL_DIRECTORY = 'app/graphql'
@@ -43,14 +45,46 @@ module Woods
43
45
  @runtime_types = load_runtime_types
44
46
  end
45
47
 
48
+ # The type classes runtime introspection can reach.
49
+ #
50
+ # Shared with the incremental path's class reconciliation (#167), which
51
+ # uses it for **additions only**. A runtime-defined type — built by a
52
+ # schema builder or a DSL rather than a literal `class ... <
53
+ # Types::BaseObject` — has no source file, so no changed path ever
54
+ # dispatches to it and `extract_graphql_file` cannot reproduce it. Only a
55
+ # full extraction emitted such units, which meant an incremental run
56
+ # could never create them and an index built incrementally from empty
57
+ # never had them at all.
58
+ #
59
+ # Empty when graphql-ruby is not loaded or the app has no schema, which is
60
+ # why the reconciliation must not read absence here as deletion — see
61
+ # `reconcile_removals: false` in {Extractor::CLASS_BASED_DISCOVERY}.
62
+ #
63
+ # @return [Array<Class>]
64
+ def discoverable_classes
65
+ @runtime_types.values.reject { |type_class| type_class.name.nil? }
66
+ end
67
+
46
68
  # Extract all GraphQL types, mutations, queries, and resolvers
47
69
  #
48
- # Returns an empty array if graphql-ruby is not installed or
49
- # no GraphQL files are found.
70
+ # Returns an empty array when the app has neither an `app/graphql`
71
+ # directory nor a loadable schema class.
72
+ #
73
+ # Deliberately **not** gated on graphql-ruby being loaded. The file pass
74
+ # is static — a regex over source plus a `safe_constantize` that is
75
+ # allowed to fail — so it works perfectly well without the gem, and
76
+ # `app/graphql/*.rb` is real source an agent wants indexed either way.
77
+ # Gating it produced a genuine full-vs-incremental divergence: the
78
+ # `PathDispatcher` rule routes those paths at `extract_graphql_file`,
79
+ # which never had the gate, so an app whose GraphQL gem was not loaded at
80
+ # extraction time got its types indexed incrementally and dropped by a
81
+ # full run. Runtime introspection stays optional and additive — it makes
82
+ # the units richer when the gem is there, it does not decide whether they
83
+ # exist.
50
84
  #
51
85
  # @return [Array<ExtractedUnit>] List of GraphQL units
52
86
  def extract_all
53
- return [] unless graphql_available?
87
+ return [] unless graphql_source_present?
54
88
 
55
89
  units = []
56
90
  seen_identifiers = Set.new
@@ -94,8 +128,14 @@ module Woods
94
128
  return nil unless class_name
95
129
  return nil unless graphql_class?(source)
96
130
 
97
- unit_type = classify_unit_type(file_path, source)
98
131
  runtime_class = class_name.safe_constantize
132
+ # Classify from the resolved runtime class first, matching
133
+ # {#extract_from_runtime_type}'s classifier. Keeps the two passes
134
+ # from disagreeing on a type's unit_type (a mutation that looks like
135
+ # a type by path/regex) and leaving a stale duplicate unit behind.
136
+ # Falls back to the source/path heuristic when nothing resolves
137
+ # (gem not loaded, autoloading not booted).
138
+ unit_type = runtime_class ? classify_runtime_type(runtime_class) : classify_unit_type(file_path, source)
99
139
 
100
140
  unit = ExtractedUnit.new(
101
141
  type: unit_type,
@@ -115,20 +155,62 @@ module Woods
115
155
  nil
116
156
  end
117
157
 
158
+ # Extract a unit from a runtime-loaded GraphQL type class.
159
+ #
160
+ # Public because {Extractor::CLASS_BASED_DISCOVERY} names it as the
161
+ # graphql entry's `method:` and the reconciler invokes it with
162
+ # `public_send` (#167). Private, it raised NoMethodError for every
163
+ # candidate — swallowed by the reconciler's `rescue StandardError` into a
164
+ # warn — so the incremental path silently added nothing, which is exactly
165
+ # what #167 set out to fix. `spec/extractor_spec.rb` asserts every
166
+ # CLASS_BASED_DISCOVERY method is publicly callable on its real
167
+ # extractor so no future entry can regress the same way.
168
+ #
169
+ # @param type_class [Class] A graphql-ruby type class
170
+ # @return [ExtractedUnit, nil]
171
+ def extract_from_runtime_type(type_class)
172
+ return nil unless type_class.respond_to?(:name) && type_class.name
173
+ # Skip anonymous or internal graphql-ruby classes
174
+ return nil if type_class.name.start_with?('GraphQL::')
175
+
176
+ file_path = source_file_for_class(type_class)
177
+ source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
178
+ unit_type = classify_runtime_type(type_class)
179
+
180
+ unit = ExtractedUnit.new(
181
+ type: unit_type,
182
+ identifier: type_class.name,
183
+ file_path: file_path
184
+ )
185
+
186
+ unit.namespace = extract_namespace(type_class.name)
187
+ unit.source_code = build_annotated_source(source, type_class.name, unit_type, type_class)
188
+ unit.metadata = build_metadata(source, type_class.name, unit_type, type_class)
189
+ unit.dependencies = extract_dependencies(source, type_class.name)
190
+ unit.chunks = build_chunks(unit, type_class) if unit.needs_chunking?(threshold: CHUNK_THRESHOLD)
191
+
192
+ unit
193
+ rescue StandardError => e
194
+ Rails.logger.error("Failed to extract GraphQL type #{type_class.name}: #{e.message}") if defined?(Rails)
195
+ nil
196
+ end
197
+
118
198
  private
119
199
 
120
200
  # ──────────────────────────────────────────────────────────────────────
121
201
  # Schema and Runtime Discovery
122
202
  # ──────────────────────────────────────────────────────────────────────
123
203
 
124
- # Check if graphql-ruby is available at runtime
204
+ # Does this app have GraphQL source to extract?
205
+ #
206
+ # Note what this does *not* ask: whether graphql-ruby is loaded. See
207
+ # {#extract_all} — the gem enriches the units, it does not gate them.
125
208
  #
126
209
  # @return [Boolean]
127
- def graphql_available?
128
- return false unless defined?(GraphQL::Schema)
129
- return false unless @graphql_dir&.directory? || @schema_class
210
+ def graphql_source_present?
211
+ return true if @graphql_dir&.directory?
130
212
 
131
- true
213
+ !@schema_class.nil?
132
214
  end
133
215
 
134
216
  # Find the application's schema class (descendant of GraphQL::Schema)
@@ -164,54 +246,34 @@ module Woods
164
246
  {}
165
247
  end
166
248
 
167
- # ──────────────────────────────────────────────────────────────────────
168
- # Runtime Type Extraction
169
- # ──────────────────────────────────────────────────────────────────────
170
-
171
- # Extract a unit from a runtime-loaded GraphQL type class
249
+ # Locate the file this type was defined in, or nil when there is none.
172
250
  #
173
- # @param type_class [Class] A graphql-ruby type class
174
- # @return [ExtractedUnit, nil]
175
- def extract_from_runtime_type(type_class)
176
- return nil unless type_class.respond_to?(:name) && type_class.name
177
- # Skip anonymous or internal graphql-ruby classes
178
- return nil if type_class.name.start_with?('GraphQL::')
179
-
180
- file_path = source_file_for_class(type_class)
181
- source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
182
- unit_type = classify_runtime_type(type_class)
183
-
184
- unit = ExtractedUnit.new(
185
- type: unit_type,
186
- identifier: type_class.name,
187
- file_path: file_path
188
- )
189
-
190
- unit.namespace = extract_namespace(type_class.name)
191
- unit.source_code = build_annotated_source(source, type_class.name, unit_type, type_class)
192
- unit.metadata = build_metadata(source, type_class.name, unit_type, type_class)
193
- unit.dependencies = extract_dependencies(source, type_class.name)
194
- unit.chunks = build_chunks(unit, type_class) if unit.needs_chunking?(threshold: CHUNK_THRESHOLD)
195
-
196
- unit
197
- rescue StandardError => e
198
- Rails.logger.error("Failed to extract GraphQL type #{type_class.name}: #{e.message}") if defined?(Rails)
199
- nil
200
- end
201
-
202
- # Determine the source file for a runtime-loaded class, validating that
203
- # paths are within Rails.root to avoid returning graphql gem internals.
251
+ # nil, not a fabricated convention path. A runtime-defined type
252
+ # (built by a schema builder or DSL) has no file, and inventing
253
+ # `app/graphql/<constant>.rb` for it created a unit indistinguishable from a
254
+ # file-defined one whose file had since been deleted. That ambiguity is
255
+ # unresolvable downstream: the conventional location of `Types::FooType`
256
+ # *is* `app/graphql/types/foo_type.rb`, so no path comparison can separate
257
+ # "never had a file" from "file was deleted".
204
258
  #
205
- # Convention path first, then introspection via {#resolve_source_location}
206
- # which filters out vendor/node_modules paths.
259
+ # Returning nil resolves it at the only point where the answer is known.
260
+ # `DependencyGraph#register` skips nil paths, so such a unit never enters
261
+ # `file_map` and the deletion sweep — which walks registered paths — cannot
262
+ # reach it. No predicate required, and a full extraction produces the same
263
+ # nil, so the two paths still agree.
264
+ #
265
+ # `resolve_source_location` is tried first and has three tiers of real
266
+ # discovery (`const_source_location`, then instance and singleton method
267
+ # locations). It reaches its fallback only when Ruby cannot place the class
268
+ # in app source at all, which is precisely the no-file case.
207
269
  #
208
270
  # @param klass [Class]
209
- # @return [String] Absolute path to the source file
271
+ # @return [String, nil]
210
272
  def source_file_for_class(klass)
211
273
  convention_path = Rails.root.join("#{GRAPHQL_DIRECTORY}/#{klass.name.underscore}.rb").to_s
212
274
  return convention_path if File.exist?(convention_path)
213
275
 
214
- resolve_source_location(klass, app_root: Rails.root.to_s, fallback: convention_path)
276
+ resolve_source_location(klass, app_root: Rails.root.to_s, fallback: nil)
215
277
  end
216
278
 
217
279
  # ──────────────────────────────────────────────────────────────────────
@@ -286,19 +348,32 @@ module Woods
286
348
 
287
349
  # Extract the fully-qualified class name from source or file path
288
350
  #
351
+ # The identifier is the *outermost* class: the first `class` declaration
352
+ # qualified by the modules actually open at that position, via the
353
+ # position-aware nesting scanner ({SourceNesting}, #174). Declarations
354
+ # after the first `class` are nested — joining every declaration in the
355
+ # file made a mutation with an inner `class InvalidInput < StandardError`
356
+ # identify as `Mutations::CreateUser::InvalidInput`, so the file-pass
357
+ # unit never deduped against the runtime unit in {#extract_all}:
358
+ # double-indexing with graphql-ruby loaded, dangling edges without it
359
+ # (#202). Tracking nesting by position also stops a sibling module that
360
+ # closed *before* the class opened from polluting the prefix — the old
361
+ # end-blind scan turned `module Helpers ... end; module Mutations; class
362
+ # CreateUser` into `Helpers::Mutations::CreateUser`. Module-only files
363
+ # (e.g. interfaces) keep their identifier via the outer module chain.
364
+ #
289
365
  # @param file_path [String]
290
366
  # @param source [String]
291
367
  # @return [String, nil]
292
368
  def extract_class_name(file_path, source)
293
- # Build from nested module/class declarations
294
- modules = source.scan(/^\s*(?:module|class)\s+([\w:]+)/).flatten
295
- return nil if modules.empty?
296
-
297
- # If first token is a fully-qualified name, use it directly
298
- return modules.first if modules.first.include?('::')
299
-
300
- # Otherwise join the nesting
301
- modules.join('::')
369
+ # Zeitwerk-governed naming first (G-1) — managed graphql/ files are
370
+ # named for the constant their path spells, and the expected path can
371
+ # only match the outer, file-named declaration, so the #202
372
+ # outermost-class contract below is preserved by construction. Then
373
+ # the position-aware nesting scan (#174), then the outer module
374
+ # chain for module-only files (interfaces).
375
+ governed_class_name(file_path, source) ||
376
+ qualified_first_class_name(source) || qualified_outer_module_name(source)
302
377
  rescue StandardError
303
378
  # Fall back to convention from file path
304
379
  return nil unless defined?(Rails)
@@ -636,13 +711,20 @@ module Woods
636
711
  def extract_complexity(source)
637
712
  complexities = []
638
713
 
639
- source.scan(/field\s+:(\w+).*?complexity:\s*(\d+|->.*?(?:end|\}))/m) do |name, value|
714
+ # `[^\n]*?` keeps the field name and its `complexity:` on one
715
+ # declaration line. With `.*?` under /m the match crossed
716
+ # declarations, so a field with no complexity absorbed the *next*
717
+ # field's — and the real owner lost it, because `scan` resumes after
718
+ # the match (EXTB-8). The lambda value may still span lines.
719
+ source.scan(/field\s+:(\w+)[^\n]*?complexity:\s*(\d+|->.*?(?:end|\}))/m) do |name, value|
640
720
  complexities << { field: name, complexity: value.strip }
641
721
  end
642
722
 
643
- # Max complexity on schema level
644
- if source.match?(/max_complexity\s+(\d+)/)
645
- complexities << { field: :schema, complexity: ::Regexp.last_match(1).to_i }
723
+ # Max complexity on schema level. `match`, not `match?` — the latter
724
+ # never populates `$~`/`Regexp.last_match`, so the capture always
725
+ # read back as 0 (or a stale match from elsewhere in the method).
726
+ if (schema_match = source.match(/max_complexity\s+(\d+)/))
727
+ complexities << { field: :schema, complexity: schema_match[1].to_i }
646
728
  end
647
729
 
648
730
  complexities
@@ -750,15 +832,29 @@ module Woods
750
832
  deps << { type: :model, target: model_ref, via: :code_reference }
751
833
  end
752
834
 
753
- source.scan(/\b([A-Z][a-z][a-zA-Z]*)\b/).flatten.uniq.each do |const_ref|
835
+ # The per-constant follow-up check used to run one full-source scan
836
+ # for every unique capitalized constant (audit P9c). One combined
837
+ # scan collects the constants that are actually followed by a model
838
+ # call; only the exact word at a position can match (each candidate
839
+ # is a maximal word in this source and `\.` must immediately follow),
840
+ # so the collected set is identical. Candidate order drives emission
841
+ # below, unchanged.
842
+ candidates = source.scan(/\b([A-Z][a-z][a-zA-Z]*)\b/).flatten.uniq
843
+ model_callers = if candidates.empty?
844
+ {}
845
+ else
846
+ source.scan(
847
+ /\b(#{candidates.map { |c| Regexp.escape(c) }.join('|')})\.(?:find|where|find_by|create|new|first|last|all)\b/
848
+ ).flatten.to_h { |const| [const, true] }
849
+ end
850
+
851
+ candidates.each do |const_ref|
754
852
  if const_ref.match?(/\A(Types|Mutations|Resolvers|GraphQL|Base|String|Integer|Float|Boolean|Array|Hash|Set|Struct|Module|Class|Object|ID|Int|ISO8601)\z/)
755
853
  next
756
854
  end
757
855
  next if deps.any? { |d| d[:target] == const_ref }
758
856
 
759
- if source.match?(/\b#{Regexp.escape(const_ref)}\.(?:find|where|find_by|create|new|first|last|all)\b/)
760
- deps << { type: :model, target: const_ref, via: :code_reference }
761
- end
857
+ deps << { type: :model, target: const_ref, via: :code_reference } if model_callers.key?(const_ref)
762
858
  end
763
859
 
764
860
  deps.concat(scan_service_dependencies(source))
@@ -45,7 +45,12 @@ module Woods
45
45
  # @return [ExtractedUnit, nil] The extracted unit or nil on failure
46
46
  def extract_i18n_file(file_path)
47
47
  source = File.read(file_path)
48
- data = YAML.safe_load(source, permitted_classes: [Symbol, Date, Time, Regexp])
48
+ # aliases: true locale files commonly share defaults via anchors
49
+ # (`<<: *defaults`); without it Psych 4+ raises AliasesNotEnabled and
50
+ # the whole file is silently dropped (#203). Alias expansion only
51
+ # re-references the scalar/hash types already permitted, so it adds
52
+ # no deserialization risk.
53
+ data = YAML.safe_load(source, permitted_classes: [Symbol, Date, Time, Regexp], aliases: true)
49
54
 
50
55
  return nil unless data.is_a?(Hash) && data.any?
51
56